From 30a903a2997af6fb0c241f0ed05ad2f64a6d0dec Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 4 Oct 2026 09:40:17 +0800 Subject: [PATCH] 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. --- README.md | 6 +- blender/scripts/random_stroke_pattern.py | 537 ++++++++++----------- sensors/finger-xynova/r01/design.yaml | 20 + sensors/finger/r01/design.yaml | 20 + src/orisys_sensor_design/cli.py | 119 ++--- src/orisys_sensor_design/stroke_pattern.py | 151 ++++++ tests/test_binary_preview_cli.py | 73 --- tests/test_stroke_pattern.py | 182 +++++++ tests/test_stroke_texture_cli.py | 12 +- 9 files changed, 699 insertions(+), 421 deletions(-) create mode 100644 src/orisys_sensor_design/stroke_pattern.py delete mode 100644 tests/test_binary_preview_cli.py create mode 100644 tests/test_stroke_pattern.py diff --git a/README.md b/README.md index dd4346c..71fb70c 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,9 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa `stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash manifest from existing color artwork, for example: `orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a -`strokes` subdirectory; `--output-dir` selects another. `binary-preview` -remains PNG-only. Nominal stroke width is 0.11–0.28 mm; verify vectors and -printed dimensions before manufacturing. +`strokes` subdirectory; `--output-dir` selects another. Tuning lives in the +revision's `stroke_pattern` block. Nominal stroke width is 0.11–0.28 mm; +verify vectors and printed dimensions before manufacturing. ## Inspect printability (advisory) diff --git a/blender/scripts/random_stroke_pattern.py b/blender/scripts/random_stroke_pattern.py index c2d4b40..52798f0 100644 --- a/blender/scripts/random_stroke_pattern.py +++ b/blender/scripts/random_stroke_pattern.py @@ -5,26 +5,21 @@ Run after the colour texture command has produced its PNGs:: blender -b -t 2 --python-exit-code 1 \ -P blender/scripts/random_stroke_pattern.py -- \ --job build/textures/finger/r01/_texture_views_job.json \ - --output-dir .scratch/binary-pattern-from-color/run-01 + --output-dir .scratch/stroke-pattern/run-01 -This is an image-space experiment, not a print master. It accepts either a single-view -texture job or a multiview job. Each distinct source colour is treated as one cell -within each PNG. A split cell can have a different centre in the two print views. -The medium- and dense-dash outputs test higher black coverage without changing -cell positions. -The physical-size outputs compare two stroke widths with a 0.2 mm-long, -0.1 mm-wide capsule. Clipping and merged neighbours can change the widths of -visible features. -The spaced 0.12 mm trial rejects clipped or colliding marks on each print page; -its selection can differ between the two UV views, so it is also preview-only. -The balanced trial permits overlaps but caps nearby black coverage. -The camera-scaled trial uses the source sensing-camera grid for a radial size -gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These are -nominal print-page sizes; clipped/merged ink still needs physical validation. -The camera-compensated trial scales width from each camera cell's print-page -footprint, bounded to 0.11–0.28 mm; larger far-field cells therefore receive -larger marks. The PNG and native SVG/PDF share accepted capsule geometry, but -image-space regions can be distorted. Verify dimensions before manufacturing. +Accepts either a single-view or a multiview colour job. Each distinct source colour is one cell +of the colour artwork; a split cell can have a different centre in the two print views. + +One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of ``width_mm`` swept +``length_mm``, rotated per cell, and its width is scaled from that cell's footprint on the print +page so a cell the projection stretches receives a proportionally larger mark. The width is then +clamped to ``width_min_mm``..``width_max_mm``. + +**This is an image-space experiment, not a print master.** Clipping and merged neighbours change +the visible widths, so verify dimensions on the printed part before manufacturing. + +Tuning lives in ``design.yaml`` under ``stroke_pattern``; the CLI serialises it here as +``--params``. With no ``--params`` the built-in ``DEFAULT_PARAMS`` apply. """ from __future__ import annotations @@ -45,39 +40,75 @@ if script_dir not in sys.path: sys.path.insert(0, script_dir) from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path -PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2} -MODES = ( - "dots", - "dashes", - "medium_dashes", - "dense_dashes", - *PHYSICAL_WIDTH_MM, - "length_0p2_width_0p1mm", - "spaced_width_0p12mm", - "balanced_width_0p12mm", - "camera_scaled_width", - "camera_compensated_width", -) +MODE = "camera_compensated_width" + +#: Every tunable, at the values this experiment shipped with. The CLI writes a `design.yaml` +#: `stroke_pattern` block here as `--params`; `--seed`, `--min-cell-pixels` and +#: `--camera-width-fraction` override individual keys for one run. +DEFAULT_PARAMS: dict[str, float] = { + "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, +} + + +def validate_params(params: dict) -> None: + """Raise on a value that cannot produce artwork, naming the key. + + Ranges live here rather than only on the host side, because this script is also run + directly under Blender, where nothing else would catch a bad `--params` file. + """ + for key, value in params.items(): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"params.{key}: must be a number (got {value!r})") + if not math.isfinite(float(value)): + raise ValueError(f"params.{key}: must be finite (got {value!r})") + if params["min_cell_pixels"] < 1: + raise ValueError("params.min_cell_pixels must be >= 1") + if not 0.0 <= params["jitter_fraction"] <= 1.0: + raise ValueError("params.jitter_fraction must be within [0, 1]") + if params["angle_span_deg"] < 0.0: + raise ValueError("params.angle_span_deg must be >= 0") + for key in ("width_fraction", "width_min_mm", "width_max_mm", "length_ratio", + "neighbourhood_mm"): + if params[key] <= 0.0: + raise ValueError(f"params.{key} must be > 0") + if params["width_min_mm"] > params["width_max_mm"]: + raise ValueError("params.width_min_mm must be <= params.width_max_mm") + if not 0.0 < params["coverage_cap"] <= 1.0: + raise ValueError("params.coverage_cap must be within (0, 1]") def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--job", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) - parser.add_argument("--seed", type=int, default=7) - parser.add_argument("--min-cell-pixels", type=int, default=20) parser.add_argument( - "--modes", - nargs="+", - choices=MODES, - default=MODES, - help="Generate only these preview variants", + "--params", + type=Path, + default=None, + help="JSON of stroke tuning values; overrides DEFAULT_PARAMS key by key", + ) + parser.add_argument( + "--seed", type=int, default=None, + help="Override params.seed for this run", + ) + parser.add_argument( + "--min-cell-pixels", type=int, default=None, + help="Override params.min_cell_pixels for this run", ) parser.add_argument( "--camera-width-fraction", type=float, - default=0.5, - help="Compensated stroke width as a fraction of the local print-cell span", + default=None, + help="Override params.width_fraction for this run", ) parser.add_argument( "--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",) @@ -85,23 +116,52 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--artifact-prefix", default=None, - help="Name single-mode outputs _.", + help="Name outputs _.", ) return parser.parse_args(argv) -def cell_code(rgb: np.ndarray, seed: int) -> tuple[float, float, float, float]: - """Return bounded offsets, angle and size from one exact source colour.""" +def resolve_params(args: argparse.Namespace) -> dict: + """DEFAULT_PARAMS, then the --params file, then the per-run flag overrides.""" + params = dict(DEFAULT_PARAMS) + if args.params is not None: + supplied = json.loads(args.params.read_text(encoding="utf-8")) + if not isinstance(supplied, dict): + raise ValueError("--params must contain a JSON object") + unknown = sorted(set(supplied) - set(DEFAULT_PARAMS)) + if unknown: + raise ValueError( + f"--params: unknown key(s) {unknown}; allowed: {sorted(DEFAULT_PARAMS)}" + ) + params.update(supplied) + for key, value in ( + ("seed", args.seed), + ("min_cell_pixels", args.min_cell_pixels), + ("width_fraction", args.camera_width_fraction), + ): + if value is not None: + params[key] = value + validate_params(params) + return params + + +def cell_code( + rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float +) -> tuple[float, float, float]: + """Return bounded offsets and angle from one exact source colour. + + Derived from the colour itself, so the same palette gives the same layout in both print + views without storing anything. + """ digest = hashlib.blake2b( bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True), digest_size=8, ).digest() values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)] return ( - 0.66 * (values[0] - 0.5), - 0.66 * (values[1] - 0.5), - math.pi * values[2], - 0.85 + 0.3 * values[3], + jitter_fraction * (values[0] - 0.5), + jitter_fraction * (values[1] - 0.5), + math.radians(angle_span_deg) * values[2], ) @@ -186,22 +246,25 @@ def camera_cell_lookup( return lookup -def camera_scaled_size(cell: tuple[int, int], nx: int, ny: int) -> tuple[float, float]: - """Return nominal print width and length in mm from camera-grid position.""" - i, j = cell - u = (i + 0.5) / nx - 0.5 - v = (j + 0.5) / ny - 0.5 - radius = min(1.0, math.hypot(u, v) / math.sqrt(0.5)) - centre_weight = 1.0 - radius**2 - return (0.11 + 0.05 * centre_weight, 0.24 + 0.14 * centre_weight) - - def camera_compensated_size( - print_span_px: int, px_per_mm: float, width_fraction: float + print_span_px: int, + px_per_mm: float, + width_fraction: float, + width_min_mm: float, + width_max_mm: float, + length_ratio: float, ) -> tuple[float, float]: - """Return nominal print dimensions from one projected camera-cell footprint.""" - width_mm = max(0.11, min(0.28, print_span_px / px_per_mm * width_fraction)) - return width_mm, 2.4 * width_mm + """Nominal print width and length in mm from one projected camera-cell footprint. + + Scaling from the footprint is the point of this mode: the page is a projection, so a cell + the projection stretches is physically larger and must receive a proportionally larger mark + to stay in proportion. The clamp then keeps a far-field cell from growing past what the + process can hold. + """ + width_mm = max( + width_min_mm, min(width_max_mm, print_span_px / px_per_mm * width_fraction) + ) + return width_mm, length_ratio * width_mm def capsule_polygon( @@ -227,66 +290,39 @@ def capsule_polygon( def draw_mark( canvas: np.ndarray, region, - seed: int, - mode: str, + params: dict, px_per_mm: float, patterned: np.ndarray | None = None, camera_cell: tuple[int, int] | None = None, - grid_shape: tuple[int, int] | None = None, - camera_width_fraction: float = 0.5, vector_shapes: list | None = None, ) -> float: + """Draw one camera-compensated capsule. Returns its printed width in pixels, or 0.""" colour, cell_x, cell_y, width, height = region rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255) - dx, dy, angle, scale = cell_code(np.asarray(rgb), seed) + dx, dy, angle = cell_code( + np.asarray(rgb), + params["seed"], + params["jitter_fraction"], + params["angle_span_deg"], + ) span = min(width, height) centre_x = cell_x + dx * width centre_y = cell_y + dy * height - if mode == "camera_compensated_width": - if camera_cell is None: - raise ValueError( - "camera-compensated marks require an unambiguous camera cell" - ) - width_mm, length_mm = camera_compensated_size( - span, px_per_mm, camera_width_fraction + if camera_cell is None: + raise ValueError( + "camera-compensated marks require an unambiguous camera cell" ) - radius = width_mm * px_per_mm / 2 - half_length = (length_mm - width_mm) * px_per_mm / 2 - elif mode == "camera_scaled_width": - if camera_cell is None or grid_shape is None: - raise ValueError("camera-scaled marks require an unambiguous camera cell") - width_mm, length_mm = camera_scaled_size(camera_cell, *grid_shape) - radius = width_mm * px_per_mm / 2 - half_length = (length_mm - width_mm) * px_per_mm / 2 - elif mode == "spaced_width_0p12mm": - radius = 0.12 * px_per_mm / 2 - half_length = (0.30 - 0.12) * px_per_mm / 2 - elif mode == "balanced_width_0p12mm": - radius = 0.12 * px_per_mm / 2 - half_length = 2 * radius - elif mode == "length_0p2_width_0p1mm": - radius = 0.1 * px_per_mm / 2 - half_length = (0.2 - 0.1) * px_per_mm / 2 - elif mode in PHYSICAL_WIDTH_MM: - radius = PHYSICAL_WIDTH_MM[mode] * px_per_mm / 2 - half_length = 2 * radius - else: - radius_fraction = { - "dots": 0.14, - "dashes": 0.14, - "medium_dashes": 0.16, - "dense_dashes": 0.18, - }[mode] - radius = max(1.0, radius_fraction * span * scale) - half_length_fraction = { - "dots": 0.0, - "dashes": 0.28, - "medium_dashes": 0.32, - "dense_dashes": 0.36, - }[mode] - half_length = half_length_fraction * span * scale - gap_px = 0.02 * px_per_mm if mode == "spaced_width_0p12mm" else 0.0 - extent = radius + half_length + gap_px + width_mm, length_mm = camera_compensated_size( + span, + px_per_mm, + params["width_fraction"], + params["width_min_mm"], + params["width_max_mm"], + params["length_ratio"], + ) + radius = width_mm * px_per_mm / 2 + half_length = (length_mm - width_mm) * px_per_mm / 2 + extent = radius + half_length if patterned is not None and ( centre_x - extent < 0 or centre_y - extent < 0 @@ -310,24 +346,19 @@ def draw_mark( if patterned is not None: if np.any(footprint & ~patterned[y0:y1, x0:x1]): return 0.0 - if mode == "spaced_width_0p12mm": - clearance = distance_sq <= (radius + gap_px) ** 2 - if np.any(canvas[y0:y1, x0:x1] & clearance): - return 0.0 - else: - local_radius = round(0.5 * px_per_mm) - lx0 = max(0, round(centre_x) - local_radius) - lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1) - ly0 = max(0, round(centre_y) - local_radius) - ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1) - local_pattern = patterned[ly0:ly1, lx0:lx1] - local_area = np.count_nonzero(local_pattern) - if local_area == 0: - return 0.0 - existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern) - added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1]) - if (existing + added) / local_area > 0.45: - return 0.0 + local_radius = round(params["neighbourhood_mm"] * px_per_mm) + lx0 = max(0, round(centre_x) - local_radius) + lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1) + ly0 = max(0, round(centre_y) - local_radius) + ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1) + local_pattern = patterned[ly0:ly1, lx0:lx1] + local_area = np.count_nonzero(local_pattern) + if local_area == 0: + return 0.0 + existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern) + added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1]) + if (existing + added) / local_area > params["coverage_cap"]: + return 0.0 canvas[y0:y1, x0:x1] |= footprint if vector_shapes is not None: vector_shapes.append( @@ -459,34 +490,29 @@ def source_mesh_polygons( return polygons, width_mm, height_mm -def artifact_stem(source_id: str, mode: str, prefix: str | None) -> str: - return f"{prefix}_{source_id}" if prefix else f"{source_id}_{mode}" +def artifact_stem(source_id: str, prefix: str | None) -> str: + return f"{prefix}_{source_id}" if prefix else f"{source_id}_{MODE}" def run( job_path: Path, output_dir: Path, - seed: int, - minimum: int, - modes: tuple[str, ...], - camera_width_fraction: float, + params: dict, formats: tuple[str, ...] = ("png",), artifact_prefix: str | None = None, ) -> None: job = json.loads(job_path.read_text(encoding="utf-8")) sources, px_per_mm = job_sources(job) if artifact_prefix is not None and ( - len(modes) != 1 - or not artifact_prefix + not artifact_prefix or not all(char.isalnum() or char in "_-" for char in artifact_prefix) ): - raise ValueError("artifact prefix requires one mode and a filename-safe prefix") + raise ValueError("artifact prefix must be non-empty and filename-safe") if not formats or len(formats) != len(set(formats)): raise ValueError("formats must be non-empty and distinct") targets = [ - output_dir / f"{artifact_stem(source_id, mode, artifact_prefix)}.{fmt}" + output_dir / f"{artifact_stem(source_id, artifact_prefix)}.{fmt}" for source_id, _, _, _ in sources - for mode in modes for fmt in formats ] for target in targets: @@ -503,7 +529,7 @@ def run( ) surface = np.any(rgb != 0, axis=2) patterned = surface & np.any(rgb != 255, axis=2) - regions = list(cell_regions(rgb, minimum)) + regions = list(cell_regions(rgb, int(params["min_cell_pixels"]))) if not regions or not np.any(patterned): raise ValueError(f"{source_id}: no coloured cell regions in source PNG") cell_span = float(np.median([min(region[3], region[4]) for region in regions])) @@ -512,147 +538,118 @@ def run( f"({cell_span / px_per_mm:.3f} mm on the print page)", flush=True, ) - lookup = None - grid_shape = None - camera_modes = ("camera_scaled_width", "camera_compensated_width") - if any(mode in modes for mode in camera_modes): - grid_shape = (int(settings["nx"]), int(settings["ny"])) - pair_seed = source_seed if source_seed is not None else settings.get("seed") - if pair_seed is None: - raise ValueError( - "camera-based preview requires a seeded colour palette" - ) - lookup = camera_cell_lookup( - *grid_shape, float(settings["color_r"]), int(pair_seed) - ) - missing = [ - hex(region[0]) - for region in regions - if region[0] != 0xFF0000 and region[0] not in lookup - ] - if missing: - raise ValueError( - f"{source_id}: camera cell missing for " - f"{len(missing)} colours: {missing[:5]}" - ) - ambiguous = [ - hex(region[0]) - for region in regions - if region[0] != 0xFF0000 and lookup[region[0]] is None - ] - if ambiguous: - print( - f"{source_id}: skip {len(ambiguous)} ambiguous palette regions " - f"{ambiguous[:5]}", - flush=True, - ) - for mode in modes: - marks = np.zeros((height, width), dtype=bool) - vector_shapes = [] if vector_page is not None else None - limit = ( - patterned - if mode - in ( - "spaced_width_0p12mm", - "balanced_width_0p12mm", - "camera_scaled_width", - "camera_compensated_width", - ) - else None - ) - mode_regions = [ - region - for region in regions - if mode not in camera_modes - or (region[0] != 0xFF0000 and lookup[region[0]] is not None) - ] - diameters = [ - draw_mark( - marks, - region, - seed, - mode, - px_per_mm, - limit, - camera_cell=lookup[region[0]] - if lookup is not None and mode in camera_modes - else None, - grid_shape=grid_shape, - camera_width_fraction=camera_width_fraction, - vector_shapes=vector_shapes, - ) - for region in mode_regions - ] - stem = artifact_stem(source_id, mode, artifact_prefix) - if "png" in formats: - write_binary_png(output_dir / f"{stem}.png", marks, surface) - if vector_page is not None: - mesh_polygons, width_mm, height_mm = vector_page - if "svg" in formats: - write_svg_vector( - output_dir / f"{stem}.svg", - width, - height, - width_mm, - height_mm, - mesh_polygons, - vector_shapes, - [], - (0.0, 0.0, 0.0, 1.0), - source_id, - artwork_label="stroke", - ) - if "pdf" in formats: - write_pdf_vector( - output_dir / f"{stem}.pdf", - width, - height, - width_mm, - height_mm, - mesh_polygons, - vector_shapes, - [], - (0.0, 0.0, 0.0, 1.0), - artwork_label="stroke", - ) - accepted = [diameter for diameter in diameters if diameter > 0] - median_width = float(np.median(accepted)) if accepted else 0.0 - physical_width = ( - f", about {median_width / px_per_mm:.3f} mm on the print page" - if px_per_mm > 0 - else "" - ) - coverage = float( - np.count_nonzero(marks & patterned) / np.count_nonzero(patterned) - ) - width_range = ( - f", width range {min(accepted) / px_per_mm:.3f}–" - f"{max(accepted) / px_per_mm:.3f} mm" - if accepted - else "" + # A mark is sized from the cell's footprint, so an ambiguous palette colour has no + # known footprint and is skipped rather than guessed. + grid_shape = (int(settings["nx"]), int(settings["ny"])) + pair_seed = source_seed if source_seed is not None else settings.get("seed") + if pair_seed is None: + raise ValueError("camera-compensated preview requires a seeded colour palette") + lookup = camera_cell_lookup( + *grid_shape, float(settings["color_r"]), int(pair_seed) + ) + missing = [ + hex(region[0]) + for region in regions + if region[0] != 0xFF0000 and region[0] not in lookup + ] + if missing: + raise ValueError( + f"{source_id}: camera cell missing for " + f"{len(missing)} colours: {missing[:5]}" ) + ambiguous = [ + hex(region[0]) + for region in regions + if region[0] != 0xFF0000 and lookup[region[0]] is None + ] + if ambiguous: print( - f"{source_id}: {mode}, {len(accepted)}/{len(mode_regions)} marks, " - f"black coverage {coverage:.1%}, median mark width " - f"{median_width:.1f} px{physical_width}{width_range} -> {stem}", + f"{source_id}: skip {len(ambiguous)} ambiguous palette regions " + f"{ambiguous[:5]}", flush=True, ) + marks = np.zeros((height, width), dtype=bool) + vector_shapes = [] if vector_page is not None else None + mode_regions = [ + region + for region in regions + if region[0] != 0xFF0000 and lookup[region[0]] is not None + ] + diameters = [ + draw_mark( + marks, + region, + params, + px_per_mm, + patterned, + camera_cell=lookup[region[0]], + vector_shapes=vector_shapes, + ) + for region in mode_regions + ] + stem = artifact_stem(source_id, artifact_prefix) + if "png" in formats: + write_binary_png(output_dir / f"{stem}.png", marks, surface) + if vector_page is not None: + mesh_polygons, width_mm, height_mm = vector_page + if "svg" in formats: + write_svg_vector( + output_dir / f"{stem}.svg", + width, + height, + width_mm, + height_mm, + mesh_polygons, + vector_shapes, + [], + (0.0, 0.0, 0.0, 1.0), + source_id, + artwork_label="stroke", + ) + if "pdf" in formats: + write_pdf_vector( + output_dir / f"{stem}.pdf", + width, + height, + width_mm, + height_mm, + mesh_polygons, + vector_shapes, + [], + (0.0, 0.0, 0.0, 1.0), + artwork_label="stroke", + ) + accepted = [diameter for diameter in diameters if diameter > 0] + median_width = float(np.median(accepted)) if accepted else 0.0 + physical_width = ( + f", about {median_width / px_per_mm:.3f} mm on the print page" + if px_per_mm > 0 + else "" + ) + coverage = float( + np.count_nonzero(marks & patterned) / np.count_nonzero(patterned) + ) + width_range = ( + f", width range {min(accepted) / px_per_mm:.3f}–" + f"{max(accepted) / px_per_mm:.3f} mm" + if accepted + else "" + ) + print( + f"{source_id}: {MODE}, {len(accepted)}/{len(mode_regions)} marks, " + f"black coverage {coverage:.1%}, median mark width " + f"{median_width:.1f} px{physical_width}{width_range} -> {stem}", + flush=True, + ) def main() -> int: argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] args = parse_args(argv) - if args.min_cell_pixels < 1: - raise ValueError("--min-cell-pixels must be positive") - if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0: - raise ValueError("--camera-width-fraction must be finite and positive") run( args.job.resolve(), args.output_dir.resolve(), - args.seed, - args.min_cell_pixels, - tuple(args.modes), - args.camera_width_fraction, + resolve_params(args), tuple(args.formats), args.artifact_prefix, ) diff --git a/sensors/finger-xynova/r01/design.yaml b/sensors/finger-xynova/r01/design.yaml index f5acf1c..4d0b6b8 100644 --- a/sensors/finger-xynova/r01/design.yaml +++ b/sensors/finger-xynova/r01/design.yaml @@ -88,6 +88,26 @@ texture_patch_views: print: base_fill: owned # white ground, already split by the seam +# Random Stroke Pattern (`orisys-design stroke-texture`). +# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera +# cell's print-page footprint so a cell the projection stretches gets a proportionally +# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run. +# Field reference: the inline comments below. These defaults are an image-space experiment, +# not a print master: `width_min_mm: 0.11` is below the demonstrated 0.2 mm dot (see +# docs/probe-findings.md). The resolved values are written to _stroke_pattern_params.json and +# recorded in the stroke-texture manifest, which is what makes a run reproducible. +stroke_pattern: + seed: 7 # per-cell offset and angle; reproducible + min_cell_pixels: 20 # cells smaller than this are skipped + jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre + angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel + width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm) + width_min_mm: 0.11 + width_max_mm: 0.28 + length_ratio: 2.4 # length = ratio x width + coverage_cap: 0.45 # skip a mark that would exceed this local fill + neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood + # Advisory only: reports a verdict, never blocks generation or changes exit codes. # Millimetres are measured on the vector page declared above. printability: diff --git a/sensors/finger/r01/design.yaml b/sensors/finger/r01/design.yaml index 403f9b6..0227e32 100644 --- a/sensors/finger/r01/design.yaml +++ b/sensors/finger/r01/design.yaml @@ -94,6 +94,26 @@ texture_patch_views: print: base_fill: owned # white ground, already split by the seam +# Random Stroke Pattern (`orisys-design stroke-texture`). +# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera +# cell's print-page footprint so a cell the projection stretches gets a proportionally +# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run. +# Field reference: the inline comments below. These defaults are an image-space experiment, +# not a print master: `width_min_mm: 0.11` is below the demonstrated 0.2 mm dot (see +# docs/probe-findings.md). The resolved values are written to _stroke_pattern_params.json and +# recorded in the stroke-texture manifest, which is what makes a run reproducible. +stroke_pattern: + seed: 7 # per-cell offset and angle; reproducible + min_cell_pixels: 20 # cells smaller than this are skipped + jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre + angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel + width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm) + width_min_mm: 0.11 + width_max_mm: 0.28 + length_ratio: 2.4 # length = ratio x width + coverage_cap: 0.45 # skip a mark that would exceed this local fill + neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood + # Advisory only: reports a verdict, never blocks generation or changes exit codes. # Millimetres are measured on the vector page declared above. printability: diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 560d559..ac07d38 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -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": diff --git a/src/orisys_sensor_design/stroke_pattern.py b/src/orisys_sensor_design/stroke_pattern.py new file mode 100644 index 0000000..77a7471 --- /dev/null +++ b/src/orisys_sensor_design/stroke_pattern.py @@ -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", +] diff --git a/tests/test_binary_preview_cli.py b/tests/test_binary_preview_cli.py deleted file mode 100644 index 58b2ce6..0000000 --- a/tests/test_binary_preview_cli.py +++ /dev/null @@ -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 diff --git a/tests/test_stroke_pattern.py b/tests/test_stroke_pattern.py new file mode 100644 index 0000000..81d76a3 --- /dev/null +++ b/tests/test_stroke_pattern.py @@ -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 diff --git a/tests/test_stroke_texture_cli.py b/tests/test_stroke_texture_cli.py index c9d322b..fe32004 100644 --- a/tests/test_stroke_texture_cli.py +++ b/tests/test_stroke_texture_cli.py @@ -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)