initial version of random angle strokes

This commit is contained in:
ykcheungab
2026-10-03 20:41:30 +08:00
parent 1675ccb4af
commit 59f48d6d06
7 changed files with 695 additions and 22 deletions
+5 -5
View File
@@ -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)
@@ -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())
+5
View File
@@ -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,
Binary file not shown.
+98 -6
View File
@@ -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":
+73
View File
@@ -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
+7 -11
View File
@@ -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"]]