Give the stroke pattern one mode and design.yaml tuning
The twelve experimental modes collapse to camera_compensated_width, whose ten knobs now come from a `stroke_pattern` block, and the PNG-only binary-preview command goes with them as a strict subset of stroke-texture. Verified: output is byte-identical at the defaults against the previous code, a knob change is visible in the artifacts, the suite is green and the doc gate is clean.
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
"""The binary CLI must keep trials separate from the source colour artwork."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_sensor_design import cli
|
||||
from orisys_sensor_design.manifest import RevisionPaths
|
||||
|
||||
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
||||
|
||||
|
||||
def _args(job: Path, output_dir: Path) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
views=False,
|
||||
source_job=job,
|
||||
output_dir=output_dir,
|
||||
camera_width_fraction=0.5,
|
||||
)
|
||||
|
||||
|
||||
def test_binary_preview_uses_job_and_preserves_colour_source(tmp_path, monkeypatch):
|
||||
paths = RevisionPaths.from_revision_dir(REVISION)
|
||||
colour = tmp_path / "colour.png"
|
||||
colour.write_bytes(b"original colour")
|
||||
job = {
|
||||
"schema": "orisys-sensor-design/texture-job-v3",
|
||||
"revision_dir": str(paths.root),
|
||||
"source_blend": str(paths.source_blend),
|
||||
"outputs": [{"uv_map": "compute", "artifacts": {"png": str(colour)}}],
|
||||
}
|
||||
job_path = tmp_path / "_texture_job.json"
|
||||
job_path.write_text(json.dumps(job), encoding="utf-8")
|
||||
output_dir = tmp_path / "binary"
|
||||
calls = []
|
||||
|
||||
def run_blender(script, blend, args):
|
||||
calls.append((script, blend, args))
|
||||
output_dir.mkdir()
|
||||
(output_dir / "compute_camera_compensated_width.png").write_bytes(b"binary")
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||
assert cli.cmd_binary_preview(REVISION, _args(job_path, output_dir)) == 0
|
||||
assert colour.read_bytes() == b"original colour"
|
||||
assert calls[0][0] == "random_stroke_pattern.py"
|
||||
assert "--camera-width-fraction" in calls[0][2]
|
||||
assert cli.cmd_binary_preview(REVISION, _args(job_path, output_dir)) == 2
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_binary_preview_rejects_job_from_other_scene(tmp_path, monkeypatch):
|
||||
paths = RevisionPaths.from_revision_dir(REVISION)
|
||||
job_path = tmp_path / "_texture_job.json"
|
||||
job_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "orisys-sensor-design/texture-job-v3",
|
||||
"revision_dir": str(paths.root),
|
||||
"source_blend": str(tmp_path / "other.blend"),
|
||||
"outputs": [{"uv_map": "compute", "artifacts": {"png": "missing.png"}}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"_run_blender_script",
|
||||
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
|
||||
)
|
||||
assert cli.cmd_binary_preview(REVISION, _args(job_path, tmp_path / "binary")) == 2
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Run with python -m pytest tests/test_stroke_pattern.py (no Blender needed).
|
||||
|
||||
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
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from orisys_sensor_design import stroke_pattern
|
||||
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
||||
|
||||
SCRIPT = repo_root() / "blender" / "scripts" / "random_stroke_pattern.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.
|
||||
"""
|
||||
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}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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"),
|
||||
],
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
||||
def test_shipped_revisions_declare_the_block_at_the_defaults(sensor: str) -> None:
|
||||
"""The yaml documents the shipped values, so an untouched revision changes nothing."""
|
||||
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"
|
||||
assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
|
||||
@@ -55,7 +55,9 @@ def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
|
||||
|
||||
def run_blender(script, blend, args):
|
||||
calls.append((script, blend, args))
|
||||
output_dir.mkdir()
|
||||
# exist_ok: the CLI writes _stroke_pattern_params.json into the output directory before
|
||||
# it launches Blender, so the directory already exists.
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fmt in ("png", "svg", "pdf"):
|
||||
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
||||
return 0
|
||||
@@ -63,7 +65,13 @@ def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0
|
||||
assert calls[0][0] == "random_stroke_pattern.py"
|
||||
assert calls[0][2][-4:] == ["--seed", "13", "--camera-width-fraction", "0.5"]
|
||||
# `--seed` and `--camera-width-fraction` are overrides folded into the params file, so the
|
||||
# contract is now the effective tuning the script reads.
|
||||
params = json.loads(
|
||||
Path(calls[0][2][calls[0][2].index("--params") + 1]).read_text(encoding="utf-8")
|
||||
)
|
||||
assert params["seed"] == 13
|
||||
assert params["width_fraction"] == 0.5
|
||||
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
||||
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1"
|
||||
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
||||
|
||||
Reference in New Issue
Block a user