Files
orisys-sensor-design/blender/scripts/random_stroke_pattern.py
T
ykcheungab 5bd7dd7a01 Add stroke texture PNG, SVG, and PDF export
Reuse colour artwork serializers and record source hashes. Verified single and multiview exports plus targeted tests.
2026-10-03 21:17:28 +08:00

664 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/binary-pattern-from-color/run-01
This is an image-space experiment, not a print master. It accepts either a single-view
texture job or a multiview job. Each distinct source colour is treated as one cell
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
cell positions.
The physical-size outputs compare two stroke widths with a 0.2 mm-long,
0.1 mm-wide capsule. Clipping and merged neighbours can change the widths of
visible features.
The spaced 0.12 mm trial rejects clipped or colliding marks on each print page;
its selection can differ between the two UV views, so it is also preview-only.
The balanced trial permits overlaps but caps nearby black coverage.
The camera-scaled trial uses the source sensing-camera grid for a radial size
gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These are
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
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
PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2}
MODES = (
"dots",
"dashes",
"medium_dashes",
"dense_dashes",
*PHYSICAL_WIDTH_MM,
"length_0p2_width_0p1mm",
"spaced_width_0p12mm",
"balanced_width_0p12mm",
"camera_scaled_width",
"camera_compensated_width",
)
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("--seed", type=int, default=7)
parser.add_argument("--min-cell-pixels", type=int, default=20)
parser.add_argument(
"--modes",
nargs="+",
choices=MODES,
default=MODES,
help="Generate only these preview variants",
)
parser.add_argument(
"--camera-width-fraction",
type=float,
default=0.5,
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)
def cell_code(rgb: np.ndarray, seed: int) -> tuple[float, float, float, float]:
"""Return bounded offsets, angle and size from one exact source colour."""
digest = hashlib.blake2b(
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True),
digest_size=8,
).digest()
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
return (
0.66 * (values[0] - 0.5),
0.66 * (values[1] - 0.5),
math.pi * values[2],
0.85 + 0.3 * values[3],
)
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_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(
print_span_px: int, px_per_mm: float, width_fraction: float
) -> tuple[float, float]:
"""Return nominal print dimensions 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
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(
canvas: np.ndarray,
region,
seed: int,
mode: str,
px_per_mm: float,
patterned: np.ndarray | 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,
) -> float:
colour, cell_x, cell_y, width, height = region
rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255)
dx, dy, angle, scale = cell_code(np.asarray(rgb), seed)
span = min(width, height)
centre_x = cell_x + dx * width
centre_y = cell_y + dy * height
if mode == "camera_compensated_width":
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, camera_width_fraction
)
radius = width_mm * px_per_mm / 2
half_length = (length_mm - width_mm) * px_per_mm / 2
elif mode == "camera_scaled_width":
if camera_cell is None or grid_shape is None:
raise ValueError("camera-scaled marks require an unambiguous camera cell")
width_mm, length_mm = camera_scaled_size(camera_cell, *grid_shape)
radius = width_mm * px_per_mm / 2
half_length = (length_mm - width_mm) * px_per_mm / 2
elif mode == "spaced_width_0p12mm":
radius = 0.12 * px_per_mm / 2
half_length = (0.30 - 0.12) * px_per_mm / 2
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 (
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
if mode == "spaced_width_0p12mm":
clearance = distance_sq <= (radius + gap_px) ** 2
if np.any(canvas[y0:y1, x0:x1] & clearance):
return 0.0
else:
local_radius = round(0.5 * 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 > 0.45:
return 0.0
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
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, mode: 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,
seed: int,
minimum: int,
modes: tuple[str, ...],
camera_width_fraction: float,
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 (
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 = [
output_dir / f"{artifact_stem(source_id, mode, artifact_prefix)}.{fmt}"
for source_id, _, _, _ in sources
for mode in modes
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, minimum))
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,
)
lookup = None
grid_shape = None
camera_modes = ("camera_scaled_width", "camera_compensated_width")
if any(mode in modes for mode in camera_modes):
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-based 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,
)
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 ""
)
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)
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(
args.job.resolve(),
args.output_dir.resolve(),
args.seed,
args.min_cell_pixels,
tuple(args.modes),
args.camera_width_fraction,
tuple(args.formats),
args.artifact_prefix,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())