改用相机网格生成笔画纹理

统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。
已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
ykcheungab
2026-10-06 10:51:56 +08:00
parent 35da84ef60
commit e8d231ec39
25 changed files with 405507 additions and 94905 deletions
+54 -17
View File
@@ -1,4 +1,4 @@
"""Convert camera-projected colour-patch cells to black capsule strokes.
"""Convert camera-projected patch cells to black capsule strokes.
Run with Blender's source geometry and the existing texture job. This first
camera-space prototype preserves the colour pattern's grid and visibility
@@ -7,8 +7,8 @@ projection. It does not enforce a physical minimum stroke width.
import argparse
import json
from pathlib import Path
import sys
from pathlib import Path
import bpy
@@ -19,22 +19,41 @@ from camera_grid_stroke_optimizer import optimize_angles
from patch_geometry import clip_rect
from patch_projection import build_patch_geometry
from random_patch_dd02c import (
_clipped_polygons, _clipped_shapes, _prepare_mesh_object, _require_object,
_require_uv_layer, fill_background, fill_polygon, save_png_image,
write_pdf_vector, write_svg_vector,
_clipped_polygons,
_clipped_shapes,
_prepare_mesh_object,
_require_object,
_require_uv_layer,
fill_background,
fill_polygon,
save_png_image,
write_pdf_vector,
write_svg_vector,
)
MODE = "camera_grid_capsules"
DEFAULT_PARAMS = {
"seed": 7,
"width_ratio": 0.42,
"length_ratio": 0.84,
"orientation_mode": "random",
"proposals": 100000,
}
def _args():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--job", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--width-ratio", type=float, default=0.42)
parser.add_argument("--length-ratio", type=float, default=0.84)
parser.add_argument("--params", type=Path, default=None)
parser.add_argument("--artifact-prefix", default="camera_grid_stroke")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--width-ratio", type=float, default=None)
parser.add_argument("--length-ratio", type=float, default=None)
parser.add_argument("--orientation-mode", choices=("random", "optimized"),
default="random", help="Use seeded random or experimental optimized angles")
parser.add_argument("--proposals", type=int, default=100000,
default=None, help="Use seeded random or experimental optimized angles")
parser.add_argument("--proposals", type=int, default=None,
help="Angle changes attempted in optimized mode")
return parser.parse_args(argv)
@@ -45,7 +64,7 @@ def _pixel_polygon(uv_polygon, width, height):
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None):
angle_map=None, artifact_prefix="camera_grid_stroke"):
width, height = settings["width"], settings["height"]
nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0
@@ -78,7 +97,9 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
image.pixels[:] = pixels
image.update()
label = "camera_grid_stroke_optimized" if angle_map is not None else "camera_grid_stroke"
label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}"
save_png_image(image, base.with_suffix(".png"))
page_mesh = _clipped_polygons(mesh_polygons, width, height)
@@ -96,10 +117,25 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
def main():
args = _args()
if not 0 < args.width_ratio <= args.length_ratio < 1:
params = dict(DEFAULT_PARAMS)
if args.params is not None:
loaded = json.loads(args.params.read_text(encoding="utf-8"))
if not isinstance(loaded, dict) or set(loaded) - set(DEFAULT_PARAMS):
raise ValueError("invalid stroke params")
params.update(loaded)
for key in DEFAULT_PARAMS:
value = getattr(args, key)
if value is not None:
params[key] = value
if not 0 < params["width_ratio"] <= params["length_ratio"] < 1:
raise ValueError("require 0 < width-ratio <= length-ratio < 1")
if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1:
raise ValueError("invalid orientation mode or proposal count")
job = json.loads(args.job.read_text(encoding="utf-8"))
settings = job["settings"]
if job.get("schema") != "orisys-sensor-design/texture-job-v3":
raise ValueError("camera-grid strokes require a single-view texture-job-v3")
settings = dict(job["settings"])
settings["seed"] = params["seed"]
obj = _prepare_mesh_object(job["objects"]["mesh"])
cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera")
names = [entry["uv_map"] for entry in job["outputs"]]
@@ -108,12 +144,12 @@ def main():
args.output_dir.mkdir(parents=True, exist_ok=True)
args.output_dir = args.output_dir.resolve()
angle_map = None
if args.orientation_mode == "optimized":
if params["orientation_mode"] == "optimized":
angle_map, report = optimize_angles(
settings["nx"], settings["ny"], settings.get("seed") or 0,
args.width_ratio, args.length_ratio, args.proposals,
params["width_ratio"], params["length_ratio"], params["proposals"],
)
report_path = args.output_dir / "camera_grid_stroke_optimization.json"
report_path = args.output_dir / f"{args.artifact_prefix}_optimization.json"
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Camera-grid optimization: {report}")
geometry = build_patch_geometry(
@@ -123,7 +159,8 @@ def main():
)
for name in names:
_render(name, geometry[name], settings, args.output_dir,
args.width_ratio, args.length_ratio, angle_map)
params["width_ratio"], params["length_ratio"], angle_map,
args.artifact_prefix)
if __name__ == "__main__":
-789
View File
@@ -1,789 +0,0 @@
"""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``.
When ``target_coverage`` is positive, accepted capsules grow from their endpoints into free
space, up to twice their initial length. Width and edge clearance stay fixed. The reported
coverage is measured only inside the eligible colour-pattern region.
**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,
"target_coverage": 0.0,
}
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]")
if not 0.0 <= params["target_coverage"] < 1.0:
raise ValueError("params.target_coverage 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,
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 capsule_footprint(capsule, px_per_mm, shape):
"""Raster footprint of a millimetre-space capsule, or None outside the page."""
start, end, radius_mm = capsule
cx = (start[0] + end[0]) * px_per_mm / 2
cy = (start[1] + end[1]) * px_per_mm / 2
dx = (end[0] - start[0]) * px_per_mm / 2
dy = (end[1] - start[1]) * px_per_mm / 2
half_length = math.hypot(dx, dy)
angle = math.atan2(dy, dx)
radius = radius_mm * px_per_mm
extent = half_length + radius
if cx - extent < 0 or cy - extent < 0 or cx + extent >= shape[1] or cy + extent >= shape[0]:
return None
x0, x1 = math.floor(cx - extent), min(shape[1], math.ceil(cx + extent + 1))
y0, y1 = math.floor(cy - extent), min(shape[0], math.ceil(cy + extent + 1))
yy, xx = np.ogrid[y0:y1, x0:x1]
along = (xx - cx) * math.cos(angle) + (yy - cy) * math.sin(angle)
across = -(xx - cx) * math.sin(angle) + (yy - cy) * math.cos(angle)
footprint = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 <= radius**2
return (slice(y0, y1), slice(x0, x1)), footprint
def extend_marks(marks, patterned, capsules, vector_shapes, params, px_per_mm):
"""Grow existing marks into free space until target occupancy or geometry stops them."""
target = math.ceil(params["target_coverage"] * np.count_nonzero(patterned))
occupied = np.count_nonzero(marks & patterned)
if target <= occupied or not capsules:
return occupied
step_mm = 2 / px_per_mm
initial_lengths = [math.dist(start, end) + 2 * radius
for start, end, radius in capsules]
blocked = [[False, False] for _ in capsules]
while occupied < target and any(not all(pair) for pair in blocked):
for index, capsule in enumerate(capsules):
for side in (0, 1):
if blocked[index][side]:
continue
start, end, radius = capsule
length = math.dist(start, end)
if length + step_mm + 2 * radius > 2 * initial_lengths[index]:
blocked[index][side] = True
continue
ux = (end[0] - start[0]) / length
uy = (end[1] - start[1]) / length
candidate = (
(start[0] - ux * step_mm, start[1] - uy * step_mm) if side == 0 else start,
(end[0] + ux * step_mm, end[1] + uy * step_mm) if side == 1 else end,
radius,
)
if not has_clearance(candidate, capsules, params["clearance_mm"], index):
blocked[index][side] = True
continue
raster = capsule_footprint(candidate, px_per_mm, marks.shape)
if raster is None:
blocked[index][side] = True
continue
region, footprint = raster
if np.any(footprint & ~patterned[region]):
blocked[index][side] = True
continue
occupied += np.count_nonzero(footprint & ~marks[region])
marks[region] |= footprint
capsules[index] = candidate
capsule = candidate
if occupied >= target:
break
if occupied >= target:
break
if vector_shapes is not None:
vector_shapes[:] = [
(capsule_polygon((start[0] + end[0]) * px_per_mm / 2,
(start[1] + end[1]) * px_per_mm / 2,
radius * px_per_mm,
math.dist(start, end) * px_per_mm / 2,
math.atan2(end[1] - start[1], end[0] - start[0])),
(0.0, 0.0, 0.0, 1.0))
for start, end, radius in capsules
]
return occupied
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
]
initial_coverage = np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
if params["target_coverage"] > initial_coverage:
extend_marks(marks, patterned, accepted_capsules, vector_shapes, params, px_per_mm)
print(f"{source_id}: endpoint extension {initial_coverage:.1%} -> "
f"{np.count_nonzero(marks & patterned) / np.count_nonzero(patterned):.1%} "
f"(target {params['target_coverage']:.1%})", flush=True)
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())
-53
View File
@@ -1,53 +0,0 @@
"""Physical-space clearance checks for capsule-shaped laser marks."""
from __future__ import annotations
import math
def point_segment_distance_sq(point, start, end):
dx, dy = end[0] - start[0], end[1] - start[1]
length_sq = dx * dx + dy * dy
if length_sq == 0:
return (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2
t = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_sq))
return (point[0] - start[0] - t * dx) ** 2 + (point[1] - start[1] - t * dy) ** 2
def segments_distance_sq(a0, a1, b0, b1):
"""Minimum squared distance, including a proper crossing at zero."""
def cross(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
ab0, ab1 = cross(a0, a1, b0), cross(a0, a1, b1)
ba0, ba1 = cross(b0, b1, a0), cross(b0, b1, a1)
if ab0 * ab1 < 0 and ba0 * ba1 < 0:
return 0.0
return min(
point_segment_distance_sq(a0, b0, b1),
point_segment_distance_sq(a1, b0, b1),
point_segment_distance_sq(b0, a0, a1),
point_segment_distance_sq(b1, a0, a1),
)
def capsule_segment(cx, cy, half_length, angle):
dx, dy = half_length * math.cos(angle), half_length * math.sin(angle)
return (cx - dx, cy - dy), (cx + dx, cy + dy)
def has_clearance(candidate, accepted, clearance, skip_index=None):
"""Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate
for index, (other_start, other_end, other_radius) in enumerate(accepted):
if index == skip_index:
continue
limit = radius + other_radius + clearance
if (max(start[0], end[0]) + limit < min(other_start[0], other_end[0])
or max(other_start[0], other_end[0]) + limit < min(start[0], end[0])
or max(start[1], end[1]) + limit < min(other_start[1], other_end[1])
or max(other_start[1], other_end[1]) + limit < min(start[1], end[1])):
continue
if segments_distance_sq(start, end, other_start, other_end) < limit * limit:
return False
return True