改用相机网格生成笔画纹理

统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。
已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
ykcheungab
2026-10-06 10:51:56 +08:00
parent 35da84ef60
commit e8d231ec39
25 changed files with 405507 additions and 94905 deletions
+9 -6
View File
@@ -232,12 +232,15 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa
--vector-width-mm 164 --vector-height-mm 160 --vector-width-mm 164 --vector-height-mm 160
``` ```
`stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash `stroke-texture` exports black capsule strokes using the same camera grid,
manifest from existing color artwork, for example: projection and UV output size as `texture_patch`. First generate the single-view
`orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a texture job, then run `orisys-design stroke-texture sensors/finger-xynova/r01`.
`strokes` subdirectory; `--output-dir` selects another. Tuning lives in the The stroke output is independent of the color artwork pixels. The revision's
revision's `stroke_pattern` block. Nominal stroke width is 0.11–0.28 mm; `stroke_pattern` block controls the seed, width and length ratios, and orientation
verify vectors and printed dimensions before manufacturing. mode; `--output-dir` selects a fresh destination. The command writes PNG/SVG/PDF,
resolved settings and a hash manifest. Two-view `--views` jobs are not supported.
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
measure the production SVG before manufacturing.
## Inspect printability (advisory) ## Inspect printability (advisory)
+54 -17
View File
@@ -1,4 +1,4 @@
"""Convert camera-projected colour-patch cells to black capsule strokes. """Convert camera-projected patch cells to black capsule strokes.
Run with Blender's source geometry and the existing texture job. This first Run with Blender's source geometry and the existing texture job. This first
camera-space prototype preserves the colour pattern's grid and visibility camera-space prototype preserves the colour pattern's grid and visibility
@@ -7,8 +7,8 @@ projection. It does not enforce a physical minimum stroke width.
import argparse import argparse
import json import json
from pathlib import Path
import sys import sys
from pathlib import Path
import bpy import bpy
@@ -19,22 +19,41 @@ from camera_grid_stroke_optimizer import optimize_angles
from patch_geometry import clip_rect from patch_geometry import clip_rect
from patch_projection import build_patch_geometry from patch_projection import build_patch_geometry
from random_patch_dd02c import ( from random_patch_dd02c import (
_clipped_polygons, _clipped_shapes, _prepare_mesh_object, _require_object, _clipped_polygons,
_require_uv_layer, fill_background, fill_polygon, save_png_image, _clipped_shapes,
write_pdf_vector, write_svg_vector, _prepare_mesh_object,
_require_object,
_require_uv_layer,
fill_background,
fill_polygon,
save_png_image,
write_pdf_vector,
write_svg_vector,
) )
MODE = "camera_grid_capsules"
DEFAULT_PARAMS = {
"seed": 7,
"width_ratio": 0.42,
"length_ratio": 0.84,
"orientation_mode": "random",
"proposals": 100000,
}
def _args(): def _args():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--job", type=Path, required=True) parser.add_argument("--job", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--width-ratio", type=float, default=0.42) parser.add_argument("--params", type=Path, default=None)
parser.add_argument("--length-ratio", type=float, default=0.84) parser.add_argument("--artifact-prefix", default="camera_grid_stroke")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--width-ratio", type=float, default=None)
parser.add_argument("--length-ratio", type=float, default=None)
parser.add_argument("--orientation-mode", choices=("random", "optimized"), parser.add_argument("--orientation-mode", choices=("random", "optimized"),
default="random", help="Use seeded random or experimental optimized angles") default=None, help="Use seeded random or experimental optimized angles")
parser.add_argument("--proposals", type=int, default=100000, parser.add_argument("--proposals", type=int, default=None,
help="Angle changes attempted in optimized mode") help="Angle changes attempted in optimized mode")
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -45,7 +64,7 @@ def _pixel_polygon(uv_polygon, width, height):
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio, def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None): angle_map=None, artifact_prefix="camera_grid_stroke"):
width, height = settings["width"], settings["height"] width, height = settings["width"], settings["height"]
nx, ny = settings["nx"], settings["ny"] nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0 seed = settings.get("seed") or 0
@@ -78,7 +97,9 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
image.pixels[:] = pixels image.pixels[:] = pixels
image.update() image.update()
label = "camera_grid_stroke_optimized" if angle_map is not None else "camera_grid_stroke" label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}" base = output_dir / f"{label}_{uv_name}"
save_png_image(image, base.with_suffix(".png")) save_png_image(image, base.with_suffix(".png"))
page_mesh = _clipped_polygons(mesh_polygons, width, height) page_mesh = _clipped_polygons(mesh_polygons, width, height)
@@ -96,10 +117,25 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
def main(): def main():
args = _args() args = _args()
if not 0 < args.width_ratio <= args.length_ratio < 1: params = dict(DEFAULT_PARAMS)
if args.params is not None:
loaded = json.loads(args.params.read_text(encoding="utf-8"))
if not isinstance(loaded, dict) or set(loaded) - set(DEFAULT_PARAMS):
raise ValueError("invalid stroke params")
params.update(loaded)
for key in DEFAULT_PARAMS:
value = getattr(args, key)
if value is not None:
params[key] = value
if not 0 < params["width_ratio"] <= params["length_ratio"] < 1:
raise ValueError("require 0 < width-ratio <= length-ratio < 1") raise ValueError("require 0 < width-ratio <= length-ratio < 1")
if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1:
raise ValueError("invalid orientation mode or proposal count")
job = json.loads(args.job.read_text(encoding="utf-8")) job = json.loads(args.job.read_text(encoding="utf-8"))
settings = job["settings"] if job.get("schema") != "orisys-sensor-design/texture-job-v3":
raise ValueError("camera-grid strokes require a single-view texture-job-v3")
settings = dict(job["settings"])
settings["seed"] = params["seed"]
obj = _prepare_mesh_object(job["objects"]["mesh"]) obj = _prepare_mesh_object(job["objects"]["mesh"])
cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera") cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera")
names = [entry["uv_map"] for entry in job["outputs"]] names = [entry["uv_map"] for entry in job["outputs"]]
@@ -108,12 +144,12 @@ def main():
args.output_dir.mkdir(parents=True, exist_ok=True) args.output_dir.mkdir(parents=True, exist_ok=True)
args.output_dir = args.output_dir.resolve() args.output_dir = args.output_dir.resolve()
angle_map = None angle_map = None
if args.orientation_mode == "optimized": if params["orientation_mode"] == "optimized":
angle_map, report = optimize_angles( angle_map, report = optimize_angles(
settings["nx"], settings["ny"], settings.get("seed") or 0, settings["nx"], settings["ny"], settings.get("seed") or 0,
args.width_ratio, args.length_ratio, args.proposals, params["width_ratio"], params["length_ratio"], params["proposals"],
) )
report_path = args.output_dir / "camera_grid_stroke_optimization.json" report_path = args.output_dir / f"{args.artifact_prefix}_optimization.json"
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Camera-grid optimization: {report}") print(f"Camera-grid optimization: {report}")
geometry = build_patch_geometry( geometry = build_patch_geometry(
@@ -123,7 +159,8 @@ def main():
) )
for name in names: for name in names:
_render(name, geometry[name], settings, args.output_dir, _render(name, geometry[name], settings, args.output_dir,
args.width_ratio, args.length_ratio, angle_map) params["width_ratio"], params["length_ratio"], angle_map,
args.artifact_prefix)
if __name__ == "__main__": if __name__ == "__main__":
-789
View File
@@ -1,789 +0,0 @@
"""Generate Random Stroke Pattern (RSP) artwork from colour texture jobs.
Run after the colour texture command has produced its PNGs::
blender -b -t 2 --python-exit-code 1 \
-P blender/scripts/random_stroke_pattern.py -- \
--job build/textures/finger/r01/_texture_views_job.json \
--output-dir .scratch/stroke-pattern/run-01
Accepts either a single-view or a multiview colour job. Each distinct source colour is one cell
of the colour artwork; a split cell can have a different centre in the two print views.
One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of ``width_mm`` swept
``length_mm``, rotated per cell, and its width is scaled from that cell's footprint on the print
page so a cell the projection stretches receives a proportionally larger mark. The width is then
clamped to ``width_min_mm``..``width_max_mm``.
When ``target_coverage`` is positive, accepted capsules grow from their endpoints into free
space, up to twice their initial length. Width and edge clearance stay fixed. The reported
coverage is measured only inside the eligible colour-pattern region.
**This is an image-space experiment, not a print master.** Clipping and merged neighbours change
the visible widths, so verify dimensions on the printed part before manufacturing.
Tuning lives in ``design.yaml`` under ``stroke_pattern``; the CLI serialises it here as
``--params``. With no ``--params`` the built-in ``DEFAULT_PARAMS`` apply.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import bpy
import numpy as np
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 save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path
from stroke_geometry import capsule_segment, has_clearance # noqa: E402 - Blender script directory is on sys.path
MODE = "camera_compensated_width"
#: Every tunable, at the values this experiment shipped with. The CLI writes a `design.yaml`
#: `stroke_pattern` block here as `--params`; `--seed`, `--min-cell-pixels` and
#: `--camera-width-fraction` override individual keys for one run.
DEFAULT_PARAMS: dict[str, float] = {
"seed": 7,
"min_cell_pixels": 20,
"jitter_fraction": 0.66,
"angle_span_deg": 180.0,
"width_fraction": 0.5,
"width_min_mm": 0.11,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
"target_coverage": 0.0,
}
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 isinstance(params["candidate_count"], int) or params["candidate_count"] < 1:
raise ValueError("params.candidate_count must be an integer >= 1")
if params["clearance_mm"] < 0:
raise ValueError("params.clearance_mm must be >= 0")
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]")
if not 0.0 <= params["target_coverage"] < 1.0:
raise ValueError("params.target_coverage must be within [0, 1)")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--job", required=True, type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument(
"--params",
type=Path,
default=None,
help="JSON of stroke tuning values; overrides DEFAULT_PARAMS key by key",
)
parser.add_argument(
"--seed", type=int, default=None,
help="Override params.seed for this run",
)
parser.add_argument(
"--min-cell-pixels", type=int, default=None,
help="Override params.min_cell_pixels for this run",
)
parser.add_argument(
"--camera-width-fraction",
type=float,
default=None,
help="Override params.width_fraction for this run",
)
parser.add_argument(
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
)
parser.add_argument(
"--artifact-prefix",
default=None,
help="Name outputs <prefix>_<source-id>.<format>",
)
return parser.parse_args(argv)
def resolve_params(args: argparse.Namespace) -> dict:
"""DEFAULT_PARAMS, then the --params file, then the per-run flag overrides."""
params = dict(DEFAULT_PARAMS)
if args.params is not None:
supplied = json.loads(args.params.read_text(encoding="utf-8"))
if not isinstance(supplied, dict):
raise ValueError("--params must contain a JSON object")
unknown = sorted(set(supplied) - set(DEFAULT_PARAMS))
if unknown:
raise ValueError(
f"--params: unknown key(s) {unknown}; allowed: {sorted(DEFAULT_PARAMS)}"
)
params.update(supplied)
for key, value in (
("seed", args.seed),
("min_cell_pixels", args.min_cell_pixels),
("width_fraction", args.camera_width_fraction),
):
if value is not None:
params[key] = value
validate_params(params)
return params
def cell_code(
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float,
candidate_index: int = 0,
) -> tuple[float, float, float]:
"""Return bounded offsets and angle from one exact source colour.
Derived from the colour itself, so the same palette gives the same layout in both print
views without storing anything.
"""
digest = hashlib.blake2b(
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True)
+ (candidate_index.to_bytes(4, "little") if candidate_index else b""),
digest_size=8,
).digest()
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
return (
jitter_fraction * (values[0] - 0.5),
jitter_fraction * (values[1] - 0.5),
math.radians(angle_span_deg) * values[2],
)
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_compensated_size(
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]:
"""Nominal print width and length in mm from one projected camera-cell footprint.
Scaling from the footprint is the point of this mode: the page is a projection, so a cell
the projection stretches is physically larger and must receive a proportionally larger mark
to stay in proportion. The clamp then keeps a far-field cell from growing past what the
process can hold.
"""
width_mm = max(
width_min_mm, min(width_max_mm, print_span_px / px_per_mm * width_fraction)
)
return width_mm, length_ratio * width_mm
def capsule_polygon(
centre_x: float, centre_y: float, radius: float, half_length: float, angle: float
) -> list[tuple[float, float]]:
"""Approximate the raster capsule boundary for native vector output."""
points = []
cosine, sine = math.cos(angle), math.sin(angle)
for side, start in ((1, -math.pi / 2), (-1, math.pi / 2)):
for index in range(25):
theta = start + index * math.pi / 24
local_x = side * half_length + radius * math.cos(theta)
local_y = radius * math.sin(theta)
points.append(
(
centre_x + local_x * cosine - local_y * sine,
centre_y + local_x * sine + local_y * cosine,
)
)
return points
def draw_mark_candidate(
canvas: np.ndarray,
region,
params: dict,
px_per_mm: float,
patterned: np.ndarray | None = None,
camera_cell: tuple[int, int] | None = None,
vector_shapes: list | None = None,
accepted_capsules: list | None = None,
candidate_index: int = 0,
) -> float:
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
colour, cell_x, cell_y, width, height = region
rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255)
dx, dy, angle = cell_code(
np.asarray(rgb),
params["seed"],
params["jitter_fraction"],
params["angle_span_deg"],
candidate_index,
)
span = min(width, height)
centre_x = cell_x + dx * width
centre_y = cell_y + dy * height
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,
params["width_fraction"],
params["width_min_mm"],
params["width_max_mm"],
params["length_ratio"],
)
radius = width_mm * px_per_mm / 2
half_length = (length_mm - width_mm) * px_per_mm / 2
extent = radius + half_length
if patterned is not None and (
centre_x - extent < 0
or centre_y - extent < 0
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
local_radius = round(params["neighbourhood_mm"] * px_per_mm)
lx0 = max(0, round(centre_x) - local_radius)
lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1)
ly0 = max(0, round(centre_y) - local_radius)
ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1)
local_pattern = patterned[ly0:ly1, lx0:lx1]
local_area = np.count_nonzero(local_pattern)
if local_area == 0:
return 0.0
existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern)
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
if (existing + added) / local_area > params["coverage_cap"]:
return 0.0
capsule = (*capsule_segment(centre_x / px_per_mm, centre_y / px_per_mm,
half_length / px_per_mm, angle), radius / px_per_mm)
if accepted_capsules is not None and not has_clearance(
capsule, accepted_capsules, params["clearance_mm"]
):
return 0.0
canvas[y0:y1, x0:x1] |= footprint
if accepted_capsules is not None:
accepted_capsules.append(capsule)
if vector_shapes is not None:
vector_shapes.append(
(
capsule_polygon(centre_x, centre_y, radius, half_length, angle),
(0.0, 0.0, 0.0, 1.0),
)
)
return 2 * radius
def draw_mark(canvas, region, params, px_per_mm, patterned=None,
camera_cell=None, vector_shapes=None, accepted_capsules=None) -> float:
"""Try deterministic alternatives without relaxing width or clearance."""
for candidate_index in range(params["candidate_count"]):
width = draw_mark_candidate(
canvas, region, params, px_per_mm, patterned, camera_cell,
vector_shapes, accepted_capsules, candidate_index,
)
if width:
return width
return 0.0
def capsule_footprint(capsule, px_per_mm, shape):
"""Raster footprint of a millimetre-space capsule, or None outside the page."""
start, end, radius_mm = capsule
cx = (start[0] + end[0]) * px_per_mm / 2
cy = (start[1] + end[1]) * px_per_mm / 2
dx = (end[0] - start[0]) * px_per_mm / 2
dy = (end[1] - start[1]) * px_per_mm / 2
half_length = math.hypot(dx, dy)
angle = math.atan2(dy, dx)
radius = radius_mm * px_per_mm
extent = half_length + radius
if cx - extent < 0 or cy - extent < 0 or cx + extent >= shape[1] or cy + extent >= shape[0]:
return None
x0, x1 = math.floor(cx - extent), min(shape[1], math.ceil(cx + extent + 1))
y0, y1 = math.floor(cy - extent), min(shape[0], math.ceil(cy + extent + 1))
yy, xx = np.ogrid[y0:y1, x0:x1]
along = (xx - cx) * math.cos(angle) + (yy - cy) * math.sin(angle)
across = -(xx - cx) * math.sin(angle) + (yy - cy) * math.cos(angle)
footprint = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 <= radius**2
return (slice(y0, y1), slice(x0, x1)), footprint
def extend_marks(marks, patterned, capsules, vector_shapes, params, px_per_mm):
"""Grow existing marks into free space until target occupancy or geometry stops them."""
target = math.ceil(params["target_coverage"] * np.count_nonzero(patterned))
occupied = np.count_nonzero(marks & patterned)
if target <= occupied or not capsules:
return occupied
step_mm = 2 / px_per_mm
initial_lengths = [math.dist(start, end) + 2 * radius
for start, end, radius in capsules]
blocked = [[False, False] for _ in capsules]
while occupied < target and any(not all(pair) for pair in blocked):
for index, capsule in enumerate(capsules):
for side in (0, 1):
if blocked[index][side]:
continue
start, end, radius = capsule
length = math.dist(start, end)
if length + step_mm + 2 * radius > 2 * initial_lengths[index]:
blocked[index][side] = True
continue
ux = (end[0] - start[0]) / length
uy = (end[1] - start[1]) / length
candidate = (
(start[0] - ux * step_mm, start[1] - uy * step_mm) if side == 0 else start,
(end[0] + ux * step_mm, end[1] + uy * step_mm) if side == 1 else end,
radius,
)
if not has_clearance(candidate, capsules, params["clearance_mm"], index):
blocked[index][side] = True
continue
raster = capsule_footprint(candidate, px_per_mm, marks.shape)
if raster is None:
blocked[index][side] = True
continue
region, footprint = raster
if np.any(footprint & ~patterned[region]):
blocked[index][side] = True
continue
occupied += np.count_nonzero(footprint & ~marks[region])
marks[region] |= footprint
capsules[index] = candidate
capsule = candidate
if occupied >= target:
break
if occupied >= target:
break
if vector_shapes is not None:
vector_shapes[:] = [
(capsule_polygon((start[0] + end[0]) * px_per_mm / 2,
(start[1] + end[1]) * px_per_mm / 2,
radius * px_per_mm,
math.dist(start, end) * px_per_mm / 2,
math.atan2(end[1] - start[1], end[0] - start[0])),
(0.0, 0.0, 0.0, 1.0))
for start, end, radius in capsules
]
return occupied
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()
save_png_image(image, path)
finally:
bpy.data.images.remove(image)
def job_sources(
job: dict,
) -> tuple[list[tuple[str, Path, Path | None, 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"]),
Path(entry["artifacts"]["svg"])
if entry["artifacts"].get("svg")
else None,
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"]),
Path(entry["artifacts"]["svg"])
if entry["artifacts"].get("svg")
else None,
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_id, source, _svg, _seed in sources:
if not source_id or not all(
char.isalnum() or char in "_-" for char in source_id
):
raise ValueError(f"unsafe source ID: {source_id!r}")
if not source.is_file():
raise FileNotFoundError(f"run the colour generator first: {source}")
return sources, px_per_mm
def source_mesh_polygons(
svg_path: Path | None, width: int, height: int, px_per_mm: float
) -> tuple[list[list[tuple[float, float]]], float, float]:
"""Read the source artwork's owned white ground and physical page size."""
if svg_path is None or not svg_path.is_file():
raise FileNotFoundError(
f"source colour SVG is required for vectors: {svg_path}"
)
root = ET.parse(svg_path).getroot()
width_mm = float(root.attrib["width"].removesuffix("mm"))
height_mm = float(root.attrib["height"].removesuffix("mm"))
view_box = [float(value) for value in root.attrib["viewBox"].split()]
if (
len(view_box) != 4
or view_box[:2] != [0.0, 0.0]
or view_box[2:] != [float(width), float(height)]
or not math.isfinite(width_mm)
or not math.isfinite(height_mm)
or width_mm <= 0
or height_mm <= 0
or not math.isclose(width / width_mm, px_per_mm, rel_tol=0.002)
or not math.isclose(height / height_mm, px_per_mm, rel_tol=0.002)
):
raise ValueError(f"source SVG page does not match colour PNG/job: {svg_path}")
namespace = {"svg": "http://www.w3.org/2000/svg"}
mesh = root.find(".//svg:g[@id='mesh']", namespace)
if mesh is None:
raise ValueError(f"source SVG has no owned mesh group: {svg_path}")
polygons = []
for element in mesh.findall("svg:polygon", namespace):
polygon = [
tuple(float(value) for value in point.split(","))
for point in element.attrib["points"].split()
]
if len(polygon) >= 3:
polygons.append(polygon)
if not polygons:
raise ValueError(f"source SVG has no owned mesh polygons: {svg_path}")
return polygons, width_mm, height_mm
def artifact_stem(source_id: str, prefix: str | None) -> str:
return f"{prefix}_{source_id}" if prefix else f"{source_id}_{MODE}"
def run(
job_path: Path,
output_dir: Path,
params: dict,
formats: tuple[str, ...] = ("png",),
artifact_prefix: str | None = None,
) -> None:
job = json.loads(job_path.read_text(encoding="utf-8"))
sources, px_per_mm = job_sources(job)
if artifact_prefix is not None and (
not artifact_prefix
or not all(char.isalnum() or char in "_-" for char in artifact_prefix)
):
raise ValueError("artifact prefix must be non-empty and filename-safe")
if not formats or len(formats) != len(set(formats)):
raise ValueError("formats must be non-empty and distinct")
targets = [
output_dir / f"{artifact_stem(source_id, artifact_prefix)}.{fmt}"
for source_id, _, _, _ in sources
for fmt in formats
]
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_svg, source_seed in sources:
rgb, width, height = read_rgb(source)
vector_page = (
source_mesh_polygons(source_svg, width, height, px_per_mm)
if "svg" in formats or "pdf" in formats
else None
)
surface = np.any(rgb != 0, axis=2)
patterned = surface & np.any(rgb != 255, axis=2)
regions = list(cell_regions(rgb, int(params["min_cell_pixels"])))
if not regions or not np.any(patterned):
raise ValueError(f"{source_id}: no coloured cell regions in source PNG")
cell_span = float(np.median([min(region[3], region[4]) for region in regions]))
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,
)
# A mark is sized from the cell's footprint, so an ambiguous palette colour has no
# known footprint and is skipped rather than guessed.
grid_shape = (int(settings["nx"]), int(settings["ny"]))
pair_seed = source_seed if source_seed is not None else settings.get("seed")
if pair_seed is None:
raise ValueError("camera-compensated preview requires a seeded colour palette")
lookup = camera_cell_lookup(
*grid_shape, float(settings["color_r"]), int(pair_seed)
)
missing = [
hex(region[0])
for region in regions
if region[0] != 0xFF0000 and region[0] not in lookup
]
if missing:
raise ValueError(
f"{source_id}: camera cell missing for "
f"{len(missing)} colours: {missing[:5]}"
)
ambiguous = [
hex(region[0])
for region in regions
if region[0] != 0xFF0000 and lookup[region[0]] is None
]
if ambiguous:
print(
f"{source_id}: skip {len(ambiguous)} ambiguous palette regions "
f"{ambiguous[:5]}",
flush=True,
)
marks = np.zeros((height, width), dtype=bool)
accepted_capsules = []
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,
accepted_capsules=accepted_capsules,
)
for region in mode_regions
]
initial_coverage = np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
if params["target_coverage"] > initial_coverage:
extend_marks(marks, patterned, accepted_capsules, vector_shapes, params, px_per_mm)
print(f"{source_id}: endpoint extension {initial_coverage:.1%} -> "
f"{np.count_nonzero(marks & patterned) / np.count_nonzero(patterned):.1%} "
f"(target {params['target_coverage']:.1%})", flush=True)
stem = artifact_stem(source_id, artifact_prefix)
if "png" in formats:
write_binary_png(output_dir / f"{stem}.png", marks, surface)
if vector_page is not None:
mesh_polygons, width_mm, height_mm = vector_page
if "svg" in formats:
write_svg_vector(
output_dir / f"{stem}.svg",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
source_id,
artwork_label="stroke",
)
if "pdf" in formats:
write_pdf_vector(
output_dir / f"{stem}.pdf",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
artwork_label="stroke",
)
accepted = [diameter for diameter in diameters if diameter > 0]
median_width = float(np.median(accepted)) if accepted else 0.0
physical_width = (
f", about {median_width / px_per_mm:.3f} mm on the print page"
if px_per_mm > 0
else ""
)
coverage = float(
np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
)
width_range = (
f", width range {min(accepted) / px_per_mm:.3f}–"
f"{max(accepted) / px_per_mm:.3f} mm"
if accepted
else ""
)
print(
f"{source_id}: {MODE}, {len(accepted)}/{len(mode_regions)} marks, "
f"black coverage {coverage:.1%}, median mark width "
f"{median_width:.1f} px{physical_width}{width_range} -> {stem}",
flush=True,
)
def main() -> int:
argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
args = parse_args(argv)
run(
args.job.resolve(),
args.output_dir.resolve(),
resolve_params(args),
tuple(args.formats),
args.artifact_prefix,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-53
View File
@@ -1,53 +0,0 @@
"""Physical-space clearance checks for capsule-shaped laser marks."""
from __future__ import annotations
import math
def point_segment_distance_sq(point, start, end):
dx, dy = end[0] - start[0], end[1] - start[1]
length_sq = dx * dx + dy * dy
if length_sq == 0:
return (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2
t = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_sq))
return (point[0] - start[0] - t * dx) ** 2 + (point[1] - start[1] - t * dy) ** 2
def segments_distance_sq(a0, a1, b0, b1):
"""Minimum squared distance, including a proper crossing at zero."""
def cross(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
ab0, ab1 = cross(a0, a1, b0), cross(a0, a1, b1)
ba0, ba1 = cross(b0, b1, a0), cross(b0, b1, a1)
if ab0 * ab1 < 0 and ba0 * ba1 < 0:
return 0.0
return min(
point_segment_distance_sq(a0, b0, b1),
point_segment_distance_sq(a1, b0, b1),
point_segment_distance_sq(b0, a0, a1),
point_segment_distance_sq(b1, a0, a1),
)
def capsule_segment(cx, cy, half_length, angle):
dx, dy = half_length * math.cos(angle), half_length * math.sin(angle)
return (cx - dx, cy - dy), (cx + dx, cy + dy)
def has_clearance(candidate, accepted, clearance, skip_index=None):
"""Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate
for index, (other_start, other_end, other_radius) in enumerate(accepted):
if index == skip_index:
continue
limit = radius + other_radius + clearance
if (max(start[0], end[0]) + limit < min(other_start[0], other_end[0])
or max(other_start[0], other_end[0]) + limit < min(start[0], end[0])
or max(start[1], end[1]) + limit < min(other_start[1], other_end[1])
or max(other_start[1], other_end[1]) + limit < min(start[1], end[1])):
continue
if segments_distance_sq(start, end, other_start, other_end) < limit * limit:
return False
return True
@@ -1,15 +1,7 @@
{ {
"angle_span_deg": 180.0, "length_ratio": 0.84,
"candidate_count": 12, "orientation_mode": "random",
"clearance_mm": 0.03, "proposals": 100000,
"coverage_cap": 0.45,
"jitter_fraction": 0.66,
"length_ratio": 2.4,
"min_cell_pixels": 20,
"neighbourhood_mm": 0.5,
"seed": 7, "seed": 7,
"target_coverage": 0.5, "width_ratio": 0.42
"width_fraction": 0.5,
"width_max_mm": 0.28,
"width_min_mm": 0.2
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.7 MiB

@@ -1,94 +1,64 @@
{ {
"schema": "orisys-sensor-design/stroke-texture-manifest-v1", "schema": "orisys-sensor-design/stroke-texture-manifest-v2",
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01", "revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
"source_job": { "source_job": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\_texture_views_job.json", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\_texture_job.json",
"sha256": "7259df462de85a86ec575a6296592bf78c008d44ae531d7efd233118686b194f" "sha256": "1d9d9bf605cc95b1f8375438e944e1ff4f2f8553d1793784de50a6f6ef64de77"
}, },
"source_blend": { "source_blend": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend", "path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
"sha256": "2a48957a88c760775582e4dc4780d98bf9fc94348918cff8efd82e840217530c" "sha256": "2a48957a88c760775582e4dc4780d98bf9fc94348918cff8efd82e840217530c"
}, },
"settings": { "settings": {
"mode": "camera_compensated_width", "mode": "camera_grid_capsules",
"seed": 7,
"camera_width_fraction": 0.5,
"resolved": { "resolved": {
"seed": 7, "seed": 7,
"min_cell_pixels": 20, "width_ratio": 0.42,
"jitter_fraction": 0.66, "length_ratio": 0.84,
"angle_span_deg": 180.0, "orientation_mode": "random",
"width_fraction": 0.5, "proposals": 100000
"width_min_mm": 0.2,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.03,
"candidate_count": 12,
"target_coverage": 0.5
}, },
"design_yaml": { "design_yaml": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml", "path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
"sha256": "f6d2536f66256ff4c0a6b210d9dbab7b1c0c9d263149171f4da3298a22c4166c" "sha256": "711ad69649ef0d771e8f68295bea129af0f13c0e003d9aaf361e25ea5ab8c34e"
}, },
"params": { "params": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
"sha256": "35498f3b08aa93f7e89a7f27080ec0655997b36f69fd8da30002e5d34df6083f" "sha256": "823639aa72706b1d47d0c531e704cb99f36517e87fcad7de707417cb474e6993"
} }
}, },
"outputs": [ "outputs": [
{ {
"id": "uv_1", "id": "produce",
"sources": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"sha256": "719e675395b158e30fc6310b7ba17c573d6347c0b1234c788876396a7cce3c29"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
"sha256": "98319674337cc7e9a4cbc547d0da1fc79b282067d0fd1471c5371c59d4e50532"
}
},
"artifacts": { "artifacts": {
"png": { "png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.png",
"sha256": "a00cee4113337fae6ccddd9213b832bd5a1756c78c7e23bf0caabfeccda680e6" "sha256": "a748d1963825310458ab3690a75986f75bffcfe2aab1c936b93714a38408437b"
}, },
"svg": { "svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.svg",
"sha256": "d880e71c55daa842757f5a558227c715b45c41d9e980ec4d056749eb21cd3d9f" "sha256": "b53b5c99eeac07399fac2167b63e938df4ee36bd9029d70faaca9ef945a09880"
}, },
"pdf": { "pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.pdf",
"sha256": "6cc5910f86b687a0334829b23decf3fd5d9166378ab05e3a93cbaca24278c92c" "sha256": "b8978a4f893f475124434e42fd56739ef0807bc9769f48993883303f9395c272"
} }
} }
}, },
{ {
"id": "uv_2", "id": "compute",
"sources": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"sha256": "8b1a7dc6e24820c90e604483fe515e93e9a8daddcb51e604e5d14ea47eed524a"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
"sha256": "57f1cc3dab59fd19ad99a2bd19fe1233414b2bcdbcf8e25c0698da09a61384b3"
}
},
"artifacts": { "artifacts": {
"png": { "png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.png",
"sha256": "ff75bba350475e4ae01cc841700e16b601e5a6fd06708597536ceb7bf8700ae6" "sha256": "4f761f166328f2c9dab9469f33f7509c9dd6238df3b8a4bdac48e2aa52dc362b"
}, },
"svg": { "svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.svg",
"sha256": "511b324431b0d95dc6bbf66709f3221e33dfc3a34e6416f3d24c490e3c79d1cc" "sha256": "192c13019257579a7e3bca3ddcbef16ef59cfc1a5d5c19ff3a8fa26c3b8ca041"
}, },
"pdf": { "pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.pdf",
"sha256": "b1123696d80a03b2f257f6ad397d96a1b2d9416039832df33e9d7b40a455528e" "sha256": "2fa788f4e3da05bbef4a267fafd96beb730727183b7bf4ed95f2b6c569bfba09"
} }
} }
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.7 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 920 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 934 KiB

+8 -20
View File
@@ -88,27 +88,15 @@ 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`). # Camera-grid capsule strokes (`orisys-design stroke-texture`). Grid, camera projection,
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera # UV maps, image size and vector page come from the texture_patch job above.
# cell's print-page footprint so a cell the projection stretches gets a proportionally # Ratios are fractions of each projected cell. Physical minimum width is not enforced.
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
# Field reference: the inline comments below. This is an image-space experiment, not a
# print master. 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: stroke_pattern:
seed: 7 # per-cell offset and angle; reproducible seed: 7
min_cell_pixels: 20 # cells smaller than this are skipped width_ratio: 0.42
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre length_ratio: 0.84
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel orientation_mode: random
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm) proposals: 100000 # used only in optimized mode
width_min_mm: 0.20
width_max_mm: 0.28
length_ratio: 2.4 # length = ratio x width
coverage_cap: 0.45 # initial placement limit; endpoint growth uses target_coverage
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page
candidate_count: 12 # try alternatives before skipping a cell
target_coverage: 0.50 # extend accepted strokes into free space, without overlap
# 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.
+7 -18
View File
@@ -94,25 +94,14 @@ 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`). # Camera-grid capsule strokes (`orisys-design stroke-texture`). Geometry and output size
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera # follow texture_patch. This mode currently accepts single-view jobs only.
# 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: stroke_pattern:
seed: 7 # per-cell offset and angle; reproducible seed: 7
min_cell_pixels: 20 # cells smaller than this are skipped width_ratio: 0.42
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre length_ratio: 0.84
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel orientation_mode: random
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm) proposals: 100000
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.
+38 -61
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import argparse import argparse
import json import json
import math
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -28,12 +27,6 @@ from orisys_sensor_design.manifest import (
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,
@@ -42,6 +35,12 @@ from orisys_sensor_design.printability import (
write_csv, write_csv,
write_report, write_report,
) )
from orisys_sensor_design.stroke_pattern import (
STROKE_MODE,
StrokePatternConfigError,
resolve_stroke_settings,
write_stroke_params,
)
from orisys_sensor_design.texture import ( from orisys_sensor_design.texture import (
TextureConfigError, TextureConfigError,
build_texture_job, build_texture_job,
@@ -479,25 +478,25 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace): def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace):
"""Validate the existing colour job shared by stroke preview and export.""" """Validate the single-view patch geometry job used by the stroke generator."""
if args.views:
raise ValueError("camera-grid strokes require a single-view job; --views is unsupported")
texture_dir = resolve_texture_output_dir(root, paths, None) 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 / "_texture_job.json").resolve()
job_path = (args.source_job or texture_dir / job_name).resolve()
if not job_path.is_file(): if not job_path.is_file():
raise FileNotFoundError(f"colour texture job missing: {job_path}") raise FileNotFoundError(f"texture job missing: {job_path}")
job = json.loads(job_path.read_text(encoding="utf-8")) job = json.loads(job_path.read_text(encoding="utf-8"))
expected_schema = ( expected_schema = "orisys-sensor-design/texture-job-v3"
"orisys-sensor-design/texture-job-multiview-v1"
if args.views else "orisys-sensor-design/texture-job-v3"
)
if job.get("schema") != expected_schema: if job.get("schema") != expected_schema:
raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}") raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}")
if Path(job["revision_dir"]).resolve() != paths.root.resolve(): if Path(job["revision_dir"]).resolve() != paths.root.resolve():
raise ValueError("source job belongs to a different revision") raise ValueError("source job belongs to a different revision")
if Path(job["source_blend"]).resolve() != paths.source_blend.resolve(): if Path(job["source_blend"]).resolve() != paths.source_blend.resolve():
raise ValueError("source job was built from a different Blender scene") raise ValueError("source job was built from a different Blender scene")
entries = job["pairs" if args.views else "outputs"] if not isinstance(job["settings"], dict) or not isinstance(job["objects"], dict):
source_ids = [str(entry["id" if args.views else "uv_map"]) for entry in entries] raise ValueError("texture job needs settings and objects")
entries = job["outputs"]
source_ids = [str(entry["uv_map"]) for entry in entries]
if (not source_ids or len(source_ids) != len(set(source_ids)) if (not source_ids or len(source_ids) != len(set(source_ids))
or any(not source_id or not all( or any(not source_id or not all(
char.isalnum() or char in "_-" for char in source_id char.isalnum() or char in "_-" for char in source_id
@@ -507,7 +506,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.""" """Resolve stroke tuning, then validate the shared patch geometry 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)
@@ -523,44 +522,31 @@ def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
for message in str(exc).splitlines(): for message in str(exc).splitlines():
print(f"ERROR: {message}", file=sys.stderr) 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 overrides = {key: getattr(args, key, None) for key in
# tuning value lives with the part instead of in whoever's shell history ran it last. ("seed", "width_ratio", "length_ratio", "orientation_mode", "proposals")}
if args.camera_width_fraction is not None: overrides = {key: value for key, value in overrides.items() if value is not None}
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({**settings, **overrides})
except StrokePatternConfigError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return None, 2 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:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return None, 3 return None, 3
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 texture job: {exc}", file=sys.stderr)
return None, 2 return None, 2
return (paths, *source, settings), 0 return (paths, *source, settings), 0
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"""Export one PNG/SVG/PDF stroke set per colour source and record provenance.""" """Export camera-grid stroke artwork and record its geometry and tuning."""
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, settings = context paths, job_path, _job, entries, source_ids, settings = context
for entry in entries:
for fmt in ("png", "svg"):
try:
source = Path(entry["artifacts"][fmt])
except (KeyError, TypeError) as exc:
print(f"ERROR: colour job has no {fmt.upper()} artifact: {exc}",
file=sys.stderr)
return 2
if not source.is_file() or source.stat().st_size == 0:
print(f"ERROR: source colour {fmt.upper()} missing: {source}", file=sys.stderr)
return 3
output_dir = (args.output_dir or job_path.parent / "strokes").resolve() output_dir = (args.output_dir or job_path.parent / "strokes").resolve()
outputs = { outputs = {
source_id: { source_id: {
@@ -571,16 +557,15 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
} }
manifest_path = output_dir / "stroke_texture_manifest.json" manifest_path = output_dir / "stroke_texture_manifest.json"
temporary = manifest_path.with_suffix(".json.tmp") temporary = manifest_path.with_suffix(".json.tmp")
if manifest_path.exists() or temporary.exists() or any(path.exists() for artifacts in outputs.values() if manifest_path.exists() or temporary.exists() or (output_dir / "_stroke_pattern_params.json").exists() or any(path.exists() for artifacts in outputs.values()
for path in artifacts.values()): for path in artifacts.values()):
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) params_path = write_stroke_params(settings, output_dir)
code = _run_blender_script( code = _run_blender_script(
"random_stroke_pattern.py", paths.source_blend, "camera_grid_stroke.py", paths.source_blend,
["--job", str(job_path), "--output-dir", str(output_dir), ["--job", str(job_path), "--output-dir", str(output_dir),
"--formats", "png", "svg", "pdf",
"--artifact-prefix", "stroke_texture", "--params", str(params_path)], "--artifact-prefix", "stroke_texture", "--params", str(params_path)],
) )
if code != 0: if code != 0:
@@ -592,18 +577,13 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr) print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
return 3 return 3
manifest = { manifest = {
"schema": "orisys-sensor-design/stroke-texture-manifest-v1", "schema": "orisys-sensor-design/stroke-texture-manifest-v2",
"revision_dir": str(paths.root), "revision_dir": str(paths.root),
"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)},
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
# overrides actually produced, because the flags alone no longer
# describe the run. The design.yaml and params hashes pin the tuning either way.
"settings": { "settings": {
"mode": STROKE_MODE, "mode": STROKE_MODE,
"seed": settings["seed"],
"camera_width_fraction": settings["width_fraction"],
"resolved": dict(settings), "resolved": dict(settings),
"design_yaml": {"path": str(paths.design_yaml), "design_yaml": {"path": str(paths.design_yaml),
"sha256": sha256_file(paths.design_yaml)}, "sha256": sha256_file(paths.design_yaml)},
@@ -611,13 +591,10 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
}, },
"outputs": [ "outputs": [
{"id": source_id, {"id": source_id,
"sources": {fmt: {"path": str(Path(entry["artifacts"][fmt])),
"sha256": sha256_file(Path(entry["artifacts"][fmt]))}
for fmt in ("png", "svg")},
"artifacts": {fmt: {"path": str(outputs[source_id][fmt]), "artifacts": {fmt: {"path": str(outputs[source_id][fmt]),
"sha256": sha256_file(outputs[source_id][fmt])} "sha256": sha256_file(outputs[source_id][fmt])}
for fmt in ("png", "svg", "pdf")}} for fmt in ("png", "svg", "pdf")}}
for source_id, entry in zip(source_ids, entries) for source_id in source_ids
], ],
} }
temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
@@ -2274,29 +2251,29 @@ def main(argv: list[str] | None = None) -> None:
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 camera-grid capsule strokes from a texture geometry job",
) )
stroke_p.add_argument("revision_dir", type=Path) stroke_p.add_argument("revision_dir", type=Path)
stroke_p.add_argument( stroke_p.add_argument(
"--views", action="store_true", "--views", action="store_true",
help="Use the existing two-view colour job and preserve its print ownership", help="Unsupported for camera-grid strokes; single-view jobs only",
) )
stroke_p.add_argument( stroke_p.add_argument(
"--source-job", type=Path, default=None, "--source-job", type=Path, default=None,
help="Existing colour job (default: revision build/textures job)", help="Existing texture geometry job (default: revision build/textures job)",
) )
stroke_p.add_argument( stroke_p.add_argument(
"--output-dir", type=Path, default=None, "--output-dir", type=Path, default=None,
help="Fresh output directory (default: strokes beside the colour job)", help="Fresh output directory (default: strokes beside the texture job)",
) )
stroke_p.add_argument( stroke_p.add_argument(
"--seed", type=int, default=None, "--seed", type=int, default=None,
help="Override stroke_pattern.seed for this run", help="Override stroke_pattern.seed for this run",
) )
stroke_p.add_argument( stroke_p.add_argument("--width-ratio", type=float, default=None)
"--camera-width-fraction", type=float, default=None, stroke_p.add_argument("--length-ratio", type=float, default=None)
help="Override stroke_pattern.width_fraction for this run", stroke_p.add_argument("--orientation-mode", choices=("random", "optimized"), default=None)
) stroke_p.add_argument("--proposals", type=int, default=None)
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")
manifest_p.add_argument("revision_dir", type=Path) manifest_p.add_argument("revision_dir", type=Path)
+33 -123
View File
@@ -1,162 +1,72 @@
"""Tuning for the Random Stroke Pattern: the `stroke_pattern` block in `design.yaml`. """Configuration for camera-grid capsule strokes generated from a texture job."""
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. This module owns the live knobs, their 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 from __future__ import annotations
import json import json
import math
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import Any, Mapping from typing import Any
#: The only mode the generator implements. Recorded in the job so a run is self-describing. STROKE_MODE = "camera_grid_capsules"
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] = { STROKE_DEFAULTS: dict[str, Any] = {
"seed": 7, "seed": 7,
"min_cell_pixels": 20, "width_ratio": 0.42,
"jitter_fraction": 0.66, "length_ratio": 0.84,
"angle_span_deg": 180.0, "orientation_mode": "random",
"width_fraction": 0.5, "proposals": 100000,
"width_min_mm": 0.11,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
"target_coverage": 0.0,
} }
STROKE_KEYS = frozenset(STROKE_DEFAULTS) STROKE_KEYS = frozenset(STROKE_DEFAULTS)
STROKE_INT_KEYS = frozenset({"seed", "proposals"})
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels", "candidate_count"})
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json" STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
class StrokePatternConfigError(ValueError): class StrokePatternConfigError(ValueError):
"""The `stroke_pattern` block cannot produce artwork.""" """The stroke settings cannot produce artwork."""
def validate_stroke_block(block: Any) -> list[str]: def validate_stroke_block(block: Any) -> list[str]:
"""Return every reason this block is unusable; empty when it is fine. """Validate the subset of camera-grid stroke settings provided by a revision."""
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: if block is None:
return [] return []
where = "stroke_pattern"
if not isinstance(block, Mapping): if not isinstance(block, Mapping):
return [f"{where}: must be a mapping"] return ["stroke_pattern: must be a mapping"]
errors: list[str] = [] errors: list[str] = []
extra = sorted(set(block) - STROKE_KEYS) extra = sorted(set(block) - STROKE_KEYS)
if extra: if extra:
errors.append( errors.append(f"stroke_pattern: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}")
f"{where}: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}" for key in STROKE_INT_KEYS & block.keys():
) value = block[key]
for key, value in block.items(): if isinstance(value, bool) or not isinstance(value, int):
if key not in STROKE_KEYS: errors.append(f"stroke_pattern.{key}: must be an integer")
continue for key in ("width_ratio", "length_ratio"):
if isinstance(value, bool) or not isinstance(value, (int, float)): if key in block:
errors.append(f"{where}.{key}: must be a number (got {value!r})") value = block[key]
continue if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
if key in STROKE_INT_KEYS and not isinstance(value, int): errors.append(f"stroke_pattern.{key}: must be a finite number")
errors.append(f"{where}.{key}: must be an integer (got {value!r})") if "orientation_mode" in block and block["orientation_mode"] not in ("random", "optimized"):
continue errors.append("stroke_pattern.orientation_mode: must be random or optimized")
if value != value or value in (float("inf"), float("-inf")):
errors.append(f"{where}.{key}: must be finite (got {value!r})")
if errors: if errors:
return errors return errors
merged = {**STROKE_DEFAULTS, **block}
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1: if merged["proposals"] < 1:
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})") errors.append("stroke_pattern.proposals: must be >= 1")
if "candidate_count" in block and block["candidate_count"] < 1: if not 0 < merged["width_ratio"] <= merged["length_ratio"] < 1:
errors.append(f"{where}.candidate_count: must be >= 1 (got {block['candidate_count']!r})") errors.append("stroke_pattern: require 0 < width_ratio <= length_ratio < 1")
if "clearance_mm" in block and block["clearance_mm"] < 0:
errors.append(f"{where}.clearance_mm: must be >= 0 (got {block['clearance_mm']!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})"
)
if "target_coverage" in block and not 0.0 <= block["target_coverage"] < 1.0:
errors.append(
f"{where}.target_coverage: must be within [0, 1) (got {block['target_coverage']!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 return errors
def resolve_stroke_settings(block: Any) -> dict[str, Any]: def resolve_stroke_settings(block: Any) -> dict[str, Any]:
"""The block merged over the defaults, or raise naming every problem.""" """Merge a revision's settings over the generator defaults."""
errors = validate_stroke_block(block) errors = validate_stroke_block(block)
if errors: if errors:
raise StrokePatternConfigError("\n".join(errors)) raise StrokePatternConfigError("\n".join(errors))
settings = dict(STROKE_DEFAULTS) return {**STROKE_DEFAULTS, **(block or {})}
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: 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.""" """Write resolved settings next to generated artwork."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / STROKE_PARAMS_FILENAME path = output_dir / STROKE_PARAMS_FILENAME
path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8") path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path 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",
]
-35
View File
@@ -1,35 +0,0 @@
"""Check laser-mark clearance in physical page coordinates."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "blender" / "scripts"))
from stroke_geometry import has_clearance # noqa: E402
def test_crossing_capsules_are_rejected() -> None:
existing = [((-0.2, 0), (0.2, 0), 0.1)]
assert not has_clearance(((0, -0.2), (0, 0.2), 0.1), existing, 0.03)
def test_near_parallel_capsules_need_edge_spacing() -> None:
existing = [((-0.2, 0), (0.2, 0), 0.1)]
assert not has_clearance(((-0.2, 0.22), (0.2, 0.22), 0.1), existing, 0.03)
assert has_clearance(((-0.2, 0.24), (0.2, 0.24), 0.1), existing, 0.03)
def test_disjoint_collinear_capsules_can_clear() -> None:
existing = [((0, 0), (0.2, 0), 0.1)]
assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03)
def test_extension_ignores_only_its_original_capsule() -> None:
original = ((0, 0), (0.2, 0), 0.1)
neighbour = ((0.6, 0), (0.8, 0), 0.1)
assert has_clearance(((-0.1, 0), (0.3, 0), 0.1), [original, neighbour], 0.03,
skip_index=0)
assert not has_clearance(((-0.1, 0), (0.4, 0), 0.1), [original, neighbour], 0.03,
skip_index=0)
+32 -169
View File
@@ -1,10 +1,5 @@
"""Run with python -m pytest tests/test_stroke_pattern.py (no Blender needed). """Host and Blender stroke configuration must stay in sync."""
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 from __future__ import annotations
import ast import ast
@@ -15,177 +10,45 @@ import pytest
import yaml import yaml
from orisys_sensor_design import stroke_pattern from orisys_sensor_design import stroke_pattern
from orisys_sensor_design.manifest import RevisionPaths, repo_root from orisys_sensor_design.manifest import repo_root
SCRIPT = repo_root() / "blender" / "scripts" / "random_stroke_pattern.py" SCRIPT = repo_root() / "blender" / "scripts" / "camera_grid_stroke.py"
def script_constant(name: str): def _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")) tree = ast.parse(SCRIPT.read_text(encoding="utf-8"))
for node in tree.body: for node in tree.body:
if isinstance(node, ast.Assign): if isinstance(node, ast.Assign) and any(
targets = [t for t in node.targets if isinstance(t, ast.Name)] isinstance(target, ast.Name) and target.id == name for target in node.targets
if any(t.id == name for t in targets): ):
return ast.literal_eval(node.value) return ast.literal_eval(node.value)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): raise AssertionError(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_generator_contract():
assert _constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
assert _constant("MODE") == stroke_pattern.STROKE_MODE
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"),
("clearance_mm", -0.01, "must be >= 0"),
("candidate_count", 0, "must be >= 1"),
("target_coverage", -0.01, "within [0, 1)"),
("target_coverage", 1.0, "within [0, 1)"),
],
)
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"]) @pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None: def test_revision_settings(sensor):
"""The YAML records both legacy and Xynova manufacturing settings.""" path = repo_root() / "sensors" / sensor / "r01" / "design.yaml"
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01") block = yaml.safe_load(path.read_text(encoding="utf-8"))["stroke_pattern"]
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8")) assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
block = design.get("stroke_pattern")
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
resolved = stroke_pattern.resolve_stroke_settings(block) @pytest.mark.parametrize("block", [
expected = dict(stroke_pattern.STROKE_DEFAULTS) {"width_min_mm": 0.2}, {"width_ratio": 0}, {"width_ratio": 0.9},
if sensor == "finger-xynova": {"length_ratio": 1}, {"seed": 1.5}, {"proposals": 0},
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12, {"orientation_mode": "unknown"},
target_coverage=0.50) ])
assert resolved == expected def test_invalid_settings(block):
assert stroke_pattern.validate_stroke_block(block)
with pytest.raises(stroke_pattern.StrokePatternConfigError):
stroke_pattern.resolve_stroke_settings(block)
def test_params_round_trip(tmp_path: Path):
settings = stroke_pattern.resolve_stroke_settings({"seed": 13})
path = stroke_pattern.write_stroke_params(settings, tmp_path)
assert json.loads(path.read_text(encoding="utf-8")) == settings
+25 -67
View File
@@ -1,4 +1,4 @@
"""The stroke CLI must preserve colour sources and verify all exported formats.""" """The stroke CLI uses the texture geometry job and verifies its outputs."""
from __future__ import annotations from __future__ import annotations
@@ -12,96 +12,54 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01" REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
def _args(job: Path, output_dir: Path) -> argparse.Namespace: def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
return argparse.Namespace( return argparse.Namespace(
views=False, views=views, source_job=job, output_dir=output_dir,
source_job=job, width_ratio=0.4, length_ratio=0.8, orientation_mode=None,
output_dir=output_dir, proposals=None, seed=13,
camera_width_fraction=0.5,
seed=13,
) )
def _job(tmp_path: Path) -> tuple[Path, Path, Path]: def _job(tmp_path: Path) -> Path:
paths = RevisionPaths.from_revision_dir(REVISION) paths = RevisionPaths.from_revision_dir(REVISION)
png = tmp_path / "colour.png" job = tmp_path / "_texture_job.json"
svg = tmp_path / "colour.svg" job.write_text(json.dumps({
png.write_bytes(b"colour pixels")
svg.write_bytes(b"<svg>colour geometry</svg>")
job_path = tmp_path / "_texture_job.json"
job_path.write_text(
json.dumps(
{
"schema": "orisys-sensor-design/texture-job-v3", "schema": "orisys-sensor-design/texture-job-v3",
"revision_dir": str(paths.root), "revision_dir": str(paths.root), "source_blend": str(paths.source_blend),
"source_blend": str(paths.source_blend), "settings": {"nx": 80, "ny": 80},
"outputs": [ "objects": {"mesh": "sensing_surface", "camera": "cam_sensor"},
{ "outputs": [{"uv_map": "compute"}],
"uv_map": "compute", }), encoding="utf-8")
"artifacts": {"png": str(png), "svg": str(svg)}, return job
}
],
}
),
encoding="utf-8",
)
return job_path, png, svg
def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch): def test_exports_from_geometry_job(tmp_path, monkeypatch):
job, png, svg = _job(tmp_path) job = _job(tmp_path)
output_dir = tmp_path / "strokes" output_dir = tmp_path / "strokes"
calls = [] calls = []
def run_blender(script, blend, args): def run_blender(script, blend, args):
calls.append((script, blend, args)) calls.append((script, blend, args))
# 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
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] == "camera_grid_stroke.py"
# `--seed` and `--camera-width-fraction` are overrides folded into the params file, so the params_path = Path(calls[0][2][calls[0][2].index("--params") + 1])
# contract is now the effective tuning the script reads. params = json.loads(params_path.read_text(encoding="utf-8"))
params = json.loads(
Path(calls[0][2][calls[0][2].index("--params") + 1]).read_text(encoding="utf-8")
)
assert params["seed"] == 13 assert params["seed"] == 13
assert params["width_fraction"] == 0.5 assert params["width_ratio"] == 0.4
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-v2"
assert manifest["source_job"]["sha256"] == sha256_file(job) assert manifest["source_job"]["sha256"] == sha256_file(job)
assert manifest["outputs"][0]["sources"]["png"]["sha256"] == sha256_file(png)
assert manifest["outputs"][0]["sources"]["svg"]["sha256"] == sha256_file(svg)
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"} assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
assert png.read_bytes() == b"colour pixels"
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2 assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
assert len(calls) == 1 assert len(calls) == 1
def test_stroke_cli_refuses_missing_source_svg(tmp_path, monkeypatch): def test_rejects_multiview(tmp_path, monkeypatch):
job, _png, svg = _job(tmp_path) job = _job(tmp_path)
svg.unlink() monkeypatch.setattr(cli, "_run_blender_script", lambda *_: 99)
monkeypatch.setattr( assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "out", views=True)) == 2
cli,
"_run_blender_script",
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
)
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 3
def test_stroke_cli_rejects_incomplete_source_job(tmp_path, monkeypatch):
job, _png, _svg = _job(tmp_path)
data = json.loads(job.read_text(encoding="utf-8"))
del data["outputs"][0]["artifacts"]["svg"]
job.write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setattr(
cli,
"_run_blender_script",
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
)
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 2