改用相机网格生成笔画纹理

统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。
已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
ykcheungab
2026-10-06 10:51:56 +08:00
parent 35da84ef60
commit e8d231ec39
25 changed files with 405507 additions and 94905 deletions
+33 -170
View File
@@ -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