30a903a299
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.
661 lines
25 KiB
Python
661 lines
25 KiB
Python
"""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
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--job", required=True, type=Path)
|
||
parser.add_argument("--output-dir", required=True, type=Path)
|
||
parser.add_argument(
|
||
"--params",
|
||
type=Path,
|
||
default=None,
|
||
help="JSON of stroke tuning values; overrides DEFAULT_PARAMS key by key",
|
||
)
|
||
parser.add_argument(
|
||
"--seed", type=int, default=None,
|
||
help="Override params.seed for this run",
|
||
)
|
||
parser.add_argument(
|
||
"--min-cell-pixels", type=int, default=None,
|
||
help="Override params.min_cell_pixels for this run",
|
||
)
|
||
parser.add_argument(
|
||
"--camera-width-fraction",
|
||
type=float,
|
||
default=None,
|
||
help="Override params.width_fraction for this run",
|
||
)
|
||
parser.add_argument(
|
||
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
|
||
)
|
||
parser.add_argument(
|
||
"--artifact-prefix",
|
||
default=None,
|
||
help="Name outputs <prefix>_<source-id>.<format>",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def resolve_params(args: argparse.Namespace) -> dict:
|
||
"""DEFAULT_PARAMS, then the --params file, then the per-run flag overrides."""
|
||
params = dict(DEFAULT_PARAMS)
|
||
if args.params is not None:
|
||
supplied = json.loads(args.params.read_text(encoding="utf-8"))
|
||
if not isinstance(supplied, dict):
|
||
raise ValueError("--params must contain a JSON object")
|
||
unknown = sorted(set(supplied) - set(DEFAULT_PARAMS))
|
||
if unknown:
|
||
raise ValueError(
|
||
f"--params: unknown key(s) {unknown}; allowed: {sorted(DEFAULT_PARAMS)}"
|
||
)
|
||
params.update(supplied)
|
||
for key, value in (
|
||
("seed", args.seed),
|
||
("min_cell_pixels", args.min_cell_pixels),
|
||
("width_fraction", args.camera_width_fraction),
|
||
):
|
||
if value is not None:
|
||
params[key] = value
|
||
validate_params(params)
|
||
return params
|
||
|
||
|
||
def cell_code(
|
||
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float
|
||
) -> 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),
|
||
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(
|
||
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,
|
||
) -> 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"],
|
||
)
|
||
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
|
||
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, 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)
|
||
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:
|
||
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())
|