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

统一笔画配置与彩色 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
-35
View File
@@ -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
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
+26 -68
View File
@@ -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