Give the stroke pattern one mode and design.yaml tuning

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.
This commit is contained in:
ykcheungab
2026-10-04 09:40:17 +08:00
parent 5bd7dd7a01
commit 30a903a299
9 changed files with 699 additions and 421 deletions
+267 -270
View File
@@ -5,26 +5,21 @@ Run after the colour texture command has produced its PNGs::
blender -b -t 2 --python-exit-code 1 \
-P blender/scripts/random_stroke_pattern.py -- \
--job build/textures/finger/r01/_texture_views_job.json \
--output-dir .scratch/binary-pattern-from-color/run-01
--output-dir .scratch/stroke-pattern/run-01
This is an image-space experiment, not a print master. It accepts either a single-view
texture job or a multiview job. Each distinct source colour is treated as one cell
within each PNG. A split cell can have a different centre in the two print views.
The medium- and dense-dash outputs test higher black coverage without changing
cell positions.
The physical-size outputs compare two stroke widths with a 0.2 mm-long,
0.1 mm-wide capsule. Clipping and merged neighbours can change the widths of
visible features.
The spaced 0.12 mm trial rejects clipped or colliding marks on each print page;
its selection can differ between the two UV views, so it is also preview-only.
The balanced trial permits overlaps but caps nearby black coverage.
The camera-scaled trial uses the source sensing-camera grid for a radial size
gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These are
nominal print-page sizes; clipped/merged ink still needs physical validation.
The camera-compensated trial scales width from each camera cell's print-page
footprint, bounded to 0.11–0.28 mm; larger far-field cells therefore receive
larger marks. The PNG and native SVG/PDF share accepted capsule geometry, but
image-space regions can be distorted. Verify dimensions before manufacturing.
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
@@ -45,39 +40,75 @@ if script_dir not in sys.path:
sys.path.insert(0, script_dir)
from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path
PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2}
MODES = (
"dots",
"dashes",
"medium_dashes",
"dense_dashes",
*PHYSICAL_WIDTH_MM,
"length_0p2_width_0p1mm",
"spaced_width_0p12mm",
"balanced_width_0p12mm",
"camera_scaled_width",
"camera_compensated_width",
)
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("--seed", type=int, default=7)
parser.add_argument("--min-cell-pixels", type=int, default=20)
parser.add_argument(
"--modes",
nargs="+",
choices=MODES,
default=MODES,
help="Generate only these preview variants",
"--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=0.5,
help="Compensated stroke width as a fraction of the local print-cell span",
default=None,
help="Override params.width_fraction for this run",
)
parser.add_argument(
"--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",)
@@ -85,23 +116,52 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument(
"--artifact-prefix",
default=None,
help="Name single-mode outputs <prefix>_<source-id>.<format>",
help="Name outputs <prefix>_<source-id>.<format>",
)
return parser.parse_args(argv)
def cell_code(rgb: np.ndarray, seed: int) -> tuple[float, float, float, float]:
"""Return bounded offsets, angle and size from one exact source colour."""
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 (
0.66 * (values[0] - 0.5),
0.66 * (values[1] - 0.5),
math.pi * values[2],
0.85 + 0.3 * values[3],
jitter_fraction * (values[0] - 0.5),
jitter_fraction * (values[1] - 0.5),
math.radians(angle_span_deg) * values[2],
)
@@ -186,22 +246,25 @@ def camera_cell_lookup(
return lookup
def camera_scaled_size(cell: tuple[int, int], nx: int, ny: int) -> tuple[float, float]:
"""Return nominal print width and length in mm from camera-grid position."""
i, j = cell
u = (i + 0.5) / nx - 0.5
v = (j + 0.5) / ny - 0.5
radius = min(1.0, math.hypot(u, v) / math.sqrt(0.5))
centre_weight = 1.0 - radius**2
return (0.11 + 0.05 * centre_weight, 0.24 + 0.14 * centre_weight)
def camera_compensated_size(
print_span_px: int, px_per_mm: float, width_fraction: float
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]:
"""Return nominal print dimensions from one projected camera-cell footprint."""
width_mm = max(0.11, min(0.28, print_span_px / px_per_mm * width_fraction))
return width_mm, 2.4 * width_mm
"""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(
@@ -227,66 +290,39 @@ def capsule_polygon(
def draw_mark(
canvas: np.ndarray,
region,
seed: int,
mode: str,
params: dict,
px_per_mm: float,
patterned: np.ndarray | None = None,
camera_cell: tuple[int, int] | None = None,
grid_shape: tuple[int, int] | None = None,
camera_width_fraction: float = 0.5,
vector_shapes: list | None = None,
) -> float:
"""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, scale = cell_code(np.asarray(rgb), seed)
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 mode == "camera_compensated_width":
if camera_cell is None:
raise ValueError(
"camera-compensated marks require an unambiguous camera cell"
)
width_mm, length_mm = camera_compensated_size(
span, px_per_mm, camera_width_fraction
if camera_cell is None:
raise ValueError(
"camera-compensated marks require an unambiguous camera cell"
)
radius = width_mm * px_per_mm / 2
half_length = (length_mm - width_mm) * px_per_mm / 2
elif mode == "camera_scaled_width":
if camera_cell is None or grid_shape is None:
raise ValueError("camera-scaled marks require an unambiguous camera cell")
width_mm, length_mm = camera_scaled_size(camera_cell, *grid_shape)
radius = width_mm * px_per_mm / 2
half_length = (length_mm - width_mm) * px_per_mm / 2
elif mode == "spaced_width_0p12mm":
radius = 0.12 * px_per_mm / 2
half_length = (0.30 - 0.12) * px_per_mm / 2
elif mode == "balanced_width_0p12mm":
radius = 0.12 * px_per_mm / 2
half_length = 2 * radius
elif mode == "length_0p2_width_0p1mm":
radius = 0.1 * px_per_mm / 2
half_length = (0.2 - 0.1) * px_per_mm / 2
elif mode in PHYSICAL_WIDTH_MM:
radius = PHYSICAL_WIDTH_MM[mode] * px_per_mm / 2
half_length = 2 * radius
else:
radius_fraction = {
"dots": 0.14,
"dashes": 0.14,
"medium_dashes": 0.16,
"dense_dashes": 0.18,
}[mode]
radius = max(1.0, radius_fraction * span * scale)
half_length_fraction = {
"dots": 0.0,
"dashes": 0.28,
"medium_dashes": 0.32,
"dense_dashes": 0.36,
}[mode]
half_length = half_length_fraction * span * scale
gap_px = 0.02 * px_per_mm if mode == "spaced_width_0p12mm" else 0.0
extent = radius + half_length + gap_px
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
@@ -310,24 +346,19 @@ def draw_mark(
if patterned is not None:
if np.any(footprint & ~patterned[y0:y1, x0:x1]):
return 0.0
if mode == "spaced_width_0p12mm":
clearance = distance_sq <= (radius + gap_px) ** 2
if np.any(canvas[y0:y1, x0:x1] & clearance):
return 0.0
else:
local_radius = round(0.5 * px_per_mm)
lx0 = max(0, round(centre_x) - local_radius)
lx1 = min(canvas.shape[1], round(centre_x) + local_radius + 1)
ly0 = max(0, round(centre_y) - local_radius)
ly1 = min(canvas.shape[0], round(centre_y) + local_radius + 1)
local_pattern = patterned[ly0:ly1, lx0:lx1]
local_area = np.count_nonzero(local_pattern)
if local_area == 0:
return 0.0
existing = np.count_nonzero(canvas[ly0:ly1, lx0:lx1] & local_pattern)
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
if (existing + added) / local_area > 0.45:
return 0.0
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(
@@ -459,34 +490,29 @@ def source_mesh_polygons(
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 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,
seed: int,
minimum: int,
modes: tuple[str, ...],
camera_width_fraction: float,
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 (
len(modes) != 1
or not artifact_prefix
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")
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, mode, artifact_prefix)}.{fmt}"
output_dir / f"{artifact_stem(source_id, artifact_prefix)}.{fmt}"
for source_id, _, _, _ in sources
for mode in modes
for fmt in formats
]
for target in targets:
@@ -503,7 +529,7 @@ def run(
)
surface = np.any(rgb != 0, axis=2)
patterned = surface & np.any(rgb != 255, axis=2)
regions = list(cell_regions(rgb, minimum))
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]))
@@ -512,147 +538,118 @@ def run(
f"({cell_span / px_per_mm:.3f} mm on the print page)",
flush=True,
)
lookup = None
grid_shape = None
camera_modes = ("camera_scaled_width", "camera_compensated_width")
if any(mode in modes for mode in camera_modes):
grid_shape = (int(settings["nx"]), int(settings["ny"]))
pair_seed = source_seed if source_seed is not None else settings.get("seed")
if pair_seed is None:
raise ValueError(
"camera-based preview requires a seeded colour palette"
)
lookup = camera_cell_lookup(
*grid_shape, float(settings["color_r"]), int(pair_seed)
)
missing = [
hex(region[0])
for region in regions
if region[0] != 0xFF0000 and region[0] not in lookup
]
if missing:
raise ValueError(
f"{source_id}: camera cell missing for "
f"{len(missing)} colours: {missing[:5]}"
)
ambiguous = [
hex(region[0])
for region in regions
if region[0] != 0xFF0000 and lookup[region[0]] is None
]
if ambiguous:
print(
f"{source_id}: skip {len(ambiguous)} ambiguous palette regions "
f"{ambiguous[:5]}",
flush=True,
)
for mode in modes:
marks = np.zeros((height, width), dtype=bool)
vector_shapes = [] if vector_page is not None else None
limit = (
patterned
if mode
in (
"spaced_width_0p12mm",
"balanced_width_0p12mm",
"camera_scaled_width",
"camera_compensated_width",
)
else None
)
mode_regions = [
region
for region in regions
if mode not in camera_modes
or (region[0] != 0xFF0000 and lookup[region[0]] is not None)
]
diameters = [
draw_mark(
marks,
region,
seed,
mode,
px_per_mm,
limit,
camera_cell=lookup[region[0]]
if lookup is not None and mode in camera_modes
else None,
grid_shape=grid_shape,
camera_width_fraction=camera_width_fraction,
vector_shapes=vector_shapes,
)
for region in mode_regions
]
stem = artifact_stem(source_id, mode, artifact_prefix)
if "png" in formats:
write_binary_png(output_dir / f"{stem}.png", marks, surface)
if vector_page is not None:
mesh_polygons, width_mm, height_mm = vector_page
if "svg" in formats:
write_svg_vector(
output_dir / f"{stem}.svg",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
source_id,
artwork_label="stroke",
)
if "pdf" in formats:
write_pdf_vector(
output_dir / f"{stem}.pdf",
width,
height,
width_mm,
height_mm,
mesh_polygons,
vector_shapes,
[],
(0.0, 0.0, 0.0, 1.0),
artwork_label="stroke",
)
accepted = [diameter for diameter in diameters if diameter > 0]
median_width = float(np.median(accepted)) if accepted else 0.0
physical_width = (
f", about {median_width / px_per_mm:.3f} mm on the print page"
if px_per_mm > 0
else ""
)
coverage = float(
np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
)
width_range = (
f", width range {min(accepted) / px_per_mm:.3f}–"
f"{max(accepted) / px_per_mm:.3f} mm"
if accepted
else ""
# 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}: {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}",
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)
if args.min_cell_pixels < 1:
raise ValueError("--min-cell-pixels must be positive")
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
raise ValueError("--camera-width-fraction must be finite and positive")
run(
args.job.resolve(),
args.output_dir.resolve(),
args.seed,
args.min_cell_pixels,
tuple(args.modes),
args.camera_width_fraction,
resolve_params(args),
tuple(args.formats),
args.artifact_prefix,
)