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:
ykcheungab
2026-10-04 09:40:17 +08:00
parent 5bd7dd7a01
commit 30a903a299
9 changed files with 699 additions and 421 deletions
+46 -73
View File
@@ -21,12 +21,19 @@ from orisys_sensor_design.manifest import (
export_output_dir,
export_revision_dir,
load_profile,
load_yaml,
repo_root,
sha256_file,
validate_revision_metadata,
write_export_job,
write_manifest,
)
from orisys_sensor_design.stroke_pattern import (
STROKE_MODE,
StrokePatternConfigError,
resolve_stroke_settings,
write_stroke_params,
)
from orisys_sensor_design.printability import (
REPORT_FILENAME,
PrintabilityConfigError,
@@ -500,6 +507,7 @@ def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespac
def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
"""Resolve the stroke tuning block, then validate the shared colour job."""
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
@@ -507,9 +515,24 @@ def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return None, 1
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr)
try:
settings = resolve_stroke_settings(
load_yaml(paths.design_yaml).get("stroke_pattern")
)
except StrokePatternConfigError as exc:
for message in str(exc).splitlines():
print(f"ERROR: {message}", file=sys.stderr)
return None, 2
# The flags remain per-run overrides; `design.yaml` is the default they change, so a
# tuning value lives with the part instead of in whoever's shell history ran it last.
if args.camera_width_fraction is not None:
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr)
return None, 2
settings["width_fraction"] = args.camera_width_fraction
# `_stroke_cli_context` is only reached from `stroke-texture`, which has this flag.
if args.seed is not None:
settings["seed"] = args.seed
try:
source = _stroke_source_job(paths, root, args)
except FileNotFoundError as exc:
@@ -518,43 +541,7 @@ def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr)
return None, 2
return (paths, *source), 0
def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int:
"""Convert existing colour textures to a bounded binary camera-footprint trial."""
context, code = _stroke_cli_context(revision_dir, args)
if context is None:
return code
paths, job_path, _job, _entries, source_ids = context
output_dir = (args.output_dir or job_path.parent / "binary-preview").resolve()
targets = [output_dir / f"{source_id}_camera_compensated_width.png"
for source_id in source_ids]
if any(path.exists() for path in targets):
print("ERROR: binary preview target already exists; choose a fresh --output-dir",
file=sys.stderr)
return 2
code = _run_blender_script(
"random_stroke_pattern.py", paths.source_blend,
["--job", str(job_path), "--output-dir", str(output_dir),
"--modes", "camera_compensated_width",
"--camera-width-fraction", str(args.camera_width_fraction)],
)
if code != 0:
return code
missing = [path for path in targets if not path.is_file() or path.stat().st_size == 0]
if missing:
for path in missing:
print(f"ERROR: binary preview missing or empty: {path}", file=sys.stderr)
return 3
if console.get_level() == console.QUIET:
console.artifact_paths(targets)
else:
console.report(f"OK: {len(targets)} binary preview PNG(s) in {output_dir}")
for path in targets:
console.report(f" {path}")
return 0
return (paths, *source, settings), 0
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
@@ -562,7 +549,7 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
context, code = _stroke_cli_context(revision_dir, args)
if context is None:
return code
paths, job_path, _job, entries, source_ids = context
paths, job_path, _job, entries, source_ids, settings = context
for entry in entries:
for fmt in ("png", "svg"):
try:
@@ -589,12 +576,12 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
print("ERROR: stroke artifacts already exist; choose a fresh --output-dir",
file=sys.stderr)
return 2
params_path = write_stroke_params(settings, output_dir)
code = _run_blender_script(
"random_stroke_pattern.py", paths.source_blend,
["--job", str(job_path), "--output-dir", str(output_dir),
"--modes", "camera_compensated_width", "--formats", "png", "svg", "pdf",
"--artifact-prefix", "stroke_texture", "--seed", str(args.seed),
"--camera-width-fraction", str(args.camera_width_fraction)],
"--formats", "png", "svg", "pdf",
"--artifact-prefix", "stroke_texture", "--params", str(params_path)],
)
if code != 0:
return code
@@ -610,8 +597,18 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"source_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
"source_blend": {"path": str(paths.source_blend),
"sha256": sha256_file(paths.source_blend)},
"settings": {"mode": "camera_compensated_width", "seed": args.seed,
"camera_width_fraction": args.camera_width_fraction},
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
# overrides actually produced, because with ten knobs the flags alone no longer
# describe the run. The design.yaml and params hashes pin the tuning either way.
"settings": {
"mode": STROKE_MODE,
"seed": settings["seed"],
"camera_width_fraction": settings["width_fraction"],
"resolved": dict(settings),
"design_yaml": {"path": str(paths.design_yaml),
"sha256": sha256_file(paths.design_yaml)},
"params": {"path": str(params_path), "sha256": sha256_file(params_path)},
},
"outputs": [
{"id": source_id,
"sources": {fmt: {"path": str(Path(entry["artifacts"][fmt])),
@@ -2275,28 +2272,6 @@ def main(argv: list[str] | None = None) -> None:
),
)
binary_p = sub.add_parser(
"binary-preview",
help="Make bounded black/white PNG trials from existing colour textures",
)
binary_p.add_argument("revision_dir", type=Path)
binary_p.add_argument(
"--views", action="store_true",
help="Read the texture-views job instead of the single-UV texture job",
)
binary_p.add_argument(
"--source-job", type=Path, default=None,
help="Existing colour texture job (default: revision build/textures job)",
)
binary_p.add_argument(
"--output-dir", type=Path, default=None,
help="Fresh output directory (default: binary-preview beside the colour job)",
)
binary_p.add_argument(
"--camera-width-fraction", type=float, default=0.5,
help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)",
)
stroke_p = sub.add_parser(
"stroke-texture",
help="Export Random Stroke Pattern PNG/SVG/PDF from colour artwork",
@@ -2315,12 +2290,12 @@ def main(argv: list[str] | None = None) -> None:
help="Fresh output directory (default: strokes beside the colour job)",
)
stroke_p.add_argument(
"--seed", type=int, default=7,
help="Reproducible stroke offsets and angles (default: 7)",
"--seed", type=int, default=None,
help="Override stroke_pattern.seed for this run",
)
stroke_p.add_argument(
"--camera-width-fraction", type=float, default=0.5,
help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)",
"--camera-width-fraction", type=float, default=None,
help="Override stroke_pattern.width_fraction for this run",
)
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
@@ -2716,8 +2691,6 @@ def main(argv: list[str] | None = None) -> None:
code = cmd_texture(args.revision_dir, args)
elif args.command == "texture-views":
code = cmd_texture_views(args.revision_dir, args)
elif args.command == "binary-preview":
code = cmd_binary_preview(args.revision_dir, args)
elif args.command == "stroke-texture":
code = cmd_stroke_texture(args.revision_dir, args)
elif args.command == "inspect":
+151
View File
@@ -0,0 +1,151 @@
"""Tuning for the Random Stroke Pattern: the `stroke_pattern` block in `design.yaml`.
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
footprint on the print page — so a cell the projection stretches gets a proportionally larger
mark — then clamped. That gives ten live knobs, and this module owns their names, defaults and
ranges on the host side.
Why the keys are exactly these
------------------------------
Everything else in the generator is either derived or dead:
* ``formats`` is **not** a knob. ``stroke-texture`` fixes its artifact set at png+svg+pdf,
because that set is part of the command's contract; a yaml value could only disagree with it.
* The generator's old per-cell size scale is **dead for this mode** — only the deleted
dashed/dotted modes read it — so it is gone rather than exposed. No key here can be inert.
Precedence is ``these defaults < design.yaml < CLI flag``, matching how the texture commands
already layer the revision block under their flags.
The Blender script keeps its own copy of these names and defaults, because it runs under Blender
while this module is stdlib-only — the same split as the ``::report::`` prefix constants. A test
pins the two copies against each other, so drift fails rather than silently changing behaviour.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
#: The only mode the generator implements. Recorded in the job so a run is self-describing.
STROKE_MODE = "camera_compensated_width"
#: Every knob, at the values the experiment shipped with. Mirror of
#: ``random_stroke_pattern.DEFAULT_PARAMS``; a test asserts they agree.
STROKE_DEFAULTS: dict[str, Any] = {
"seed": 7,
"min_cell_pixels": 20,
"jitter_fraction": 0.66,
"angle_span_deg": 180.0,
"width_fraction": 0.5,
"width_min_mm": 0.11,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
}
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels"})
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
class StrokePatternConfigError(ValueError):
"""The `stroke_pattern` block cannot produce artwork."""
def validate_stroke_block(block: Any) -> list[str]:
"""Return every reason this block is unusable; empty when it is fine.
Strict about unknown keys on purpose: the generator would ignore a misspelled knob and the
run would look successful while the value did nothing.
"""
if block is None:
return []
where = "stroke_pattern"
if not isinstance(block, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(block) - STROKE_KEYS)
if extra:
errors.append(
f"{where}: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}"
)
for key, value in block.items():
if key not in STROKE_KEYS:
continue
if isinstance(value, bool) or not isinstance(value, (int, float)):
errors.append(f"{where}.{key}: must be a number (got {value!r})")
continue
if key in STROKE_INT_KEYS and not isinstance(value, int):
errors.append(f"{where}.{key}: must be an integer (got {value!r})")
continue
if value != value or value in (float("inf"), float("-inf")):
errors.append(f"{where}.{key}: must be finite (got {value!r})")
if errors:
return errors
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
errors.append(
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
)
if "angle_span_deg" in block and block["angle_span_deg"] < 0.0:
errors.append(
f"{where}.angle_span_deg: must be >= 0 (got {block['angle_span_deg']!r})"
)
for key in ("width_fraction", "width_min_mm", "width_max_mm", "length_ratio",
"neighbourhood_mm"):
if key in block and block[key] <= 0.0:
errors.append(f"{where}.{key}: must be > 0 (got {block[key]!r})")
if "coverage_cap" in block and not 0.0 < block["coverage_cap"] <= 1.0:
errors.append(
f"{where}.coverage_cap: must be within (0, 1] (got {block['coverage_cap']!r})"
)
merged = {**STROKE_DEFAULTS, **{k: v for k, v in block.items() if k in STROKE_KEYS}}
if merged["width_min_mm"] > merged["width_max_mm"]:
errors.append(
f"{where}: width_min_mm {merged['width_min_mm']:g} must be <= width_max_mm "
f"{merged['width_max_mm']:g}, or the clamp inverts and every mark takes the minimum"
)
return errors
def resolve_stroke_settings(block: Any) -> dict[str, Any]:
"""The block merged over the defaults, or raise naming every problem."""
errors = validate_stroke_block(block)
if errors:
raise StrokePatternConfigError("\n".join(errors))
settings = dict(STROKE_DEFAULTS)
if isinstance(block, Mapping):
settings.update({key: block[key] for key in STROKE_KEYS if key in block})
return settings
def write_stroke_params(settings: Mapping[str, Any], output_dir: Path) -> Path:
"""Write the resolved tuning beside the artifacts, so a run records what it used."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / STROKE_PARAMS_FILENAME
path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
__all__ = [
"STROKE_DEFAULTS",
"STROKE_INT_KEYS",
"STROKE_KEYS",
"STROKE_MODE",
"STROKE_PARAMS_FILENAME",
"StrokePatternConfigError",
"resolve_stroke_settings",
"validate_stroke_block",
"write_stroke_params",
]