改用相机网格生成笔画纹理
统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。 已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
"""Check laser-mark clearance in physical page coordinates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "blender" / "scripts"))
|
||||
|
||||
from stroke_geometry import has_clearance # noqa: E402
|
||||
|
||||
|
||||
def test_crossing_capsules_are_rejected() -> None:
|
||||
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
||||
assert not has_clearance(((0, -0.2), (0, 0.2), 0.1), existing, 0.03)
|
||||
|
||||
|
||||
def test_near_parallel_capsules_need_edge_spacing() -> None:
|
||||
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
||||
assert not has_clearance(((-0.2, 0.22), (0.2, 0.22), 0.1), existing, 0.03)
|
||||
assert has_clearance(((-0.2, 0.24), (0.2, 0.24), 0.1), existing, 0.03)
|
||||
|
||||
|
||||
def test_disjoint_collinear_capsules_can_clear() -> None:
|
||||
existing = [((0, 0), (0.2, 0), 0.1)]
|
||||
assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03)
|
||||
|
||||
|
||||
def test_extension_ignores_only_its_original_capsule() -> None:
|
||||
original = ((0, 0), (0.2, 0), 0.1)
|
||||
neighbour = ((0.6, 0), (0.8, 0), 0.1)
|
||||
assert has_clearance(((-0.1, 0), (0.3, 0), 0.1), [original, neighbour], 0.03,
|
||||
skip_index=0)
|
||||
assert not has_clearance(((-0.1, 0), (0.4, 0), 0.1), [original, neighbour], 0.03,
|
||||
skip_index=0)
|
||||
+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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The stroke CLI must preserve colour sources and verify all exported formats."""
|
||||
"""The stroke CLI uses the texture geometry job and verifies its outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,96 +12,54 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
|
||||
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
||||
|
||||
|
||||
def _args(job: Path, output_dir: Path) -> argparse.Namespace:
|
||||
def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
views=False,
|
||||
source_job=job,
|
||||
output_dir=output_dir,
|
||||
camera_width_fraction=0.5,
|
||||
seed=13,
|
||||
views=views, source_job=job, output_dir=output_dir,
|
||||
width_ratio=0.4, length_ratio=0.8, orientation_mode=None,
|
||||
proposals=None, seed=13,
|
||||
)
|
||||
|
||||
|
||||
def _job(tmp_path: Path) -> tuple[Path, Path, Path]:
|
||||
def _job(tmp_path: Path) -> Path:
|
||||
paths = RevisionPaths.from_revision_dir(REVISION)
|
||||
png = tmp_path / "colour.png"
|
||||
svg = tmp_path / "colour.svg"
|
||||
png.write_bytes(b"colour pixels")
|
||||
svg.write_bytes(b"<svg>colour geometry</svg>")
|
||||
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(paths.source_blend),
|
||||
"outputs": [
|
||||
{
|
||||
"uv_map": "compute",
|
||||
"artifacts": {"png": str(png), "svg": str(svg)},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return job_path, png, svg
|
||||
job = tmp_path / "_texture_job.json"
|
||||
job.write_text(json.dumps({
|
||||
"schema": "orisys-sensor-design/texture-job-v3",
|
||||
"revision_dir": str(paths.root), "source_blend": str(paths.source_blend),
|
||||
"settings": {"nx": 80, "ny": 80},
|
||||
"objects": {"mesh": "sensing_surface", "camera": "cam_sensor"},
|
||||
"outputs": [{"uv_map": "compute"}],
|
||||
}), encoding="utf-8")
|
||||
return job
|
||||
|
||||
|
||||
def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
|
||||
job, png, svg = _job(tmp_path)
|
||||
def test_exports_from_geometry_job(tmp_path, monkeypatch):
|
||||
job = _job(tmp_path)
|
||||
output_dir = tmp_path / "strokes"
|
||||
calls = []
|
||||
|
||||
def run_blender(script, blend, args):
|
||||
calls.append((script, blend, args))
|
||||
# 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
|
||||
|
||||
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"
|
||||
# `--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 calls[0][0] == "camera_grid_stroke.py"
|
||||
params_path = Path(calls[0][2][calls[0][2].index("--params") + 1])
|
||||
params = json.loads(params_path.read_text(encoding="utf-8"))
|
||||
assert params["seed"] == 13
|
||||
assert params["width_fraction"] == 0.5
|
||||
assert params["width_ratio"] == 0.4
|
||||
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
||||
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1"
|
||||
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v2"
|
||||
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
||||
assert manifest["outputs"][0]["sources"]["png"]["sha256"] == sha256_file(png)
|
||||
assert manifest["outputs"][0]["sources"]["svg"]["sha256"] == sha256_file(svg)
|
||||
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
|
||||
assert png.read_bytes() == b"colour pixels"
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_stroke_cli_refuses_missing_source_svg(tmp_path, monkeypatch):
|
||||
job, _png, svg = _job(tmp_path)
|
||||
svg.unlink()
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"_run_blender_script",
|
||||
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
|
||||
)
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 3
|
||||
|
||||
|
||||
def test_stroke_cli_rejects_incomplete_source_job(tmp_path, monkeypatch):
|
||||
job, _png, _svg = _job(tmp_path)
|
||||
data = json.loads(job.read_text(encoding="utf-8"))
|
||||
del data["outputs"][0]["artifacts"]["svg"]
|
||||
job.write_text(json.dumps(data), encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"_run_blender_script",
|
||||
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
|
||||
)
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 2
|
||||
def test_rejects_multiview(tmp_path, monkeypatch):
|
||||
job = _job(tmp_path)
|
||||
monkeypatch.setattr(cli, "_run_blender_script", lambda *_: 99)
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "out", views=True)) == 2
|
||||
|
||||
Reference in New Issue
Block a user