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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user