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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user