Add stroke texture PNG, SVG, and PDF export

Reuse colour artwork serializers and record source hashes. Verified single and multiview exports plus targeted tests.
This commit is contained in:
ykcheungab
2026-10-03 21:17:28 +08:00
parent 6b0694d6cb
commit 5bd7dd7a01
5 changed files with 452 additions and 66 deletions
+22 -15
View File
@@ -93,8 +93,6 @@ def fill_polygon(image, pixels, width, height, polygon, color):
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width-1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height-1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
@@ -125,8 +123,6 @@ def fill_polygon_value(grid, width, height, polygon, value):
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width - 1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height - 1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
@@ -679,10 +675,19 @@ def _clipped_shapes(shapes, width, height):
return clipped
def save_png_image(image, output_path):
"""Serialize an already-rasterized Blender image as PNG."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(path)
image.file_format = 'PNG'
image.save()
def write_svg_vector(
output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map,
background=None,
background=None, artwork_label="patch",
):
"""Write editable grouped SVG geometry; no raster data is embedded.
@@ -696,6 +701,9 @@ def write_svg_vector(
background_fill = "#" + "".join(
f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3]
)
if artwork_label not in ("patch", "stroke"):
raise ValueError(f"unsupported artwork label: {artwork_label!r}")
plural = "patches" if artwork_label == "patch" else "strokes"
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
(
@@ -705,10 +713,10 @@ def write_svg_vector(
f'viewBox="0 0 {texture_width} {texture_height}" '
f'preserveAspectRatio="none">'
),
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>",
f" <title>Orisys {artwork_label} texture — {_xml_escape(uv_map)} UV map</title>",
(
" <desc>Native vector geometry grouped as background, mesh, "
"camera-space patches, and reference cross.</desc>"
f"camera-space {plural}, and reference cross.</desc>"
),
(
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
@@ -728,10 +736,10 @@ def write_svg_vector(
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
)
)
lines.extend([" </g>", ' <g id="patches">'])
lines.extend([" </g>", f' <g id="{plural}">'])
for index, (polygon, color) in enumerate(patch_shapes, start=1):
lines.append(
_svg_polygon_element(f"patch-{index:04d}", polygon, color)
_svg_polygon_element(f"{artwork_label}-{index:04d}", polygon, color)
)
lines.extend([" </g>", ' <g id="reference-cross">'])
cross_attributes = _svg_fill_attributes(cross_color)
@@ -798,7 +806,7 @@ def _build_pdf(objects):
def write_pdf_vector(
output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color,
background=None,
background=None, artwork_label="patch",
):
"""Write native vector PDF paths at the configured physical page size.
@@ -824,7 +832,9 @@ def write_pdf_vector(
for polygon in mesh_polygons:
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
content.append("% camera-space patches")
if artwork_label not in ("patch", "stroke"):
raise ValueError(f"unsupported artwork label: {artwork_label!r}")
content.append(f"% camera-space {'patches' if artwork_label == 'patch' else 'strokes'}")
for polygon, color in patch_shapes:
content.append(_pdf_set_fill(color))
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
@@ -961,10 +971,7 @@ def generate_patch_texture(
image.pixels[:] = pixels
image.update()
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
save_png_image(image, out_path)
print(f"PNG raster texture saved to {out_path}")
uv_map_name = uv_layer.name if uv_layer is not None else "active"
+171 -15
View File
@@ -1,4 +1,4 @@
"""Generate Random Stroke Pattern (RSP) trials from colour texture jobs.
"""Generate Random Stroke Pattern (RSP) artwork from colour texture jobs.
Run after the colour texture command has produced its PNGs::
@@ -23,7 +23,8 @@ gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These
nominal print-page sizes; clipped/merged ink still needs physical validation.
The camera-compensated trial scales width from each camera cell's print-page
footprint, bounded to 0.11–0.28 mm; larger far-field cells therefore receive
larger marks. It remains a preview because image-space regions can be distorted.
larger marks. The PNG and native SVG/PDF share accepted capsule geometry, but
image-space regions can be distorted. Verify dimensions before manufacturing.
"""
from __future__ import annotations
@@ -33,11 +34,17 @@ import hashlib
import json
import math
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import bpy
import numpy as np
script_dir = str(Path(__file__).resolve().parent)
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path
PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2}
MODES = (
"dots",
@@ -72,6 +79,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
default=0.5,
help="Compensated stroke width as a fraction of the local print-cell span",
)
parser.add_argument(
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
)
parser.add_argument(
"--artifact-prefix",
default=None,
help="Name single-mode outputs <prefix>_<source-id>.<format>",
)
return parser.parse_args(argv)
@@ -189,6 +204,26 @@ def camera_compensated_size(
return width_mm, 2.4 * width_mm
def capsule_polygon(
centre_x: float, centre_y: float, radius: float, half_length: float, angle: float
) -> list[tuple[float, float]]:
"""Approximate the raster capsule boundary for native vector output."""
points = []
cosine, sine = math.cos(angle), math.sin(angle)
for side, start in ((1, -math.pi / 2), (-1, math.pi / 2)):
for index in range(25):
theta = start + index * math.pi / 24
local_x = side * half_length + radius * math.cos(theta)
local_y = radius * math.sin(theta)
points.append(
(
centre_x + local_x * cosine - local_y * sine,
centre_y + local_x * sine + local_y * cosine,
)
)
return points
def draw_mark(
canvas: np.ndarray,
region,
@@ -199,6 +234,7 @@ def draw_mark(
camera_cell: tuple[int, int] | None = None,
grid_shape: tuple[int, int] | None = None,
camera_width_fraction: float = 0.5,
vector_shapes: list | None = None,
) -> float:
colour, cell_x, cell_y, width, height = region
rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255)
@@ -293,6 +329,13 @@ def draw_mark(
if (existing + added) / local_area > 0.45:
return 0.0
canvas[y0:y1, x0:x1] |= footprint
if vector_shapes is not None:
vector_shapes.append(
(
capsule_polygon(centre_x, centre_y, radius, half_length, angle),
(0.0, 0.0, 0.0, 1.0),
)
)
return 2 * radius
@@ -305,14 +348,14 @@ def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None
try:
image.pixels.foreach_set(rgba.ravel())
image.update()
image.filepath_raw = str(path)
image.file_format = "PNG"
image.save()
save_png_image(image, path)
finally:
bpy.data.images.remove(image)
def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]:
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")
@@ -325,7 +368,14 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]:
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"]), entry.get("seed"))
(
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":
@@ -344,19 +394,75 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]:
"single-view page has unequal horizontal and vertical scales"
)
sources = [
(entry["uv_map"], Path(entry["artifacts"]["png"]), settings.get("seed"))
(
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, _ in sources:
for source_id, source, _svg, _seed in sources:
if not source_id or not all(
char.isalnum() or char in "_-" for char in source_id
):
raise ValueError(f"unsafe source ID: {source_id!r}")
if not source.is_file():
raise FileNotFoundError(f"run the colour generator first: {source}")
return sources, px_per_mm
def source_mesh_polygons(
svg_path: Path | None, width: int, height: int, px_per_mm: float
) -> tuple[list[list[tuple[float, float]]], float, float]:
"""Read the source artwork's owned white ground and physical page size."""
if svg_path is None or not svg_path.is_file():
raise FileNotFoundError(
f"source colour SVG is required for vectors: {svg_path}"
)
root = ET.parse(svg_path).getroot()
width_mm = float(root.attrib["width"].removesuffix("mm"))
height_mm = float(root.attrib["height"].removesuffix("mm"))
view_box = [float(value) for value in root.attrib["viewBox"].split()]
if (
len(view_box) != 4
or view_box[:2] != [0.0, 0.0]
or view_box[2:] != [float(width), float(height)]
or not math.isfinite(width_mm)
or not math.isfinite(height_mm)
or width_mm <= 0
or height_mm <= 0
or not math.isclose(width / width_mm, px_per_mm, rel_tol=0.002)
or not math.isclose(height / height_mm, px_per_mm, rel_tol=0.002)
):
raise ValueError(f"source SVG page does not match colour PNG/job: {svg_path}")
namespace = {"svg": "http://www.w3.org/2000/svg"}
mesh = root.find(".//svg:g[@id='mesh']", namespace)
if mesh is None:
raise ValueError(f"source SVG has no owned mesh group: {svg_path}")
polygons = []
for element in mesh.findall("svg:polygon", namespace):
polygon = [
tuple(float(value) for value in point.split(","))
for point in element.attrib["points"].split()
]
if len(polygon) >= 3:
polygons.append(polygon)
if not polygons:
raise ValueError(f"source SVG has no owned mesh polygons: {svg_path}")
return polygons, width_mm, height_mm
def artifact_stem(source_id: str, mode: str, prefix: str | None) -> str:
return f"{prefix}_{source_id}" if prefix else f"{source_id}_{mode}"
def run(
job_path: Path,
output_dir: Path,
@@ -364,21 +470,37 @@ def run(
minimum: int,
modes: tuple[str, ...],
camera_width_fraction: float,
formats: tuple[str, ...] = ("png",),
artifact_prefix: str | None = None,
) -> None:
job = json.loads(job_path.read_text(encoding="utf-8"))
sources, px_per_mm = job_sources(job)
if artifact_prefix is not None and (
len(modes) != 1
or not artifact_prefix
or not all(char.isalnum() or char in "_-" for char in artifact_prefix)
):
raise ValueError("artifact prefix requires one mode and a filename-safe prefix")
if not formats or len(formats) != len(set(formats)):
raise ValueError("formats must be non-empty and distinct")
targets = [
output_dir / f"{source_id}_{mode}.png"
for source_id, _, _ in sources
output_dir / f"{artifact_stem(source_id, mode, artifact_prefix)}.{fmt}"
for source_id, _, _, _ in sources
for mode in modes
for fmt in formats
]
for target in targets:
if target.exists():
raise FileExistsError(f"refusing to overwrite {target}")
output_dir.mkdir(parents=True, exist_ok=True)
settings = job.get("settings", {})
for source_id, source, source_seed in sources:
for source_id, source, source_svg, source_seed in sources:
rgb, width, height = read_rgb(source)
vector_page = (
source_mesh_polygons(source_svg, width, height, px_per_mm)
if "svg" in formats or "pdf" in formats
else None
)
surface = np.any(rgb != 0, axis=2)
patterned = surface & np.any(rgb != 255, axis=2)
regions = list(cell_regions(rgb, minimum))
@@ -426,6 +548,7 @@ def run(
)
for mode in modes:
marks = np.zeros((height, width), dtype=bool)
vector_shapes = [] if vector_page is not None else None
limit = (
patterned
if mode
@@ -456,11 +579,42 @@ def run(
else None,
grid_shape=grid_shape,
camera_width_fraction=camera_width_fraction,
vector_shapes=vector_shapes,
)
for region in mode_regions
]
target = output_dir / f"{source_id}_{mode}.png"
write_binary_png(target, marks, surface)
stem = artifact_stem(source_id, mode, artifact_prefix)
if "png" in formats:
write_binary_png(output_dir / f"{stem}.png", marks, surface)
if vector_page is not None:
mesh_polygons, width_mm, height_mm = vector_page
if "svg" in formats:
write_svg_vector(
output_dir / f"{stem}.svg",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
source_id,
artwork_label="stroke",
)
if "pdf" in formats:
write_pdf_vector(
output_dir / f"{stem}.pdf",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
artwork_label="stroke",
)
accepted = [diameter for diameter in diameters if diameter > 0]
median_width = float(np.median(accepted)) if accepted else 0.0
physical_width = (
@@ -480,7 +634,7 @@ def run(
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} -> {target}",
f"{median_width:.1f} px{physical_width}{width_range} -> {stem}",
flush=True,
)
@@ -499,6 +653,8 @@ def main() -> int:
args.min_cell_pixels,
tuple(args.modes),
args.camera_width_fraction,
tuple(args.formats),
args.artifact_prefix,
)
return 0