改用相机网格生成笔画纹理
统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。 已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
This commit is contained in:
@@ -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__":
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user