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:
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user