Add stroke texture PNG, SVG, and PDF export

Reuse colour artwork serializers and record source hashes. Verified single and multiview exports plus targeted tests.
This commit is contained in:
ykcheungab
2026-10-03 21:17:28 +08:00
parent 6b0694d6cb
commit 5bd7dd7a01
5 changed files with 452 additions and 66 deletions
+6 -5
View File
@@ -227,11 +227,12 @@ 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
``` ```
`binary-preview` makes Random Stroke Pattern (RSP) PNG trials from a color job `stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash
in a separate directory; `--views` selects the multiview job. Stroke width manifest from existing color artwork, for example:
follows camera-cell print size, bounded to 0.11–0.28 mm. These image-space `orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a
trials are for probe comparisons, not print masters; UV views may select `strokes` subdirectory; `--output-dir` selects another. `binary-preview`
different cells. See `binary-preview --help` for paths and sizing controls. remains PNG-only. Nominal stroke width is 0.11–0.28 mm; verify vectors and
printed dimensions before manufacturing.
## Inspect printability (advisory) ## Inspect printability (advisory)
+22 -15
View File
@@ -93,8 +93,6 @@ def fill_polygon(image, pixels, width, height, polygon, color):
poly = [(int(round(x)), int(round(y))) for x, y in polygon] poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3: if len(poly) < 3:
return return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width-1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly)) min_y = max(0, min(p[1] for p in poly))
max_y = min(height-1, max(p[1] for p in poly)) max_y = min(height-1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1): for y in range(min_y, max_y + 1):
@@ -125,8 +123,6 @@ def fill_polygon_value(grid, width, height, polygon, value):
poly = [(int(round(x)), int(round(y))) for x, y in polygon] poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3: if len(poly) < 3:
return return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width - 1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly)) min_y = max(0, min(p[1] for p in poly))
max_y = min(height - 1, max(p[1] for p in poly)) max_y = min(height - 1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1): for y in range(min_y, max_y + 1):
@@ -679,10 +675,19 @@ def _clipped_shapes(shapes, width, height):
return clipped return clipped
def save_png_image(image, output_path):
"""Serialize an already-rasterized Blender image as PNG."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(path)
image.file_format = 'PNG'
image.save()
def write_svg_vector( def write_svg_vector(
output_path, texture_width, texture_height, width_mm, height_mm, output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map, mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map,
background=None, background=None, artwork_label="patch",
): ):
"""Write editable grouped SVG geometry; no raster data is embedded. """Write editable grouped SVG geometry; no raster data is embedded.
@@ -696,6 +701,9 @@ def write_svg_vector(
background_fill = "#" + "".join( background_fill = "#" + "".join(
f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3] f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3]
) )
if artwork_label not in ("patch", "stroke"):
raise ValueError(f"unsupported artwork label: {artwork_label!r}")
plural = "patches" if artwork_label == "patch" else "strokes"
lines = [ lines = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
( (
@@ -705,10 +713,10 @@ def write_svg_vector(
f'viewBox="0 0 {texture_width} {texture_height}" ' f'viewBox="0 0 {texture_width} {texture_height}" '
f'preserveAspectRatio="none">' f'preserveAspectRatio="none">'
), ),
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>", f" <title>Orisys {artwork_label} texture — {_xml_escape(uv_map)} UV map</title>",
( (
" <desc>Native vector geometry grouped as background, mesh, " " <desc>Native vector geometry grouped as background, mesh, "
"camera-space patches, and reference cross.</desc>" f"camera-space {plural}, and reference cross.</desc>"
), ),
( (
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">' f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
@@ -728,10 +736,10 @@ def write_svg_vector(
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0) f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
) )
) )
lines.extend([" </g>", ' <g id="patches">']) lines.extend([" </g>", f' <g id="{plural}">'])
for index, (polygon, color) in enumerate(patch_shapes, start=1): for index, (polygon, color) in enumerate(patch_shapes, start=1):
lines.append( lines.append(
_svg_polygon_element(f"patch-{index:04d}", polygon, color) _svg_polygon_element(f"{artwork_label}-{index:04d}", polygon, color)
) )
lines.extend([" </g>", ' <g id="reference-cross">']) lines.extend([" </g>", ' <g id="reference-cross">'])
cross_attributes = _svg_fill_attributes(cross_color) cross_attributes = _svg_fill_attributes(cross_color)
@@ -798,7 +806,7 @@ def _build_pdf(objects):
def write_pdf_vector( def write_pdf_vector(
output_path, texture_width, texture_height, width_mm, height_mm, output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color, mesh_polygons, patch_shapes, cross_rects, cross_color,
background=None, background=None, artwork_label="patch",
): ):
"""Write native vector PDF paths at the configured physical page size. """Write native vector PDF paths at the configured physical page size.
@@ -824,7 +832,9 @@ def write_pdf_vector(
for polygon in mesh_polygons: for polygon in mesh_polygons:
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y)) content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
content.append("% camera-space patches") if artwork_label not in ("patch", "stroke"):
raise ValueError(f"unsupported artwork label: {artwork_label!r}")
content.append(f"% camera-space {'patches' if artwork_label == 'patch' else 'strokes'}")
for polygon, color in patch_shapes: for polygon, color in patch_shapes:
content.append(_pdf_set_fill(color)) content.append(_pdf_set_fill(color))
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y)) content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
@@ -961,10 +971,7 @@ def generate_patch_texture(
image.pixels[:] = pixels image.pixels[:] = pixels
image.update() image.update()
out_path = Path(output_path) out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True) save_png_image(image, out_path)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"PNG raster texture saved to {out_path}") print(f"PNG raster texture saved to {out_path}")
uv_map_name = uv_layer.name if uv_layer is not None else "active" uv_map_name = uv_layer.name if uv_layer is not None else "active"
+171 -15
View File
@@ -1,4 +1,4 @@
"""Generate Random Stroke Pattern (RSP) trials from colour texture jobs. """Generate Random Stroke Pattern (RSP) artwork from colour texture jobs.
Run after the colour texture command has produced its PNGs:: Run after the colour texture command has produced its PNGs::
@@ -23,7 +23,8 @@ gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These
nominal print-page sizes; clipped/merged ink still needs physical validation. nominal print-page sizes; clipped/merged ink still needs physical validation.
The camera-compensated trial scales width from each camera cell's print-page 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 footprint, bounded to 0.11–0.28 mm; larger far-field cells therefore receive
larger marks. It remains a preview because image-space regions can be distorted. 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
@@ -33,11 +34,17 @@ import hashlib
import json import json
import math import math
import sys import sys
import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
import bpy import bpy
import numpy as np 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
PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2} PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2}
MODES = ( MODES = (
"dots", "dots",
@@ -72,6 +79,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
default=0.5, default=0.5,
help="Compensated stroke width as a fraction of the local print-cell span", help="Compensated stroke width as a fraction of the local print-cell span",
) )
parser.add_argument(
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
)
parser.add_argument(
"--artifact-prefix",
default=None,
help="Name single-mode outputs <prefix>_<source-id>.<format>",
)
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -189,6 +204,26 @@ def camera_compensated_size(
return width_mm, 2.4 * width_mm return width_mm, 2.4 * 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( def draw_mark(
canvas: np.ndarray, canvas: np.ndarray,
region, region,
@@ -199,6 +234,7 @@ def draw_mark(
camera_cell: tuple[int, int] | None = None, camera_cell: tuple[int, int] | None = None,
grid_shape: tuple[int, int] | None = None, grid_shape: tuple[int, int] | None = None,
camera_width_fraction: float = 0.5, camera_width_fraction: float = 0.5,
vector_shapes: list | None = None,
) -> float: ) -> float:
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)
@@ -293,6 +329,13 @@ def draw_mark(
if (existing + added) / local_area > 0.45: if (existing + added) / local_area > 0.45:
return 0.0 return 0.0
canvas[y0:y1, x0:x1] |= footprint canvas[y0:y1, x0:x1] |= footprint
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 return 2 * radius
@@ -305,14 +348,14 @@ def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None
try: try:
image.pixels.foreach_set(rgba.ravel()) image.pixels.foreach_set(rgba.ravel())
image.update() image.update()
image.filepath_raw = str(path) save_png_image(image, path)
image.file_format = "PNG"
image.save()
finally: finally:
bpy.data.images.remove(image) bpy.data.images.remove(image)
def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]: 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.""" """Resolve source PNGs and physical scale from a colour texture job."""
settings = job.get("settings") or {} settings = job.get("settings") or {}
schema = job.get("schema") schema = job.get("schema")
@@ -325,7 +368,14 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]:
raise ValueError("multiview preview requires auto-sized pages") raise ValueError("multiview preview requires auto-sized pages")
px_per_mm = float(page.get("px_per_mm", 0)) px_per_mm = float(page.get("px_per_mm", 0))
sources = [ sources = [
(entry["id"], Path(entry["artifacts"]["png"]), entry.get("seed")) (
entry["id"],
Path(entry["artifacts"]["png"]),
Path(entry["artifacts"]["svg"])
if entry["artifacts"].get("svg")
else None,
entry.get("seed"),
)
for entry in entries for entry in entries
] ]
elif schema == "orisys-sensor-design/texture-job-v3": elif schema == "orisys-sensor-design/texture-job-v3":
@@ -344,19 +394,75 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]:
"single-view page has unequal horizontal and vertical scales" "single-view page has unequal horizontal and vertical scales"
) )
sources = [ sources = [
(entry["uv_map"], Path(entry["artifacts"]["png"]), settings.get("seed")) (
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 for entry in entries
] ]
else: else:
raise ValueError(f"unsupported colour texture job schema: {schema!r}") 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: 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") raise ValueError("colour texture job needs source PNGs and positive px_per_mm")
for _, source, _ in sources: 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(): if not source.is_file():
raise FileNotFoundError(f"run the colour generator first: {source}") raise FileNotFoundError(f"run the colour generator first: {source}")
return sources, px_per_mm 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, mode: str, prefix: str | None) -> str:
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,
@@ -364,21 +470,37 @@ def run(
minimum: int, minimum: int,
modes: tuple[str, ...], modes: tuple[str, ...],
camera_width_fraction: float, camera_width_fraction: float,
formats: tuple[str, ...] = ("png",),
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 (
len(modes) != 1
or not 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")
if not formats or len(formats) != len(set(formats)):
raise ValueError("formats must be non-empty and distinct")
targets = [ targets = [
output_dir / f"{source_id}_{mode}.png" output_dir / f"{artifact_stem(source_id, mode, artifact_prefix)}.{fmt}"
for source_id, _, _ in sources for source_id, _, _, _ in sources
for mode in modes for mode in modes
for fmt in formats
] ]
for target in targets: for target in targets:
if target.exists(): if target.exists():
raise FileExistsError(f"refusing to overwrite {target}") raise FileExistsError(f"refusing to overwrite {target}")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
settings = job.get("settings", {}) settings = job.get("settings", {})
for source_id, source, source_seed in sources: for source_id, source, source_svg, source_seed in sources:
rgb, width, height = read_rgb(source) 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) 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, minimum))
@@ -426,6 +548,7 @@ def run(
) )
for mode in modes: for mode in modes:
marks = np.zeros((height, width), dtype=bool) marks = np.zeros((height, width), dtype=bool)
vector_shapes = [] if vector_page is not None else None
limit = ( limit = (
patterned patterned
if mode if mode
@@ -456,11 +579,42 @@ def run(
else None, else None,
grid_shape=grid_shape, grid_shape=grid_shape,
camera_width_fraction=camera_width_fraction, camera_width_fraction=camera_width_fraction,
vector_shapes=vector_shapes,
) )
for region in mode_regions for region in mode_regions
] ]
target = output_dir / f"{source_id}_{mode}.png" stem = artifact_stem(source_id, mode, artifact_prefix)
write_binary_png(target, marks, surface) 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] accepted = [diameter for diameter in diameters if diameter > 0]
median_width = float(np.median(accepted)) if accepted else 0.0 median_width = float(np.median(accepted)) if accepted else 0.0
physical_width = ( physical_width = (
@@ -480,7 +634,7 @@ def run(
print( print(
f"{source_id}: {mode}, {len(accepted)}/{len(mode_regions)} marks, " f"{source_id}: {mode}, {len(accepted)}/{len(mode_regions)} marks, "
f"black coverage {coverage:.1%}, median mark width " f"black coverage {coverage:.1%}, median mark width "
f"{median_width:.1f} px{physical_width}{width_range} -> {target}", f"{median_width:.1f} px{physical_width}{width_range} -> {stem}",
flush=True, flush=True,
) )
@@ -499,6 +653,8 @@ def main() -> int:
args.min_cell_pixels, args.min_cell_pixels,
tuple(args.modes), tuple(args.modes),
args.camera_width_fraction, args.camera_width_fraction,
tuple(args.formats),
args.artifact_prefix,
) )
return 0 return 0
+141 -18
View File
@@ -22,6 +22,7 @@ from orisys_sensor_design.manifest import (
export_revision_dir, export_revision_dir,
load_profile, load_profile,
repo_root, repo_root,
sha256_file,
validate_revision_metadata, validate_revision_metadata,
write_export_job, write_export_job,
write_manifest, write_manifest,
@@ -470,26 +471,13 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
return 0 return 0
def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int: def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace):
"""Convert existing colour textures to a bounded binary camera-footprint trial.""" """Validate the existing colour job shared by stroke preview and export."""
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return 1
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr)
return 2
texture_dir = resolve_texture_output_dir(root, paths, None) texture_dir = resolve_texture_output_dir(root, paths, None)
job_name = "_texture_views_job.json" if args.views else "_texture_job.json" job_name = "_texture_views_job.json" if args.views else "_texture_job.json"
job_path = (args.source_job or texture_dir / job_name).resolve() job_path = (args.source_job or texture_dir / job_name).resolve()
if not job_path.is_file(): if not job_path.is_file():
print(f"ERROR: colour texture job missing: {job_path}", file=sys.stderr) raise FileNotFoundError(f"colour texture job missing: {job_path}")
return 3
try:
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-multiview-v1" "orisys-sensor-design/texture-job-multiview-v1"
@@ -508,9 +496,37 @@ def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int:
char.isalnum() or char in "_-" for char in source_id char.isalnum() or char in "_-" for char in source_id
) for source_id in source_ids)): ) for source_id in source_ids)):
raise ValueError("source job needs distinct, filename-safe output IDs") raise ValueError("source job needs distinct, filename-safe output IDs")
return job_path, job, entries, source_ids
def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return None, 1
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr)
return None, 2
try:
source = _stroke_source_job(paths, root, args)
except FileNotFoundError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return None, 3
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"ERROR: invalid colour texture job {job_path}: {exc}", file=sys.stderr) print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr)
return 2 return None, 2
return (paths, *source), 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() output_dir = (args.output_dir or job_path.parent / "binary-preview").resolve()
targets = [output_dir / f"{source_id}_camera_compensated_width.png" targets = [output_dir / f"{source_id}_camera_compensated_width.png"
@@ -541,6 +557,85 @@ def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int:
return 0 return 0
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"""Export one PNG/SVG/PDF stroke set per colour source and record provenance."""
context, code = _stroke_cli_context(revision_dir, args)
if context is None:
return code
paths, job_path, _job, entries, source_ids = 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()
outputs = {
source_id: {
fmt: output_dir / f"stroke_texture_{source_id}.{fmt}"
for fmt in ("png", "svg", "pdf")
}
for source_id in source_ids
}
manifest_path = output_dir / "stroke_texture_manifest.json"
temporary = manifest_path.with_suffix(".json.tmp")
if manifest_path.exists() or temporary.exists() or any(path.exists() for artifacts in outputs.values()
for path in artifacts.values()):
print("ERROR: stroke artifacts already exist; 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", "--formats", "png", "svg", "pdf",
"--artifact-prefix", "stroke_texture", "--seed", str(args.seed),
"--camera-width-fraction", str(args.camera_width_fraction)],
)
if code != 0:
return code
missing = [path for artifacts in outputs.values() for path in artifacts.values()
if not path.is_file() or path.stat().st_size == 0]
if missing:
for path in missing:
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
return 3
manifest = {
"schema": "orisys-sensor-design/stroke-texture-manifest-v1",
"revision_dir": str(paths.root),
"source_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
"source_blend": {"path": str(paths.source_blend),
"sha256": sha256_file(paths.source_blend)},
"settings": {"mode": "camera_compensated_width", "seed": args.seed,
"camera_width_fraction": args.camera_width_fraction},
"outputs": [
{"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]),
"sha256": sha256_file(outputs[source_id][fmt])}
for fmt in ("png", "svg", "pdf")}}
for source_id, entry in zip(source_ids, entries)
],
}
temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
temporary.replace(manifest_path)
artifacts = [path for entry in outputs.values() for path in entry.values()]
if console.get_level() == console.QUIET:
console.artifact_paths([*artifacts, manifest_path])
else:
console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}")
for path in artifacts:
console.report(f" {path}")
console.report(f" manifest: {manifest_path}")
return 0
def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate one owned texture per production view. """Generate one owned texture per production view.
@@ -2202,6 +2297,32 @@ def main(argv: list[str] | None = None) -> None:
help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)", help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)",
) )
stroke_p = sub.add_parser(
"stroke-texture",
help="Export Random Stroke Pattern PNG/SVG/PDF from colour artwork",
)
stroke_p.add_argument("revision_dir", type=Path)
stroke_p.add_argument(
"--views", action="store_true",
help="Use the existing two-view colour job and preserve its print ownership",
)
stroke_p.add_argument(
"--source-job", type=Path, default=None,
help="Existing colour job (default: revision build/textures job)",
)
stroke_p.add_argument(
"--output-dir", type=Path, default=None,
help="Fresh output directory (default: strokes beside the colour job)",
)
stroke_p.add_argument(
"--seed", type=int, default=7,
help="Reproducible stroke offsets and angles (default: 7)",
)
stroke_p.add_argument(
"--camera-width-fraction", type=float, default=0.5,
help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)",
)
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes") manifest_p = 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)
manifest_p.add_argument( manifest_p.add_argument(
@@ -2597,6 +2718,8 @@ def main(argv: list[str] | None = None) -> None:
code = cmd_texture_views(args.revision_dir, args) code = cmd_texture_views(args.revision_dir, args)
elif args.command == "binary-preview": elif args.command == "binary-preview":
code = cmd_binary_preview(args.revision_dir, args) code = cmd_binary_preview(args.revision_dir, args)
elif args.command == "stroke-texture":
code = cmd_stroke_texture(args.revision_dir, args)
elif args.command == "inspect": elif args.command == "inspect":
code = cmd_inspect(args.revision_dir, args) code = cmd_inspect(args.revision_dir, args)
elif args.command == "preview": elif args.command == "preview":
+99
View File
@@ -0,0 +1,99 @@
"""The stroke CLI must preserve colour sources and verify all exported formats."""
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, sha256_file
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,
seed=13,
)
def _job(tmp_path: Path) -> tuple[Path, Path, Path]:
paths = RevisionPaths.from_revision_dir(REVISION)
png = tmp_path / "colour.png"
svg = tmp_path / "colour.svg"
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",
"revision_dir": str(paths.root),
"source_blend": str(paths.source_blend),
"outputs": [
{
"uv_map": "compute",
"artifacts": {"png": str(png), "svg": str(svg)},
}
],
}
),
encoding="utf-8",
)
return job_path, png, svg
def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
job, png, svg = _job(tmp_path)
output_dir = tmp_path / "strokes"
calls = []
def run_blender(script, blend, args):
calls.append((script, blend, args))
output_dir.mkdir()
for fmt in ("png", "svg", "pdf"):
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
return 0
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0
assert calls[0][0] == "random_stroke_pattern.py"
assert calls[0][2][-4:] == ["--seed", "13", "--camera-width-fraction", "0.5"]
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1"
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 png.read_bytes() == b"colour pixels"
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
assert len(calls) == 1
def test_stroke_cli_refuses_missing_source_svg(tmp_path, monkeypatch):
job, _png, svg = _job(tmp_path)
svg.unlink()
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")) == 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