"""Generate Random Stroke Pattern (RSP) artwork from colour texture jobs. Run after the colour texture command has produced its PNGs:: blender -b -t 2 --python-exit-code 1 \ -P blender/scripts/random_stroke_pattern.py -- \ --job build/textures/finger/r01/_texture_views_job.json \ --output-dir .scratch/stroke-pattern/run-01 Accepts either a single-view or a multiview colour job. Each distinct source colour is one cell of the colour artwork; a split cell can have a different centre in the two print views. One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of ``width_mm`` swept ``length_mm``, rotated per cell, and its width is scaled from that cell's footprint on the print page so a cell the projection stretches receives a proportionally larger mark. The width is then clamped to ``width_min_mm``..``width_max_mm``. **This is an image-space experiment, not a print master.** Clipping and merged neighbours change the visible widths, so verify dimensions on the printed part before manufacturing. Tuning lives in ``design.yaml`` under ``stroke_pattern``; the CLI serialises it here as ``--params``. With no ``--params`` the built-in ``DEFAULT_PARAMS`` apply. """ from __future__ import annotations import argparse import hashlib import json import math import sys import xml.etree.ElementTree as ET from pathlib import Path import bpy import numpy as np script_dir = str(Path(__file__).resolve().parent) if script_dir not in sys.path: sys.path.insert(0, script_dir) from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path from stroke_geometry import capsule_segment, has_clearance # noqa: E402 - Blender script directory is on sys.path MODE = "camera_compensated_width" #: Every tunable, at the values this experiment shipped with. The CLI writes a `design.yaml` #: `stroke_pattern` block here as `--params`; `--seed`, `--min-cell-pixels` and #: `--camera-width-fraction` override individual keys for one run. DEFAULT_PARAMS: dict[str, float] = { "seed": 7, "min_cell_pixels": 20, "jitter_fraction": 0.66, "angle_span_deg": 180.0, "width_fraction": 0.5, "width_min_mm": 0.11, "width_max_mm": 0.28, "length_ratio": 2.4, "coverage_cap": 0.45, "neighbourhood_mm": 0.5, "clearance_mm": 0.0, "candidate_count": 1, } def validate_params(params: dict) -> None: """Raise on a value that cannot produce artwork, naming the key. Ranges live here rather than only on the host side, because this script is also run directly under Blender, where nothing else would catch a bad `--params` file. """ for key, value in params.items(): if not isinstance(value, (int, float)) or isinstance(value, bool): raise ValueError(f"params.{key}: must be a number (got {value!r})") if not math.isfinite(float(value)): raise ValueError(f"params.{key}: must be finite (got {value!r})") if params["min_cell_pixels"] < 1: raise ValueError("params.min_cell_pixels must be >= 1") if not isinstance(params["candidate_count"], int) or params["candidate_count"] < 1: raise ValueError("params.candidate_count must be an integer >= 1") if params["clearance_mm"] < 0: raise ValueError("params.clearance_mm must be >= 0") if not 0.0 <= params["jitter_fraction"] <= 1.0: raise ValueError("params.jitter_fraction must be within [0, 1]") if params["angle_span_deg"] < 0.0: raise ValueError("params.angle_span_deg must be >= 0") for key in ("width_fraction", "width_min_mm", "width_max_mm", "length_ratio", "neighbourhood_mm"): if params[key] <= 0.0: raise ValueError(f"params.{key} must be > 0") if params["width_min_mm"] > params["width_max_mm"]: raise ValueError("params.width_min_mm must be <= params.width_max_mm") if not 0.0 < params["coverage_cap"] <= 1.0: raise ValueError("params.coverage_cap must be within (0, 1]") def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--job", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument( "--params", type=Path, default=None, help="JSON of stroke tuning values; overrides DEFAULT_PARAMS key by key", ) parser.add_argument( "--seed", type=int, default=None, help="Override params.seed for this run", ) parser.add_argument( "--min-cell-pixels", type=int, default=None, help="Override params.min_cell_pixels for this run", ) parser.add_argument( "--camera-width-fraction", type=float, default=None, help="Override params.width_fraction for this run", ) parser.add_argument( "--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",) ) parser.add_argument( "--artifact-prefix", default=None, help="Name outputs _.", ) return parser.parse_args(argv) def resolve_params(args: argparse.Namespace) -> dict: """DEFAULT_PARAMS, then the --params file, then the per-run flag overrides.""" params = dict(DEFAULT_PARAMS) if args.params is not None: supplied = json.loads(args.params.read_text(encoding="utf-8")) if not isinstance(supplied, dict): raise ValueError("--params must contain a JSON object") unknown = sorted(set(supplied) - set(DEFAULT_PARAMS)) if unknown: raise ValueError( f"--params: unknown key(s) {unknown}; allowed: {sorted(DEFAULT_PARAMS)}" ) params.update(supplied) for key, value in ( ("seed", args.seed), ("min_cell_pixels", args.min_cell_pixels), ("width_fraction", args.camera_width_fraction), ): if value is not None: params[key] = value validate_params(params) return params def cell_code( rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float, candidate_index: int = 0, ) -> tuple[float, float, float]: """Return bounded offsets and angle from one exact source colour. Derived from the colour itself, so the same palette gives the same layout in both print views without storing anything. """ digest = hashlib.blake2b( bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True) + (candidate_index.to_bytes(4, "little") if candidate_index else b""), digest_size=8, ).digest() values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)] return ( jitter_fraction * (values[0] - 0.5), jitter_fraction * (values[1] - 0.5), math.radians(angle_span_deg) * values[2], ) def read_rgb(path: Path) -> tuple[np.ndarray, int, int]: image = bpy.data.images.load(str(path), check_existing=False) try: width, height = image.size pixels = np.empty(width * height * 4, dtype=np.float32) image.pixels.foreach_get(pixels) rgb = np.clip( np.rint(pixels.reshape(height, width, 4)[:, :, :3] * 255), 0, 255 ).astype(np.uint8) return rgb, width, height finally: bpy.data.images.remove(image) def cell_regions(rgb: np.ndarray, minimum: int): """Measure source-colour regions without assuming a rectangular UV cell.""" height, width, _ = rgb.shape packed = ( (rgb[:, :, 0].astype(np.uint32) << 16) | (rgb[:, :, 1].astype(np.uint32) << 8) | rgb[:, :, 2].astype(np.uint32) ) colours, inverse, counts = np.unique( packed, return_inverse=True, return_counts=True ) rows = np.repeat(np.arange(height, dtype=np.int32), width) cols = np.tile(np.arange(width, dtype=np.int32), height) n = len(colours) left = np.full(n, width, dtype=np.int32) right = np.full(n, -1, dtype=np.int32) bottom = np.full(n, height, dtype=np.int32) top = np.full(n, -1, dtype=np.int32) np.minimum.at(left, inverse, cols) np.maximum.at(right, inverse, cols) np.minimum.at(bottom, inverse, rows) np.maximum.at(top, inverse, rows) cx = np.bincount(inverse, weights=cols, minlength=n) / counts cy = np.bincount(inverse, weights=rows, minlength=n) / counts for index, colour in enumerate(colours): if colour in (0, 0xFFFFFF) or counts[index] < minimum: continue cell_width = right[index] - left[index] + 1 cell_height = top[index] - bottom[index] + 1 yield ( int(colour), float(cx[index]), float(cy[index]), int(cell_width), int(cell_height), ) def camera_cell_lookup( nx: int, ny: int, colour_radius: float, seed: int ) -> dict[int, tuple[int, int] | None]: """Recover the original camera-grid cell from its quantized palette colour.""" script_dir = str(Path(__file__).resolve().parent) if script_dir not in sys.path: sys.path.insert(0, script_dir) from random_patch_dd02c import precompute_cell_colors rng_state = np.random.get_state() try: np.random.seed(seed) palette = precompute_cell_colors(nx, ny, colour_radius) finally: np.random.set_state(rng_state) rgb = np.clip(np.rint(palette * 255), 0, 255).astype(np.uint8) packed = ( (rgb[:, :, 0].astype(np.uint32) << 16) | (rgb[:, :, 1].astype(np.uint32) << 8) | rgb[:, :, 2].astype(np.uint32) ) lookup: dict[int, tuple[int, int] | None] = {} for j in range(ny): for i in range(nx): colour = int(packed[j, i]) lookup[colour] = None if colour in lookup else (i, j) return lookup def camera_compensated_size( print_span_px: int, px_per_mm: float, width_fraction: float, width_min_mm: float, width_max_mm: float, length_ratio: float, ) -> tuple[float, float]: """Nominal print width and length in mm from one projected camera-cell footprint. Scaling from the footprint is the point of this mode: the page is a projection, so a cell the projection stretches is physically larger and must receive a proportionally larger mark to stay in proportion. The clamp then keeps a far-field cell from growing past what the process can hold. """ width_mm = max( width_min_mm, min(width_max_mm, print_span_px / px_per_mm * width_fraction) ) return width_mm, length_ratio * width_mm def capsule_polygon( centre_x: float, centre_y: float, radius: float, half_length: float, angle: float ) -> list[tuple[float, float]]: """Approximate the raster capsule boundary for native vector output.""" points = [] cosine, sine = math.cos(angle), math.sin(angle) for side, start in ((1, -math.pi / 2), (-1, math.pi / 2)): for index in range(25): theta = start + index * math.pi / 24 local_x = side * half_length + radius * math.cos(theta) local_y = radius * math.sin(theta) points.append( ( centre_x + local_x * cosine - local_y * sine, centre_y + local_x * sine + local_y * cosine, ) ) return points def draw_mark_candidate( canvas: np.ndarray, region, params: dict, px_per_mm: float, patterned: np.ndarray | None = None, camera_cell: tuple[int, int] | None = None, vector_shapes: list | None = None, accepted_capsules: list | None = None, candidate_index: int = 0, ) -> float: """Draw one camera-compensated capsule. Returns its printed width in pixels, or 0.""" colour, cell_x, cell_y, width, height = region rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255) dx, dy, angle = cell_code( np.asarray(rgb), params["seed"], params["jitter_fraction"], params["angle_span_deg"], candidate_index, ) span = min(width, height) centre_x = cell_x + dx * width centre_y = cell_y + dy * height if camera_cell is None: raise ValueError( "camera-compensated marks require an unambiguous camera cell" ) width_mm, length_mm = camera_compensated_size( span, px_per_mm, params["width_fraction"], params["width_min_mm"], params["width_max_mm"], params["length_ratio"], ) radius = width_mm * px_per_mm / 2 half_length = (length_mm - width_mm) * px_per_mm / 2 extent = radius + half_length if patterned is not None and ( centre_x - extent < 0 or centre_y - extent < 0 or centre_x + extent >= canvas.shape[1] or centre_y + extent >= canvas.shape[0] ): return 0.0 x0 = max(0, math.floor(centre_x - extent)) x1 = min(canvas.shape[1], math.ceil(centre_x + extent + 1)) y0 = max(0, math.floor(centre_y - extent)) y1 = min(canvas.shape[0], math.ceil(centre_y + extent + 1)) if x0 >= x1 or y0 >= y1: return 0.0 yy, xx = np.ogrid[y0:y1, x0:x1] px = xx - centre_x py = yy - centre_y along = px * math.cos(angle) + py * math.sin(angle) across = -px * math.sin(angle) + py * math.cos(angle) distance_sq = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 footprint = distance_sq <= radius**2 if patterned is not None: if np.any(footprint & ~patterned[y0:y1, x0:x1]): return 0.0 local_radius = round(params["neighbourhood_mm"] * px_per_mm) lx0 = max(0, round(centre_x) - local_radius) lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1) ly0 = max(0, round(centre_y) - local_radius) ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1) local_pattern = patterned[ly0:ly1, lx0:lx1] local_area = np.count_nonzero(local_pattern) if local_area == 0: return 0.0 existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern) added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1]) if (existing + added) / local_area > params["coverage_cap"]: return 0.0 capsule = (*capsule_segment(centre_x / px_per_mm, centre_y / px_per_mm, half_length / px_per_mm, angle), radius / px_per_mm) if accepted_capsules is not None and not has_clearance( capsule, accepted_capsules, params["clearance_mm"] ): return 0.0 canvas[y0:y1, x0:x1] |= footprint if accepted_capsules is not None: accepted_capsules.append(capsule) if vector_shapes is not None: vector_shapes.append( ( capsule_polygon(centre_x, centre_y, radius, half_length, angle), (0.0, 0.0, 0.0, 1.0), ) ) return 2 * radius def draw_mark(canvas, region, params, px_per_mm, patterned=None, camera_cell=None, vector_shapes=None, accepted_capsules=None) -> float: """Try deterministic alternatives without relaxing width or clearance.""" for candidate_index in range(params["candidate_count"]): width = draw_mark_candidate( canvas, region, params, px_per_mm, patterned, camera_cell, vector_shapes, accepted_capsules, candidate_index, ) if width: return width return 0.0 def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None: height, width = marks.shape rgba = np.zeros((height, width, 4), dtype=np.float32) rgba[:, :, :3] = (surface & ~marks)[:, :, None] rgba[:, :, 3] = 1.0 image = bpy.data.images.new(path.stem, width=width, height=height, alpha=True) try: image.pixels.foreach_set(rgba.ravel()) image.update() save_png_image(image, path) finally: bpy.data.images.remove(image) def job_sources( job: dict, ) -> tuple[list[tuple[str, Path, Path | None, int | None]], float]: """Resolve source PNGs and physical scale from a colour texture job.""" settings = job.get("settings") or {} schema = job.get("schema") if schema == "orisys-sensor-design/texture-job-multiview-v1": entries = job.get("pairs") or [] page = job.get("page") or {} if page.get("mode") != "auto" or any( entry.get("page", "auto") != "auto" for entry in entries ): raise ValueError("multiview preview requires auto-sized pages") px_per_mm = float(page.get("px_per_mm", 0)) sources = [ ( entry["id"], Path(entry["artifacts"]["png"]), Path(entry["artifacts"]["svg"]) if entry["artifacts"].get("svg") else None, entry.get("seed"), ) for entry in entries ] elif schema == "orisys-sensor-design/texture-job-v3": entries = job.get("outputs") or [] vector = settings.get("vector") or {} width_mm = float(vector.get("width_mm", 0)) height_mm = float(vector.get("height_mm", 0)) if width_mm <= 0 or height_mm <= 0: raise ValueError( "single-view preview requires positive vector page dimensions" ) px_per_mm = float(settings["width"]) / width_mm vertical_scale = float(settings["height"]) / height_mm if not math.isclose(px_per_mm, vertical_scale, rel_tol=1e-6): raise ValueError( "single-view page has unequal horizontal and vertical scales" ) sources = [ ( entry["uv_map"], Path(entry["artifacts"]["png"]), Path(entry["artifacts"]["svg"]) if entry["artifacts"].get("svg") else None, settings.get("seed"), ) for entry in entries ] else: raise ValueError(f"unsupported colour texture job schema: {schema!r}") if not sources or not math.isfinite(px_per_mm) or px_per_mm <= 0: raise ValueError("colour texture job needs source PNGs and positive px_per_mm") for source_id, source, _svg, _seed in sources: if not source_id or not all( char.isalnum() or char in "_-" for char in source_id ): raise ValueError(f"unsafe source ID: {source_id!r}") if not source.is_file(): raise FileNotFoundError(f"run the colour generator first: {source}") return sources, px_per_mm def source_mesh_polygons( svg_path: Path | None, width: int, height: int, px_per_mm: float ) -> tuple[list[list[tuple[float, float]]], float, float]: """Read the source artwork's owned white ground and physical page size.""" if svg_path is None or not svg_path.is_file(): raise FileNotFoundError( f"source colour SVG is required for vectors: {svg_path}" ) root = ET.parse(svg_path).getroot() width_mm = float(root.attrib["width"].removesuffix("mm")) height_mm = float(root.attrib["height"].removesuffix("mm")) view_box = [float(value) for value in root.attrib["viewBox"].split()] if ( len(view_box) != 4 or view_box[:2] != [0.0, 0.0] or view_box[2:] != [float(width), float(height)] or not math.isfinite(width_mm) or not math.isfinite(height_mm) or width_mm <= 0 or height_mm <= 0 or not math.isclose(width / width_mm, px_per_mm, rel_tol=0.002) or not math.isclose(height / height_mm, px_per_mm, rel_tol=0.002) ): raise ValueError(f"source SVG page does not match colour PNG/job: {svg_path}") namespace = {"svg": "http://www.w3.org/2000/svg"} mesh = root.find(".//svg:g[@id='mesh']", namespace) if mesh is None: raise ValueError(f"source SVG has no owned mesh group: {svg_path}") polygons = [] for element in mesh.findall("svg:polygon", namespace): polygon = [ tuple(float(value) for value in point.split(",")) for point in element.attrib["points"].split() ] if len(polygon) >= 3: polygons.append(polygon) if not polygons: raise ValueError(f"source SVG has no owned mesh polygons: {svg_path}") return polygons, width_mm, height_mm def artifact_stem(source_id: str, prefix: str | None) -> str: return f"{prefix}_{source_id}" if prefix else f"{source_id}_{MODE}" def run( job_path: Path, output_dir: Path, params: dict, formats: tuple[str, ...] = ("png",), artifact_prefix: str | None = None, ) -> None: job = json.loads(job_path.read_text(encoding="utf-8")) sources, px_per_mm = job_sources(job) if artifact_prefix is not None and ( not artifact_prefix or not all(char.isalnum() or char in "_-" for char in artifact_prefix) ): raise ValueError("artifact prefix must be non-empty and filename-safe") if not formats or len(formats) != len(set(formats)): raise ValueError("formats must be non-empty and distinct") targets = [ output_dir / f"{artifact_stem(source_id, artifact_prefix)}.{fmt}" for source_id, _, _, _ in sources for fmt in formats ] for target in targets: if target.exists(): raise FileExistsError(f"refusing to overwrite {target}") output_dir.mkdir(parents=True, exist_ok=True) settings = job.get("settings", {}) for source_id, source, source_svg, source_seed in sources: rgb, width, height = read_rgb(source) vector_page = ( source_mesh_polygons(source_svg, width, height, px_per_mm) if "svg" in formats or "pdf" in formats else None ) surface = np.any(rgb != 0, axis=2) patterned = surface & np.any(rgb != 255, axis=2) regions = list(cell_regions(rgb, int(params["min_cell_pixels"]))) if not regions or not np.any(patterned): raise ValueError(f"{source_id}: no coloured cell regions in source PNG") cell_span = float(np.median([min(region[3], region[4]) for region in regions])) print( f"{source_id}: median cell span {cell_span:.1f} px " f"({cell_span / px_per_mm:.3f} mm on the print page)", flush=True, ) # A mark is sized from the cell's footprint, so an ambiguous palette colour has no # known footprint and is skipped rather than guessed. grid_shape = (int(settings["nx"]), int(settings["ny"])) pair_seed = source_seed if source_seed is not None else settings.get("seed") if pair_seed is None: raise ValueError("camera-compensated preview requires a seeded colour palette") lookup = camera_cell_lookup( *grid_shape, float(settings["color_r"]), int(pair_seed) ) missing = [ hex(region[0]) for region in regions if region[0] != 0xFF0000 and region[0] not in lookup ] if missing: raise ValueError( f"{source_id}: camera cell missing for " f"{len(missing)} colours: {missing[:5]}" ) ambiguous = [ hex(region[0]) for region in regions if region[0] != 0xFF0000 and lookup[region[0]] is None ] if ambiguous: print( f"{source_id}: skip {len(ambiguous)} ambiguous palette regions " f"{ambiguous[:5]}", flush=True, ) marks = np.zeros((height, width), dtype=bool) accepted_capsules = [] vector_shapes = [] if vector_page is not None else None mode_regions = [ region for region in regions if region[0] != 0xFF0000 and lookup[region[0]] is not None ] diameters = [ draw_mark( marks, region, params, px_per_mm, patterned, camera_cell=lookup[region[0]], vector_shapes=vector_shapes, accepted_capsules=accepted_capsules, ) for region in mode_regions ] stem = artifact_stem(source_id, artifact_prefix) if "png" in formats: write_binary_png(output_dir / f"{stem}.png", marks, surface) if vector_page is not None: mesh_polygons, width_mm, height_mm = vector_page if "svg" in formats: write_svg_vector( output_dir / f"{stem}.svg", width, height, width_mm, height_mm, mesh_polygons, vector_shapes, [], (0.0, 0.0, 0.0, 1.0), source_id, artwork_label="stroke", ) if "pdf" in formats: write_pdf_vector( output_dir / f"{stem}.pdf", width, height, width_mm, height_mm, mesh_polygons, vector_shapes, [], (0.0, 0.0, 0.0, 1.0), artwork_label="stroke", ) accepted = [diameter for diameter in diameters if diameter > 0] median_width = float(np.median(accepted)) if accepted else 0.0 physical_width = ( f", about {median_width / px_per_mm:.3f} mm on the print page" if px_per_mm > 0 else "" ) coverage = float( np.count_nonzero(marks & patterned) / np.count_nonzero(patterned) ) width_range = ( f", width range {min(accepted) / px_per_mm:.3f}–" f"{max(accepted) / px_per_mm:.3f} mm" if accepted else "" ) print( f"{source_id}: {MODE}, {len(accepted)}/{len(mode_regions)} marks, " f"black coverage {coverage:.1%}, median mark width " f"{median_width:.1f} px{physical_width}{width_range} -> {stem}", flush=True, ) def main() -> int: argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] args = parse_args(argv) run( args.job.resolve(), args.output_dir.resolve(), resolve_params(args), tuple(args.formats), args.artifact_prefix, ) return 0 if __name__ == "__main__": raise SystemExit(main())