diff --git a/README.md b/README.md index 9403702..cb969d7 100644 --- a/README.md +++ b/README.md @@ -227,11 +227,11 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa --vector-width-mm 164 --vector-height-mm 160 ``` -Only the colored "patches" mode is reachable headlessly; the checker and dot -helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The -default 3280×3200 grid matches the in-app script and is deliberately slow -(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick -check runs, and the full default grid for the textures you actually ship. +`binary-preview` derives PNG-only tracking trials from the existing color job +in a separate directory; `--views` selects the multiview job. Stroke width +follows camera-cell print size, bounded to 0.11–0.28 mm. These image-space +trials are for probe comparisons, not print masters; UV views may select +different cells. See `binary-preview --help` for paths and sizing controls. ## Inspect printability (advisory) diff --git a/blender/scripts/binary_pattern_from_color.py b/blender/scripts/binary_pattern_from_color.py new file mode 100644 index 0000000..cebeaed --- /dev/null +++ b/blender/scripts/binary_pattern_from_color.py @@ -0,0 +1,507 @@ +"""Generate binary pattern trials from existing colour texture jobs. + +Run after the colour texture command has produced its PNGs:: + + blender -b -t 2 --python-exit-code 1 \ + -P blender/scripts/binary_pattern_from_color.py -- \ + --job build/textures/finger/r01/_texture_views_job.json \ + --output-dir .scratch/binary-pattern-from-color/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. It remains a preview because image-space regions can be distorted. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import sys +from pathlib import Path + +import bpy +import numpy as np + +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", +) + + +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", + ) + parser.add_argument( + "--camera-width-fraction", + type=float, + default=0.5, + help="Compensated stroke width as a fraction of the local print-cell span", + ) + 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.""" + 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], + ) + + +def read_rgb(path: Path) -> tuple[np.ndarray, int, int]: + image = bpy.data.images.load(str(path), check_existing=False) + try: + width, height = image.size + pixels = np.empty(width * height * 4, dtype=np.float32) + image.pixels.foreach_get(pixels) + rgb = np.clip( + np.rint(pixels.reshape(height, width, 4)[:, :, :3] * 255), 0, 255 + ).astype(np.uint8) + return rgb, width, height + finally: + bpy.data.images.remove(image) + + +def cell_regions(rgb: np.ndarray, minimum: int): + """Measure source-colour regions without assuming a rectangular UV cell.""" + height, width, _ = rgb.shape + packed = ( + (rgb[:, :, 0].astype(np.uint32) << 16) + | (rgb[:, :, 1].astype(np.uint32) << 8) + | rgb[:, :, 2].astype(np.uint32) + ) + colours, inverse, counts = np.unique( + packed, return_inverse=True, return_counts=True + ) + rows = np.repeat(np.arange(height, dtype=np.int32), width) + cols = np.tile(np.arange(width, dtype=np.int32), height) + n = len(colours) + left = np.full(n, width, dtype=np.int32) + right = np.full(n, -1, dtype=np.int32) + bottom = np.full(n, height, dtype=np.int32) + top = np.full(n, -1, dtype=np.int32) + np.minimum.at(left, inverse, cols) + np.maximum.at(right, inverse, cols) + np.minimum.at(bottom, inverse, rows) + np.maximum.at(top, inverse, rows) + cx = np.bincount(inverse, weights=cols, minlength=n) / counts + cy = np.bincount(inverse, weights=rows, minlength=n) / counts + for index, colour in enumerate(colours): + if colour in (0, 0xFFFFFF) or counts[index] < minimum: + continue + cell_width = right[index] - left[index] + 1 + cell_height = top[index] - bottom[index] + 1 + yield ( + int(colour), + float(cx[index]), + float(cy[index]), + int(cell_width), + int(cell_height), + ) + + +def camera_cell_lookup( + nx: int, ny: int, colour_radius: float, seed: int +) -> dict[int, tuple[int, int] | None]: + """Recover the original camera-grid cell from its quantized palette colour.""" + script_dir = str(Path(__file__).resolve().parent) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + from random_patch_dd02c import precompute_cell_colors + + rng_state = np.random.get_state() + try: + np.random.seed(seed) + palette = precompute_cell_colors(nx, ny, colour_radius) + finally: + np.random.set_state(rng_state) + rgb = np.clip(np.rint(palette * 255), 0, 255).astype(np.uint8) + packed = ( + (rgb[:, :, 0].astype(np.uint32) << 16) + | (rgb[:, :, 1].astype(np.uint32) << 8) + | rgb[:, :, 2].astype(np.uint32) + ) + lookup: dict[int, tuple[int, int] | None] = {} + for j in range(ny): + for i in range(nx): + colour = int(packed[j, i]) + lookup[colour] = None if colour in lookup else (i, j) + 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 +) -> 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 + + +def draw_mark( + canvas: np.ndarray, + region, + seed: int, + mode: str, + 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, +) -> float: + 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) + 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 + ) + 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 + if patterned is not None and ( + centre_x - extent < 0 + or centre_y - extent < 0 + or centre_x + extent >= canvas.shape[1] + or centre_y + extent >= canvas.shape[0] + ): + return 0.0 + x0 = max(0, math.floor(centre_x - extent)) + x1 = min(canvas.shape[1], math.ceil(centre_x + extent + 1)) + y0 = max(0, math.floor(centre_y - extent)) + y1 = min(canvas.shape[0], math.ceil(centre_y + extent + 1)) + if x0 >= x1 or y0 >= y1: + return 0.0 + yy, xx = np.ogrid[y0:y1, x0:x1] + px = xx - centre_x + py = yy - centre_y + along = px * math.cos(angle) + py * math.sin(angle) + across = -px * math.sin(angle) + py * math.cos(angle) + distance_sq = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 + footprint = distance_sq <= radius**2 + 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 + canvas[y0:y1, x0:x1] |= footprint + return 2 * radius + + +def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None: + height, width = marks.shape + rgba = np.zeros((height, width, 4), dtype=np.float32) + rgba[:, :, :3] = (surface & ~marks)[:, :, None] + rgba[:, :, 3] = 1.0 + image = bpy.data.images.new(path.stem, width=width, height=height, alpha=True) + try: + image.pixels.foreach_set(rgba.ravel()) + image.update() + image.filepath_raw = str(path) + image.file_format = "PNG" + image.save() + finally: + bpy.data.images.remove(image) + + +def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]: + """Resolve source PNGs and physical scale from a colour texture job.""" + settings = job.get("settings") or {} + schema = job.get("schema") + if schema == "orisys-sensor-design/texture-job-multiview-v1": + entries = job.get("pairs") or [] + page = job.get("page") or {} + if page.get("mode") != "auto" or any( + entry.get("page", "auto") != "auto" for entry in entries + ): + raise ValueError("multiview preview requires auto-sized pages") + px_per_mm = float(page.get("px_per_mm", 0)) + sources = [ + (entry["id"], Path(entry["artifacts"]["png"]), entry.get("seed")) + for entry in entries + ] + elif schema == "orisys-sensor-design/texture-job-v3": + entries = job.get("outputs") or [] + vector = settings.get("vector") or {} + width_mm = float(vector.get("width_mm", 0)) + height_mm = float(vector.get("height_mm", 0)) + if width_mm <= 0 or height_mm <= 0: + raise ValueError( + "single-view preview requires positive vector page dimensions" + ) + px_per_mm = float(settings["width"]) / width_mm + vertical_scale = float(settings["height"]) / height_mm + if not math.isclose(px_per_mm, vertical_scale, rel_tol=1e-6): + raise ValueError( + "single-view page has unequal horizontal and vertical scales" + ) + sources = [ + (entry["uv_map"], Path(entry["artifacts"]["png"]), settings.get("seed")) + for entry in entries + ] + else: + raise ValueError(f"unsupported colour texture job schema: {schema!r}") + if not sources or not math.isfinite(px_per_mm) or px_per_mm <= 0: + raise ValueError("colour texture job needs source PNGs and positive px_per_mm") + for _, source, _ in sources: + if not source.is_file(): + raise FileNotFoundError(f"run the colour generator first: {source}") + return sources, px_per_mm + + +def run( + job_path: Path, + output_dir: Path, + seed: int, + minimum: int, + modes: tuple[str, ...], + camera_width_fraction: float, +) -> None: + job = json.loads(job_path.read_text(encoding="utf-8")) + sources, px_per_mm = job_sources(job) + targets = [ + output_dir / f"{source_id}_{mode}.png" + for source_id, _, _ in sources + for mode in modes + ] + for target in targets: + if target.exists(): + raise FileExistsError(f"refusing to overwrite {target}") + output_dir.mkdir(parents=True, exist_ok=True) + settings = job.get("settings", {}) + for source_id, source, source_seed in sources: + rgb, width, height = read_rgb(source) + surface = np.any(rgb != 0, axis=2) + patterned = surface & np.any(rgb != 255, axis=2) + regions = list(cell_regions(rgb, minimum)) + 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])) + print( + f"{source_id}: median cell span {cell_span:.1f} px " + 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) + 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, + ) + for region in mode_regions + ] + target = output_dir / f"{source_id}_{mode}.png" + write_binary_png(target, marks, surface) + 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} -> {target}", + 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, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/blender/scripts/probe_render.py b/blender/scripts/probe_render.py index 0b6c12b..6849e3c 100644 --- a/blender/scripts/probe_render.py +++ b/blender/scripts/probe_render.py @@ -796,6 +796,10 @@ def main(): print(f"ERROR: {exc}", file=sys.stderr) return 3 if isinstance(exc, ArtifactError) else 1 + # The scene may be saved with a production-view camera active. Projection, + # rendered frames, and the reported ROI must all use the job's sensor camera. + scene.camera = camera_obj + started = time.time() mesh = mesh_obj.data try: @@ -992,6 +996,7 @@ def main(): "sha256": _sha256(texture_path), }, "uv_map": config["uv_map"], + "camera": camera_obj.name, "width": config["width"], "height": config["height"], "settings_applied": settings_applied, diff --git a/sensors/finger-xynova/r01/source/geometry.blend b/sensors/finger-xynova/r01/source/geometry.blend index 0a4702a..04d7e1d 100644 Binary files a/sensors/finger-xynova/r01/source/geometry.blend and b/sensors/finger-xynova/r01/source/geometry.blend differ diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 84a2184..26fb644 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import json +import math import os import shutil import subprocess @@ -177,6 +178,8 @@ def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int: str(blender), "-b", str(blend), + "--python-exit-code", + "1", "-P", str(script_path), "--", @@ -467,6 +470,77 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: return 0 +def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int: + """Convert existing colour textures to a bounded binary camera-footprint trial.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 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) + return 2 + texture_dir = resolve_texture_output_dir(root, paths, None) + job_name = "_texture_views_job.json" if args.views else "_texture_job.json" + job_path = (args.source_job or texture_dir / job_name).resolve() + if not job_path.is_file(): + print(f"ERROR: colour texture job missing: {job_path}", file=sys.stderr) + return 3 + try: + job = json.loads(job_path.read_text(encoding="utf-8")) + expected_schema = ( + "orisys-sensor-design/texture-job-multiview-v1" + if args.views else "orisys-sensor-design/texture-job-v3" + ) + if job.get("schema") != expected_schema: + raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}") + if Path(job["revision_dir"]).resolve() != paths.root.resolve(): + raise ValueError("source job belongs to a different revision") + if Path(job["source_blend"]).resolve() != paths.source_blend.resolve(): + raise ValueError("source job was built from a different Blender scene") + entries = job["pairs" if args.views else "outputs"] + source_ids = [str(entry["id" if args.views else "uv_map"]) for entry in entries] + if (not source_ids or len(source_ids) != len(set(source_ids)) + or any(not source_id or not all( + char.isalnum() or char in "_-" for char in source_id + ) for source_id in source_ids)): + raise ValueError("source job needs distinct, filename-safe output IDs") + except (OSError, ValueError, KeyError, TypeError) as exc: + print(f"ERROR: invalid colour texture job {job_path}: {exc}", file=sys.stderr) + return 2 + + 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( + "binary_pattern_from_color.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 + + def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int: """Generate one owned texture per production view. @@ -1357,7 +1431,6 @@ def _run_track_stage( def cmd_deform(revision_dir: Path, args: argparse.Namespace) -> int: """Deform the sensing surface through the declared x/y/z sweep.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -1374,7 +1447,6 @@ def cmd_deform(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_render(revision_dir: Path, args: argparse.Namespace) -> int: """Render what the sensor camera sees for every deformed frame.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -1392,7 +1464,6 @@ def cmd_render(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_track(revision_dir: Path, args: argparse.Namespace) -> int: """Estimate DIS optical flow against the zero-displacement reference.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -1426,7 +1497,6 @@ def cmd_track(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_sensitivity(revision_dir: Path, args: argparse.Namespace) -> int: """Run the probe bench end to end and write the sensitivity report.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -1490,7 +1560,6 @@ def cmd_sensitivity(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_optimise(revision_dir: Path, args: argparse.Namespace) -> int: """Search patch-pattern parameters for the best measured sensitivity.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -1740,7 +1809,6 @@ def _compare_stage_runner(paths, args): def cmd_compare(revision_dir: Path, args: argparse.Namespace) -> int: """Run one pattern under several estimator settings and plot the comparison.""" - root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) if errors: @@ -2112,6 +2180,28 @@ 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)", + ) + manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes") manifest_p.add_argument("revision_dir", type=Path) manifest_p.add_argument( @@ -2505,6 +2595,8 @@ 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 == "inspect": code = cmd_inspect(args.revision_dir, args) elif args.command == "preview": diff --git a/tests/test_binary_preview_cli.py b/tests/test_binary_preview_cli.py new file mode 100644 index 0000000..930af66 --- /dev/null +++ b/tests/test_binary_preview_cli.py @@ -0,0 +1,73 @@ +"""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] == "binary_pattern_from_color.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_render.py b/tests/test_render.py index 56ed633..77e8b6a 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -34,7 +34,7 @@ MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1" #: NOT pinned here: the user changes its render resolution as the bench is re-targeted #: (500x500 -> 250x250), and a literal would call that legitimate edit a regression. What #: the end-to-end test guards instead is that the pipeline never writes the blend. -MESH_PX_PER_MM = {"min": 10.4994, "p50": 13.4302, "max": 38.5943} +MESH_PX_PER_MM = {"min": 3.80165, "p50": 4.87396, "max": 20.94209} FRAME_COUNT = 31 REFERENCE_NAME = "000_reference" REFERENCE_NULL_NAME = "reference_null" @@ -651,14 +651,9 @@ def test_render_exits_3_when_a_required_artifact_is_missing( @pytest.fixture(scope="module") def real_npz(tmp_path_factory) -> Path: - """The deform stage's contract artifact, produced by T1's script if absent.""" - canonical = deform.probe_artifact_paths(deform.probe_output_dir(REPO, paths_for_deform()))[ - "frames" - ] - if canonical.is_file() and canonical.stat().st_size > 0: - return canonical + """Generate deformation from the current scene so mesh edits cannot reuse stale frames.""" if BLENDER is None: - pytest.skip("probe_frames.npz absent and no Blender to build it") + pytest.skip("no Blender to build probe_frames.npz") scratch = tmp_path_factory.mktemp("t2_deform") rev = RevisionPaths.from_revision_dir(REVISION_DIR) probe = deform.resolve_probe(rev) @@ -694,6 +689,7 @@ def test_end_to_end_render_manifest(paths, settings, real_npz, tmp_path): # --- identity and settings that must never silently change --- assert manifest["schema"] == MANIFEST_SCHEMA assert manifest["uv_map"] == "compute" + assert manifest["camera"] == "cam_sensor" width, height = tuple(settings.resolution) assert (manifest["width"], manifest["height"]) == (width, height) applied = manifest["settings_applied"] @@ -736,14 +732,14 @@ def test_end_to_end_render_manifest(paths, settings, real_npz, tmp_path): <= mesh_scale["p95"] <= mesh_scale["max"] ) - # MESH_PX_PER_MM is measured at 500x500, and px/mm is linear in the render width, so + # MESH_PX_PER_MM is measured at 250x250, and px/mm is linear in the render width, so # scale it to the resolution the design declares rather than re-pinning it every time # the bench is re-targeted. Halving the width must halve the scale. - factor = width / 500.0 + factor = width / 250.0 assert mesh_scale["min"] == pytest.approx(MESH_PX_PER_MM["min"] * factor, abs=0.02) assert mesh_scale["p50"] == pytest.approx(MESH_PX_PER_MM["p50"] * factor, abs=0.02) assert mesh_scale["max"] == pytest.approx(MESH_PX_PER_MM["max"] * factor, abs=0.05) - assert mesh_scale["basis"] == "per-mesh-edge projected px / 3D mm, all 7341 edges" + assert mesh_scale["basis"] == "per-mesh-edge projected px / 3D mm, all 7590 edges" probe_scale = manifest["probe_px_per_mm"] axis_scales = [probe_scale["x"], probe_scale["y"], probe_scale["z"]]