改用相机网格生成笔画纹理
统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。 已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
+33
-170
@@ -1,10 +1,5 @@
|
||||
"""Run with python -m pytest tests/test_stroke_pattern.py (no Blender needed).
|
||||
"""Host and Blender stroke configuration must stay in sync."""
|
||||
|
||||
The stroke generator runs under Blender and this module does not, so the two copies of the
|
||||
tuning names and defaults cannot be shared by import. These tests pin them against each other by
|
||||
parsing the script, which is the only way drift can be caught rather than silently changing what
|
||||
a run produces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
@@ -15,177 +10,45 @@ import pytest
|
||||
import yaml
|
||||
|
||||
from orisys_sensor_design import stroke_pattern
|
||||
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
||||
from orisys_sensor_design.manifest import repo_root
|
||||
|
||||
SCRIPT = repo_root() / "blender" / "scripts" / "random_stroke_pattern.py"
|
||||
SCRIPT = repo_root() / "blender" / "scripts" / "camera_grid_stroke.py"
|
||||
|
||||
|
||||
def script_constant(name: str):
|
||||
"""Read a module-level literal out of the Blender script without importing it.
|
||||
|
||||
The script imports `bpy` at module scope, so it cannot be imported here. Parsing keeps this
|
||||
a real check on the file rather than on a copy of it.
|
||||
"""
|
||||
def _constant(name: str):
|
||||
tree = ast.parse(SCRIPT.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name)]
|
||||
if any(t.id == name for t in targets):
|
||||
return ast.literal_eval(node.value)
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
if node.target.id == name:
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError(f"{name} is not a module-level literal in {SCRIPT.name}")
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id == name for target in node.targets
|
||||
):
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- drift against the generator
|
||||
|
||||
|
||||
def test_defaults_match_the_generator() -> None:
|
||||
"""The one test that matters: the host and the script must agree on every value."""
|
||||
assert script_constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
|
||||
|
||||
|
||||
def test_key_set_matches_the_generator() -> None:
|
||||
assert set(script_constant("DEFAULT_PARAMS")) == set(stroke_pattern.STROKE_KEYS)
|
||||
|
||||
|
||||
def test_mode_matches_the_generator() -> None:
|
||||
assert script_constant("MODE") == stroke_pattern.STROKE_MODE
|
||||
|
||||
|
||||
def test_declared_defaults_pass_our_own_validation() -> None:
|
||||
"""A default the host would reject would make an untouched revision fail validation."""
|
||||
assert stroke_pattern.validate_stroke_block(stroke_pattern.STROKE_DEFAULTS) == []
|
||||
|
||||
|
||||
def test_the_script_no_longer_offers_other_modes() -> None:
|
||||
"""Pins the simplification: reviving a second mode must be deliberate, not drift."""
|
||||
assert script_constant("MODE") == "camera_compensated_width"
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
for removed in ("PHYSICAL_WIDTH_MM", "camera_scaled_size", "--modes"):
|
||||
assert removed not in source, f"{removed} should be gone after the mode reduction"
|
||||
|
||||
|
||||
def test_the_host_cannot_import_the_generator() -> None:
|
||||
"""Why the two copies exist: the script needs bpy, so the host may never import it."""
|
||||
assert "import bpy" in SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------ validation
|
||||
|
||||
|
||||
def test_absent_block_is_valid() -> None:
|
||||
assert stroke_pattern.validate_stroke_block(None) == []
|
||||
|
||||
|
||||
def test_block_must_be_a_mapping() -> None:
|
||||
errors = stroke_pattern.validate_stroke_block(["seed"])
|
||||
assert errors and "must be a mapping" in errors[0]
|
||||
|
||||
|
||||
def test_unknown_key_is_rejected_by_name() -> None:
|
||||
errors = stroke_pattern.validate_stroke_block({"width_fracton": 0.5})
|
||||
assert any("width_fracton" in error for error in errors)
|
||||
assert any("allowed" in error for error in errors)
|
||||
|
||||
|
||||
def test_non_numeric_is_rejected() -> None:
|
||||
errors = stroke_pattern.validate_stroke_block({"seed": "seven"})
|
||||
assert any("must be a number" in error for error in errors)
|
||||
|
||||
|
||||
def test_boolean_is_not_a_number() -> None:
|
||||
"""`off`/`on` parse to booleans in YAML 1.1, which is a trap worth naming."""
|
||||
errors = stroke_pattern.validate_stroke_block({"length_ratio": True})
|
||||
assert any("must be a number" in error for error in errors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value", "expected"),
|
||||
[
|
||||
("min_cell_pixels", 0, "must be >= 1"),
|
||||
("jitter_fraction", 1.5, "within [0, 1]"),
|
||||
("jitter_fraction", -0.1, "within [0, 1]"),
|
||||
("angle_span_deg", -1.0, "must be >= 0"),
|
||||
("width_fraction", 0.0, "must be > 0"),
|
||||
("width_min_mm", -0.1, "must be > 0"),
|
||||
("length_ratio", 0.0, "must be > 0"),
|
||||
("coverage_cap", 0.0, "within (0, 1]"),
|
||||
("coverage_cap", 1.5, "within (0, 1]"),
|
||||
("neighbourhood_mm", 0.0, "must be > 0"),
|
||||
("clearance_mm", -0.01, "must be >= 0"),
|
||||
("candidate_count", 0, "must be >= 1"),
|
||||
("target_coverage", -0.01, "within [0, 1)"),
|
||||
("target_coverage", 1.0, "within [0, 1)"),
|
||||
],
|
||||
)
|
||||
def test_range_rules(key: str, value, expected: str) -> None:
|
||||
errors = stroke_pattern.validate_stroke_block({key: value})
|
||||
assert any(expected in error for error in errors), errors
|
||||
|
||||
|
||||
def test_seed_must_be_an_integer() -> None:
|
||||
errors = stroke_pattern.validate_stroke_block({"seed": 7.5})
|
||||
assert any("integer" in error for error in errors)
|
||||
|
||||
|
||||
def test_inverted_width_clamp_is_rejected_and_explains_itself() -> None:
|
||||
"""min > max would make every mark take the minimum, silently."""
|
||||
errors = stroke_pattern.validate_stroke_block(
|
||||
{"width_min_mm": 0.4, "width_max_mm": 0.2}
|
||||
)
|
||||
assert any("clamp inverts" in error for error in errors)
|
||||
|
||||
|
||||
def test_width_bounds_are_compared_after_merging_with_defaults() -> None:
|
||||
"""Raising only the minimum must still be caught against the default maximum."""
|
||||
errors = stroke_pattern.validate_stroke_block({"width_min_mm": 0.5})
|
||||
assert any("clamp inverts" in error for error in errors)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------- merge
|
||||
|
||||
|
||||
def test_empty_block_yields_the_defaults() -> None:
|
||||
assert stroke_pattern.resolve_stroke_settings(None) == stroke_pattern.STROKE_DEFAULTS
|
||||
assert stroke_pattern.resolve_stroke_settings({}) == stroke_pattern.STROKE_DEFAULTS
|
||||
|
||||
|
||||
def test_partial_block_keeps_its_siblings() -> None:
|
||||
resolved = stroke_pattern.resolve_stroke_settings({"length_ratio": 4.0})
|
||||
assert resolved["length_ratio"] == 4.0
|
||||
assert resolved["width_fraction"] == stroke_pattern.STROKE_DEFAULTS["width_fraction"]
|
||||
assert set(resolved) == set(stroke_pattern.STROKE_DEFAULTS)
|
||||
|
||||
|
||||
def test_invalid_block_raises_with_every_problem() -> None:
|
||||
with pytest.raises(stroke_pattern.StrokePatternConfigError) as caught:
|
||||
stroke_pattern.resolve_stroke_settings({"width_fraction": -1.0, "bogus": 1})
|
||||
message = str(caught.value)
|
||||
assert "width_fraction" in message and "bogus" in message
|
||||
|
||||
|
||||
def test_written_params_round_trip(tmp_path: Path) -> None:
|
||||
settings = stroke_pattern.resolve_stroke_settings({"angle_span_deg": 90.0})
|
||||
path = stroke_pattern.write_stroke_params(settings, tmp_path / "out")
|
||||
assert path.name == stroke_pattern.STROKE_PARAMS_FILENAME
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == settings
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------ revisions
|
||||
def test_generator_contract():
|
||||
assert _constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
|
||||
assert _constant("MODE") == stroke_pattern.STROKE_MODE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
||||
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
|
||||
"""The YAML records both legacy and Xynova manufacturing settings."""
|
||||
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
|
||||
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
|
||||
block = design.get("stroke_pattern")
|
||||
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
|
||||
resolved = stroke_pattern.resolve_stroke_settings(block)
|
||||
expected = dict(stroke_pattern.STROKE_DEFAULTS)
|
||||
if sensor == "finger-xynova":
|
||||
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12,
|
||||
target_coverage=0.50)
|
||||
assert resolved == expected
|
||||
def test_revision_settings(sensor):
|
||||
path = repo_root() / "sensors" / sensor / "r01" / "design.yaml"
|
||||
block = yaml.safe_load(path.read_text(encoding="utf-8"))["stroke_pattern"]
|
||||
assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block", [
|
||||
{"width_min_mm": 0.2}, {"width_ratio": 0}, {"width_ratio": 0.9},
|
||||
{"length_ratio": 1}, {"seed": 1.5}, {"proposals": 0},
|
||||
{"orientation_mode": "unknown"},
|
||||
])
|
||||
def test_invalid_settings(block):
|
||||
assert stroke_pattern.validate_stroke_block(block)
|
||||
with pytest.raises(stroke_pattern.StrokePatternConfigError):
|
||||
stroke_pattern.resolve_stroke_settings(block)
|
||||
|
||||
|
||||
def test_params_round_trip(tmp_path: Path):
|
||||
settings = stroke_pattern.resolve_stroke_settings({"seed": 13})
|
||||
path = stroke_pattern.write_stroke_params(settings, tmp_path)
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == settings
|
||||
|
||||
Reference in New Issue
Block a user