Give the stroke pattern one mode and design.yaml tuning

The twelve experimental modes collapse to camera_compensated_width, whose ten
knobs now come from a `stroke_pattern` block, and the PNG-only binary-preview
command goes with them as a strict subset of stroke-texture.

Verified: output is byte-identical at the defaults against the previous code, a
knob change is visible in the artifacts, the suite is green and the doc gate is
clean.
This commit is contained in:
ykcheungab
2026-10-04 09:40:17 +08:00
parent 5bd7dd7a01
commit 30a903a299
9 changed files with 699 additions and 421 deletions
+3 -3
View File
@@ -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 `stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash
manifest from existing color artwork, for example: manifest from existing color artwork, for example:
`orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a `orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a
`strokes` subdirectory; `--output-dir` selects another. `binary-preview` `strokes` subdirectory; `--output-dir` selects another. Tuning lives in the
remains PNG-only. Nominal stroke width is 0.11–0.28 mm; verify vectors and revision's `stroke_pattern` block. Nominal stroke width is 0.11–0.28 mm;
printed dimensions before manufacturing. verify vectors and printed dimensions before manufacturing.
## Inspect printability (advisory) ## Inspect printability (advisory)
+267 -270
View File
@@ -5,26 +5,21 @@ Run after the colour texture command has produced its PNGs::
blender -b -t 2 --python-exit-code 1 \ blender -b -t 2 --python-exit-code 1 \
-P blender/scripts/random_stroke_pattern.py -- \ -P blender/scripts/random_stroke_pattern.py -- \
--job build/textures/finger/r01/_texture_views_job.json \ --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 Accepts either a single-view or a multiview colour job. Each distinct source colour is one cell
texture job or a multiview job. Each distinct source colour is treated as one cell of the colour artwork; a split cell can have a different centre in the two print views.
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 One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of ``width_mm`` swept
cell positions. ``length_mm``, rotated per cell, and its width is scaled from that cell's footprint on the print
The physical-size outputs compare two stroke widths with a 0.2 mm-long, page so a cell the projection stretches receives a proportionally larger mark. The width is then
0.1 mm-wide capsule. Clipping and merged neighbours can change the widths of clamped to ``width_min_mm``..``width_max_mm``.
visible features.
The spaced 0.12 mm trial rejects clipped or colliding marks on each print page; **This is an image-space experiment, not a print master.** Clipping and merged neighbours change
its selection can differ between the two UV views, so it is also preview-only. the visible widths, so verify dimensions on the printed part before manufacturing.
The balanced trial permits overlaps but caps nearby black coverage.
The camera-scaled trial uses the source sensing-camera grid for a radial size Tuning lives in ``design.yaml`` under ``stroke_pattern``; the CLI serialises it here as
gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These are ``--params``. With no ``--params`` the built-in ``DEFAULT_PARAMS`` apply.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -45,39 +40,75 @@ if script_dir not in sys.path:
sys.path.insert(0, script_dir) 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 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} MODE = "camera_compensated_width"
MODES = (
"dots", #: Every tunable, at the values this experiment shipped with. The CLI writes a `design.yaml`
"dashes", #: `stroke_pattern` block here as `--params`; `--seed`, `--min-cell-pixels` and
"medium_dashes", #: `--camera-width-fraction` override individual keys for one run.
"dense_dashes", DEFAULT_PARAMS: dict[str, float] = {
*PHYSICAL_WIDTH_MM, "seed": 7,
"length_0p2_width_0p1mm", "min_cell_pixels": 20,
"spaced_width_0p12mm", "jitter_fraction": 0.66,
"balanced_width_0p12mm", "angle_span_deg": 180.0,
"camera_scaled_width", "width_fraction": 0.5,
"camera_compensated_width", "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: def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--job", required=True, type=Path) parser.add_argument("--job", required=True, type=Path)
parser.add_argument("--output-dir", 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( parser.add_argument(
"--modes", "--params",
nargs="+", type=Path,
choices=MODES, default=None,
default=MODES, help="JSON of stroke tuning values; overrides DEFAULT_PARAMS key by key",
help="Generate only these preview variants", )
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( parser.add_argument(
"--camera-width-fraction", "--camera-width-fraction",
type=float, type=float,
default=0.5, default=None,
help="Compensated stroke width as a fraction of the local print-cell span", help="Override params.width_fraction for this run",
) )
parser.add_argument( parser.add_argument(
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",) "--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
@@ -85,23 +116,52 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--artifact-prefix", "--artifact-prefix",
default=None, default=None,
help="Name single-mode outputs <prefix>_<source-id>.<format>", help="Name outputs <prefix>_<source-id>.<format>",
) )
return parser.parse_args(argv) return parser.parse_args(argv)
def cell_code(rgb: np.ndarray, seed: int) -> tuple[float, float, float, float]: def resolve_params(args: argparse.Namespace) -> dict:
"""Return bounded offsets, angle and size from one exact source colour.""" """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( digest = hashlib.blake2b(
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True), bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True),
digest_size=8, digest_size=8,
).digest() ).digest()
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)] values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
return ( return (
0.66 * (values[0] - 0.5), jitter_fraction * (values[0] - 0.5),
0.66 * (values[1] - 0.5), jitter_fraction * (values[1] - 0.5),
math.pi * values[2], math.radians(angle_span_deg) * values[2],
0.85 + 0.3 * values[3],
) )
@@ -186,22 +246,25 @@ def camera_cell_lookup(
return 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( 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]: ) -> tuple[float, float]:
"""Return nominal print dimensions from one projected camera-cell footprint.""" """Nominal print width and length in mm 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 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( def capsule_polygon(
@@ -227,66 +290,39 @@ def capsule_polygon(
def draw_mark( def draw_mark(
canvas: np.ndarray, canvas: np.ndarray,
region, region,
seed: int, params: dict,
mode: str,
px_per_mm: float, px_per_mm: float,
patterned: np.ndarray | None = None, patterned: np.ndarray | None = None,
camera_cell: tuple[int, int] | 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, vector_shapes: list | None = None,
) -> float: ) -> float:
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
colour, cell_x, cell_y, width, height = region colour, cell_x, cell_y, width, height = region
rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255) 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) span = min(width, height)
centre_x = cell_x + dx * width centre_x = cell_x + dx * width
centre_y = cell_y + dy * height centre_y = cell_y + dy * height
if mode == "camera_compensated_width": if camera_cell is None:
if camera_cell is None: raise ValueError(
raise ValueError( "camera-compensated marks require an unambiguous camera cell"
"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 width_mm, length_mm = camera_compensated_size(
half_length = (length_mm - width_mm) * px_per_mm / 2 span,
elif mode == "camera_scaled_width": px_per_mm,
if camera_cell is None or grid_shape is None: params["width_fraction"],
raise ValueError("camera-scaled marks require an unambiguous camera cell") params["width_min_mm"],
width_mm, length_mm = camera_scaled_size(camera_cell, *grid_shape) params["width_max_mm"],
radius = width_mm * px_per_mm / 2 params["length_ratio"],
half_length = (length_mm - width_mm) * px_per_mm / 2 )
elif mode == "spaced_width_0p12mm": radius = width_mm * px_per_mm / 2
radius = 0.12 * px_per_mm / 2 half_length = (length_mm - width_mm) * px_per_mm / 2
half_length = (0.30 - 0.12) * px_per_mm / 2 extent = radius + half_length
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 ( if patterned is not None and (
centre_x - extent < 0 centre_x - extent < 0
or centre_y - extent < 0 or centre_y - extent < 0
@@ -310,24 +346,19 @@ def draw_mark(
if patterned is not None: if patterned is not None:
if np.any(footprint & ~patterned[y0:y1, x0:x1]): if np.any(footprint & ~patterned[y0:y1, x0:x1]):
return 0.0 return 0.0
if mode == "spaced_width_0p12mm": local_radius = round(params["neighbourhood_mm"] * px_per_mm)
clearance = distance_sq <= (radius + gap_px) ** 2 lx0 = max(0, round(centre_x) - local_radius)
if np.any(canvas[y0:y1, x0:x1] & clearance): lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1)
return 0.0 ly0 = max(0, round(centre_y) - local_radius)
else: ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1)
local_radius = round(0.5 * px_per_mm) local_pattern = patterned[ly0:ly1, lx0:lx1]
lx0 = max(0, round(centre_x) - local_radius) local_area = np.count_nonzero(local_pattern)
lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1) if local_area == 0:
ly0 = max(0, round(centre_y) - local_radius) return 0.0
ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1) existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern)
local_pattern = patterned[ly0:ly1, lx0:lx1] added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
local_area = np.count_nonzero(local_pattern) if (existing + added) / local_area > params["coverage_cap"]:
if local_area == 0: return 0.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 canvas[y0:y1, x0:x1] |= footprint
if vector_shapes is not None: if vector_shapes is not None:
vector_shapes.append( vector_shapes.append(
@@ -459,34 +490,29 @@ def source_mesh_polygons(
return polygons, width_mm, height_mm return polygons, width_mm, height_mm
def artifact_stem(source_id: str, mode: str, prefix: str | None) -> str: def artifact_stem(source_id: str, prefix: str | None) -> str:
return f"{prefix}_{source_id}" if prefix else f"{source_id}_{mode}" return f"{prefix}_{source_id}" if prefix else f"{source_id}_{MODE}"
def run( def run(
job_path: Path, job_path: Path,
output_dir: Path, output_dir: Path,
seed: int, params: dict,
minimum: int,
modes: tuple[str, ...],
camera_width_fraction: float,
formats: tuple[str, ...] = ("png",), formats: tuple[str, ...] = ("png",),
artifact_prefix: str | None = None, artifact_prefix: str | None = None,
) -> None: ) -> None:
job = json.loads(job_path.read_text(encoding="utf-8")) job = json.loads(job_path.read_text(encoding="utf-8"))
sources, px_per_mm = job_sources(job) sources, px_per_mm = job_sources(job)
if artifact_prefix is not None and ( if artifact_prefix is not None and (
len(modes) != 1 not artifact_prefix
or not artifact_prefix
or not all(char.isalnum() or char in "_-" for char in 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)): if not formats or len(formats) != len(set(formats)):
raise ValueError("formats must be non-empty and distinct") raise ValueError("formats must be non-empty and distinct")
targets = [ 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 source_id, _, _, _ in sources
for mode in modes
for fmt in formats for fmt in formats
] ]
for target in targets: for target in targets:
@@ -503,7 +529,7 @@ def run(
) )
surface = np.any(rgb != 0, axis=2) surface = np.any(rgb != 0, axis=2)
patterned = surface & np.any(rgb != 255, 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): if not regions or not np.any(patterned):
raise ValueError(f"{source_id}: no coloured cell regions in source PNG") 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])) 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)", f"({cell_span / px_per_mm:.3f} mm on the print page)",
flush=True, flush=True,
) )
lookup = None # A mark is sized from the cell's footprint, so an ambiguous palette colour has no
grid_shape = None # known footprint and is skipped rather than guessed.
camera_modes = ("camera_scaled_width", "camera_compensated_width") grid_shape = (int(settings["nx"]), int(settings["ny"]))
if any(mode in modes for mode in camera_modes): pair_seed = source_seed if source_seed is not None else settings.get("seed")
grid_shape = (int(settings["nx"]), int(settings["ny"])) if pair_seed is None:
pair_seed = source_seed if source_seed is not None else settings.get("seed") raise ValueError("camera-compensated preview requires a seeded colour palette")
if pair_seed is None: lookup = camera_cell_lookup(
raise ValueError( *grid_shape, float(settings["color_r"]), int(pair_seed)
"camera-based preview requires a seeded colour palette" )
) missing = [
lookup = camera_cell_lookup( hex(region[0])
*grid_shape, float(settings["color_r"]), int(pair_seed) for region in regions
) if region[0] != 0xFF0000 and region[0] not in lookup
missing = [ ]
hex(region[0]) if missing:
for region in regions raise ValueError(
if region[0] != 0xFF0000 and region[0] not in lookup f"{source_id}: camera cell missing for "
] f"{len(missing)} colours: {missing[:5]}"
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 ""
) )
ambiguous = [
hex(region[0])
for region in regions
if region[0] != 0xFF0000 and lookup[region[0]] is None
]
if ambiguous:
print( print(
f"{source_id}: {mode}, {len(accepted)}/{len(mode_regions)} marks, " f"{source_id}: skip {len(ambiguous)} ambiguous palette regions "
f"black coverage {coverage:.1%}, median mark width " f"{ambiguous[:5]}",
f"{median_width:.1f} px{physical_width}{width_range} -> {stem}",
flush=True, 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: def main() -> int:
argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
args = parse_args(argv) 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( run(
args.job.resolve(), args.job.resolve(),
args.output_dir.resolve(), args.output_dir.resolve(),
args.seed, resolve_params(args),
args.min_cell_pixels,
tuple(args.modes),
args.camera_width_fraction,
tuple(args.formats), tuple(args.formats),
args.artifact_prefix, args.artifact_prefix,
) )
+20
View File
@@ -88,6 +88,26 @@ texture_patch_views:
print: print:
base_fill: owned # white ground, already split by the seam 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. # Advisory only: reports a verdict, never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
printability: printability:
+20
View File
@@ -94,6 +94,26 @@ texture_patch_views:
print: print:
base_fill: owned # white ground, already split by the seam 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. # Advisory only: reports a verdict, never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
printability: printability:
+46 -73
View File
@@ -21,12 +21,19 @@ from orisys_sensor_design.manifest import (
export_output_dir, export_output_dir,
export_revision_dir, export_revision_dir,
load_profile, load_profile,
load_yaml,
repo_root, repo_root,
sha256_file, sha256_file,
validate_revision_metadata, validate_revision_metadata,
write_export_job, write_export_job,
write_manifest, write_manifest,
) )
from orisys_sensor_design.stroke_pattern import (
STROKE_MODE,
StrokePatternConfigError,
resolve_stroke_settings,
write_stroke_params,
)
from orisys_sensor_design.printability import ( from orisys_sensor_design.printability import (
REPORT_FILENAME, REPORT_FILENAME,
PrintabilityConfigError, 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): 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) root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths) errors = validate_revision_metadata(paths)
@@ -507,9 +515,24 @@ def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
for message in errors: for message in errors:
print(f"ERROR: {message}", file=sys.stderr) print(f"ERROR: {message}", file=sys.stderr)
return None, 1 return None, 1
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0: try:
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr) 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 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: try:
source = _stroke_source_job(paths, root, args) source = _stroke_source_job(paths, root, args)
except FileNotFoundError as exc: 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: except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr) print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr)
return None, 2 return None, 2
return (paths, *source), 0 return (paths, *source, settings), 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
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int: 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) context, code = _stroke_cli_context(revision_dir, args)
if context is None: if context is None:
return code return code
paths, job_path, _job, entries, source_ids = context paths, job_path, _job, entries, source_ids, settings = context
for entry in entries: for entry in entries:
for fmt in ("png", "svg"): for fmt in ("png", "svg"):
try: 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", print("ERROR: stroke artifacts already exist; choose a fresh --output-dir",
file=sys.stderr) file=sys.stderr)
return 2 return 2
params_path = write_stroke_params(settings, output_dir)
code = _run_blender_script( code = _run_blender_script(
"random_stroke_pattern.py", paths.source_blend, "random_stroke_pattern.py", paths.source_blend,
["--job", str(job_path), "--output-dir", str(output_dir), ["--job", str(job_path), "--output-dir", str(output_dir),
"--modes", "camera_compensated_width", "--formats", "png", "svg", "pdf", "--formats", "png", "svg", "pdf",
"--artifact-prefix", "stroke_texture", "--seed", str(args.seed), "--artifact-prefix", "stroke_texture", "--params", str(params_path)],
"--camera-width-fraction", str(args.camera_width_fraction)],
) )
if code != 0: if code != 0:
return code 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_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
"source_blend": {"path": str(paths.source_blend), "source_blend": {"path": str(paths.source_blend),
"sha256": sha256_file(paths.source_blend)}, "sha256": sha256_file(paths.source_blend)},
"settings": {"mode": "camera_compensated_width", "seed": args.seed, # The v1 keys stay for compatibility; `resolved` carries what the block and the flag
"camera_width_fraction": args.camera_width_fraction}, # 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": [ "outputs": [
{"id": source_id, {"id": source_id,
"sources": {fmt: {"path": str(Path(entry["artifacts"][fmt])), "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_p = sub.add_parser(
"stroke-texture", "stroke-texture",
help="Export Random Stroke Pattern PNG/SVG/PDF from colour artwork", 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)", help="Fresh output directory (default: strokes beside the colour job)",
) )
stroke_p.add_argument( stroke_p.add_argument(
"--seed", type=int, default=7, "--seed", type=int, default=None,
help="Reproducible stroke offsets and angles (default: 7)", help="Override stroke_pattern.seed for this run",
) )
stroke_p.add_argument( stroke_p.add_argument(
"--camera-width-fraction", type=float, default=0.5, "--camera-width-fraction", type=float, default=None,
help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)", help="Override stroke_pattern.width_fraction for this run",
) )
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes") 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) code = cmd_texture(args.revision_dir, args)
elif args.command == "texture-views": elif args.command == "texture-views":
code = cmd_texture_views(args.revision_dir, args) 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": elif args.command == "stroke-texture":
code = cmd_stroke_texture(args.revision_dir, args) code = cmd_stroke_texture(args.revision_dir, args)
elif args.command == "inspect": elif args.command == "inspect":
+151
View File
@@ -0,0 +1,151 @@
"""Tuning for the Random Stroke Pattern: the `stroke_pattern` block in `design.yaml`.
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
footprint on the print page — so a cell the projection stretches gets a proportionally larger
mark — then clamped. That gives ten live knobs, and this module owns their names, defaults and
ranges on the host side.
Why the keys are exactly these
------------------------------
Everything else in the generator is either derived or dead:
* ``formats`` is **not** a knob. ``stroke-texture`` fixes its artifact set at png+svg+pdf,
because that set is part of the command's contract; a yaml value could only disagree with it.
* The generator's old per-cell size scale is **dead for this mode** — only the deleted
dashed/dotted modes read it — so it is gone rather than exposed. No key here can be inert.
Precedence is ``these defaults < design.yaml < CLI flag``, matching how the texture commands
already layer the revision block under their flags.
The Blender script keeps its own copy of these names and defaults, because it runs under Blender
while this module is stdlib-only — the same split as the ``::report::`` prefix constants. A test
pins the two copies against each other, so drift fails rather than silently changing behaviour.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
#: The only mode the generator implements. Recorded in the job so a run is self-describing.
STROKE_MODE = "camera_compensated_width"
#: Every knob, at the values the experiment shipped with. Mirror of
#: ``random_stroke_pattern.DEFAULT_PARAMS``; a test asserts they agree.
STROKE_DEFAULTS: dict[str, Any] = {
"seed": 7,
"min_cell_pixels": 20,
"jitter_fraction": 0.66,
"angle_span_deg": 180.0,
"width_fraction": 0.5,
"width_min_mm": 0.11,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
}
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels"})
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
class StrokePatternConfigError(ValueError):
"""The `stroke_pattern` block cannot produce artwork."""
def validate_stroke_block(block: Any) -> list[str]:
"""Return every reason this block is unusable; empty when it is fine.
Strict about unknown keys on purpose: the generator would ignore a misspelled knob and the
run would look successful while the value did nothing.
"""
if block is None:
return []
where = "stroke_pattern"
if not isinstance(block, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(block) - STROKE_KEYS)
if extra:
errors.append(
f"{where}: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}"
)
for key, value in block.items():
if key not in STROKE_KEYS:
continue
if isinstance(value, bool) or not isinstance(value, (int, float)):
errors.append(f"{where}.{key}: must be a number (got {value!r})")
continue
if key in STROKE_INT_KEYS and not isinstance(value, int):
errors.append(f"{where}.{key}: must be an integer (got {value!r})")
continue
if value != value or value in (float("inf"), float("-inf")):
errors.append(f"{where}.{key}: must be finite (got {value!r})")
if errors:
return errors
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
errors.append(
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
)
if "angle_span_deg" in block and block["angle_span_deg"] < 0.0:
errors.append(
f"{where}.angle_span_deg: must be >= 0 (got {block['angle_span_deg']!r})"
)
for key in ("width_fraction", "width_min_mm", "width_max_mm", "length_ratio",
"neighbourhood_mm"):
if key in block and block[key] <= 0.0:
errors.append(f"{where}.{key}: must be > 0 (got {block[key]!r})")
if "coverage_cap" in block and not 0.0 < block["coverage_cap"] <= 1.0:
errors.append(
f"{where}.coverage_cap: must be within (0, 1] (got {block['coverage_cap']!r})"
)
merged = {**STROKE_DEFAULTS, **{k: v for k, v in block.items() if k in STROKE_KEYS}}
if merged["width_min_mm"] > merged["width_max_mm"]:
errors.append(
f"{where}: width_min_mm {merged['width_min_mm']:g} must be <= width_max_mm "
f"{merged['width_max_mm']:g}, or the clamp inverts and every mark takes the minimum"
)
return errors
def resolve_stroke_settings(block: Any) -> dict[str, Any]:
"""The block merged over the defaults, or raise naming every problem."""
errors = validate_stroke_block(block)
if errors:
raise StrokePatternConfigError("\n".join(errors))
settings = dict(STROKE_DEFAULTS)
if isinstance(block, Mapping):
settings.update({key: block[key] for key in STROKE_KEYS if key in block})
return settings
def write_stroke_params(settings: Mapping[str, Any], output_dir: Path) -> Path:
"""Write the resolved tuning beside the artifacts, so a run records what it used."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / STROKE_PARAMS_FILENAME
path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
__all__ = [
"STROKE_DEFAULTS",
"STROKE_INT_KEYS",
"STROKE_KEYS",
"STROKE_MODE",
"STROKE_PARAMS_FILENAME",
"StrokePatternConfigError",
"resolve_stroke_settings",
"validate_stroke_block",
"write_stroke_params",
]
-73
View File
@@ -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
+182
View File
@@ -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
+10 -2
View File
@@ -55,7 +55,9 @@ def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
def run_blender(script, blend, args): def run_blender(script, blend, args):
calls.append((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"): for fmt in ("png", "svg", "pdf"):
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode()) (output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
return 0 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) monkeypatch.setattr(cli, "_run_blender_script", run_blender)
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0 assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0
assert calls[0][0] == "random_stroke_pattern.py" 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()) manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1" assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1"
assert manifest["source_job"]["sha256"] == sha256_file(job) assert manifest["source_job"]["sha256"] == sha256_file(job)