改用相机网格生成笔画纹理
统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。 已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
@@ -232,12 +232,15 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa
|
|||||||
--vector-width-mm 164 --vector-height-mm 160
|
--vector-width-mm 164 --vector-height-mm 160
|
||||||
```
|
```
|
||||||
|
|
||||||
`stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash
|
`stroke-texture` exports black capsule strokes using the same camera grid,
|
||||||
manifest from existing color artwork, for example:
|
projection and UV output size as `texture_patch`. First generate the single-view
|
||||||
`orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a
|
texture job, then run `orisys-design stroke-texture sensors/finger-xynova/r01`.
|
||||||
`strokes` subdirectory; `--output-dir` selects another. Tuning lives in the
|
The stroke output is independent of the color artwork pixels. The revision's
|
||||||
revision's `stroke_pattern` block. Nominal stroke width is 0.11–0.28 mm;
|
`stroke_pattern` block controls the seed, width and length ratios, and orientation
|
||||||
verify vectors and printed dimensions before manufacturing.
|
mode; `--output-dir` selects a fresh destination. The command writes PNG/SVG/PDF,
|
||||||
|
resolved settings and a hash manifest. Two-view `--views` jobs are not supported.
|
||||||
|
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
|
||||||
|
measure the production SVG before manufacturing.
|
||||||
|
|
||||||
## Inspect printability (advisory)
|
## Inspect printability (advisory)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Run with Blender's source geometry and the existing texture job. This first
|
||||||
camera-space prototype preserves the colour pattern's grid and visibility
|
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 argparse
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
@@ -19,22 +19,41 @@ from camera_grid_stroke_optimizer import optimize_angles
|
|||||||
from patch_geometry import clip_rect
|
from patch_geometry import clip_rect
|
||||||
from patch_projection import build_patch_geometry
|
from patch_projection import build_patch_geometry
|
||||||
from random_patch_dd02c import (
|
from random_patch_dd02c import (
|
||||||
_clipped_polygons, _clipped_shapes, _prepare_mesh_object, _require_object,
|
_clipped_polygons,
|
||||||
_require_uv_layer, fill_background, fill_polygon, save_png_image,
|
_clipped_shapes,
|
||||||
write_pdf_vector, write_svg_vector,
|
_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():
|
def _args():
|
||||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--job", type=Path, required=True)
|
parser.add_argument("--job", type=Path, required=True)
|
||||||
parser.add_argument("--output-dir", 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("--params", type=Path, default=None)
|
||||||
parser.add_argument("--length-ratio", type=float, default=0.84)
|
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"),
|
parser.add_argument("--orientation-mode", choices=("random", "optimized"),
|
||||||
default="random", help="Use seeded random or experimental optimized angles")
|
default=None, help="Use seeded random or experimental optimized angles")
|
||||||
parser.add_argument("--proposals", type=int, default=100000,
|
parser.add_argument("--proposals", type=int, default=None,
|
||||||
help="Angle changes attempted in optimized mode")
|
help="Angle changes attempted in optimized mode")
|
||||||
return parser.parse_args(argv)
|
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,
|
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"]
|
width, height = settings["width"], settings["height"]
|
||||||
nx, ny = settings["nx"], settings["ny"]
|
nx, ny = settings["nx"], settings["ny"]
|
||||||
seed = settings.get("seed") or 0
|
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.pixels[:] = pixels
|
||||||
image.update()
|
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}"
|
base = output_dir / f"{label}_{uv_name}"
|
||||||
save_png_image(image, base.with_suffix(".png"))
|
save_png_image(image, base.with_suffix(".png"))
|
||||||
page_mesh = _clipped_polygons(mesh_polygons, width, height)
|
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():
|
def main():
|
||||||
args = _args()
|
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")
|
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"))
|
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"])
|
obj = _prepare_mesh_object(job["objects"]["mesh"])
|
||||||
cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera")
|
cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera")
|
||||||
names = [entry["uv_map"] for entry in job["outputs"]]
|
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.mkdir(parents=True, exist_ok=True)
|
||||||
args.output_dir = args.output_dir.resolve()
|
args.output_dir = args.output_dir.resolve()
|
||||||
angle_map = None
|
angle_map = None
|
||||||
if args.orientation_mode == "optimized":
|
if params["orientation_mode"] == "optimized":
|
||||||
angle_map, report = optimize_angles(
|
angle_map, report = optimize_angles(
|
||||||
settings["nx"], settings["ny"], settings.get("seed") or 0,
|
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")
|
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||||
print(f"Camera-grid optimization: {report}")
|
print(f"Camera-grid optimization: {report}")
|
||||||
geometry = build_patch_geometry(
|
geometry = build_patch_geometry(
|
||||||
@@ -123,7 +159,8 @@ def main():
|
|||||||
)
|
)
|
||||||
for name in names:
|
for name in names:
|
||||||
_render(name, geometry[name], settings, args.output_dir,
|
_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__":
|
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
|
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
{
|
{
|
||||||
"angle_span_deg": 180.0,
|
"length_ratio": 0.84,
|
||||||
"candidate_count": 12,
|
"orientation_mode": "random",
|
||||||
"clearance_mm": 0.03,
|
"proposals": 100000,
|
||||||
"coverage_cap": 0.45,
|
|
||||||
"jitter_fraction": 0.66,
|
|
||||||
"length_ratio": 2.4,
|
|
||||||
"min_cell_pixels": 20,
|
|
||||||
"neighbourhood_mm": 0.5,
|
|
||||||
"seed": 7,
|
"seed": 7,
|
||||||
"target_coverage": 0.5,
|
"width_ratio": 0.42
|
||||||
"width_fraction": 0.5,
|
|
||||||
"width_max_mm": 0.28,
|
|
||||||
"width_min_mm": 0.2
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
@@ -1,94 +1,64 @@
|
|||||||
{
|
{
|
||||||
"schema": "orisys-sensor-design/stroke-texture-manifest-v1",
|
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
|
||||||
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
|
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
|
||||||
"source_job": {
|
"source_job": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\_texture_views_job.json",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\_texture_job.json",
|
||||||
"sha256": "7259df462de85a86ec575a6296592bf78c008d44ae531d7efd233118686b194f"
|
"sha256": "1d9d9bf605cc95b1f8375438e944e1ff4f2f8553d1793784de50a6f6ef64de77"
|
||||||
},
|
},
|
||||||
"source_blend": {
|
"source_blend": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
|
||||||
"sha256": "2a48957a88c760775582e4dc4780d98bf9fc94348918cff8efd82e840217530c"
|
"sha256": "2a48957a88c760775582e4dc4780d98bf9fc94348918cff8efd82e840217530c"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"mode": "camera_compensated_width",
|
"mode": "camera_grid_capsules",
|
||||||
"seed": 7,
|
|
||||||
"camera_width_fraction": 0.5,
|
|
||||||
"resolved": {
|
"resolved": {
|
||||||
"seed": 7,
|
"seed": 7,
|
||||||
"min_cell_pixels": 20,
|
"width_ratio": 0.42,
|
||||||
"jitter_fraction": 0.66,
|
"length_ratio": 0.84,
|
||||||
"angle_span_deg": 180.0,
|
"orientation_mode": "random",
|
||||||
"width_fraction": 0.5,
|
"proposals": 100000
|
||||||
"width_min_mm": 0.2,
|
|
||||||
"width_max_mm": 0.28,
|
|
||||||
"length_ratio": 2.4,
|
|
||||||
"coverage_cap": 0.45,
|
|
||||||
"neighbourhood_mm": 0.5,
|
|
||||||
"clearance_mm": 0.03,
|
|
||||||
"candidate_count": 12,
|
|
||||||
"target_coverage": 0.5
|
|
||||||
},
|
},
|
||||||
"design_yaml": {
|
"design_yaml": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
|
||||||
"sha256": "f6d2536f66256ff4c0a6b210d9dbab7b1c0c9d263149171f4da3298a22c4166c"
|
"sha256": "711ad69649ef0d771e8f68295bea129af0f13c0e003d9aaf361e25ea5ab8c34e"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
|
||||||
"sha256": "35498f3b08aa93f7e89a7f27080ec0655997b36f69fd8da30002e5d34df6083f"
|
"sha256": "823639aa72706b1d47d0c531e704cb99f36517e87fcad7de707417cb474e6993"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"id": "uv_1",
|
"id": "produce",
|
||||||
"sources": {
|
|
||||||
"png": {
|
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
|
|
||||||
"sha256": "719e675395b158e30fc6310b7ba17c573d6347c0b1234c788876396a7cce3c29"
|
|
||||||
},
|
|
||||||
"svg": {
|
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
|
|
||||||
"sha256": "98319674337cc7e9a4cbc547d0da1fc79b282067d0fd1471c5371c59d4e50532"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"png": {
|
"png": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.png",
|
||||||
"sha256": "a00cee4113337fae6ccddd9213b832bd5a1756c78c7e23bf0caabfeccda680e6"
|
"sha256": "a748d1963825310458ab3690a75986f75bffcfe2aab1c936b93714a38408437b"
|
||||||
},
|
},
|
||||||
"svg": {
|
"svg": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.svg",
|
||||||
"sha256": "d880e71c55daa842757f5a558227c715b45c41d9e980ec4d056749eb21cd3d9f"
|
"sha256": "b53b5c99eeac07399fac2167b63e938df4ee36bd9029d70faaca9ef945a09880"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_produce.pdf",
|
||||||
"sha256": "6cc5910f86b687a0334829b23decf3fd5d9166378ab05e3a93cbaca24278c92c"
|
"sha256": "b8978a4f893f475124434e42fd56739ef0807bc9769f48993883303f9395c272"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uv_2",
|
"id": "compute",
|
||||||
"sources": {
|
|
||||||
"png": {
|
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
|
|
||||||
"sha256": "8b1a7dc6e24820c90e604483fe515e93e9a8daddcb51e604e5d14ea47eed524a"
|
|
||||||
},
|
|
||||||
"svg": {
|
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
|
|
||||||
"sha256": "57f1cc3dab59fd19ad99a2bd19fe1233414b2bcdbcf8e25c0698da09a61384b3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"png": {
|
"png": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.png",
|
||||||
"sha256": "ff75bba350475e4ae01cc841700e16b601e5a6fd06708597536ceb7bf8700ae6"
|
"sha256": "4f761f166328f2c9dab9469f33f7509c9dd6238df3b8a4bdac48e2aa52dc362b"
|
||||||
},
|
},
|
||||||
"svg": {
|
"svg": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.svg",
|
||||||
"sha256": "511b324431b0d95dc6bbf66709f3221e33dfc3a34e6416f3d24c490e3c79d1cc"
|
"sha256": "192c13019257579a7e3bca3ddcbef16ef59cfc1a5d5c19ff3a8fa26c3b8ca041"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_compute.pdf",
|
||||||
"sha256": "b1123696d80a03b2f257f6ad397d96a1b2d9416039832df33e9d7b40a455528e"
|
"sha256": "2fa788f4e3da05bbef4a267fafd96beb730727183b7bf4ed95f2b6c569bfba09"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 702 KiB |
|
Before Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 705 KiB |
|
Before Width: | Height: | Size: 934 KiB |
@@ -88,27 +88,15 @@ texture_patch_views:
|
|||||||
print:
|
print:
|
||||||
base_fill: owned # white ground, already split by the seam
|
base_fill: owned # white ground, already split by the seam
|
||||||
|
|
||||||
# Random Stroke Pattern (`orisys-design stroke-texture`).
|
# Camera-grid capsule strokes (`orisys-design stroke-texture`). Grid, camera projection,
|
||||||
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera
|
# UV maps, image size and vector page come from the texture_patch job above.
|
||||||
# cell's print-page footprint so a cell the projection stretches gets a proportionally
|
# Ratios are fractions of each projected cell. Physical minimum width is not enforced.
|
||||||
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
|
|
||||||
# Field reference: the inline comments below. This is an image-space experiment, not a
|
|
||||||
# print master. The resolved values are written to _stroke_pattern_params.json and
|
|
||||||
# recorded in the stroke-texture manifest, which is what makes a run reproducible.
|
|
||||||
stroke_pattern:
|
stroke_pattern:
|
||||||
seed: 7 # per-cell offset and angle; reproducible
|
seed: 7
|
||||||
min_cell_pixels: 20 # cells smaller than this are skipped
|
width_ratio: 0.42
|
||||||
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre
|
length_ratio: 0.84
|
||||||
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel
|
orientation_mode: random
|
||||||
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm)
|
proposals: 100000 # used only in optimized mode
|
||||||
width_min_mm: 0.20
|
|
||||||
width_max_mm: 0.28
|
|
||||||
length_ratio: 2.4 # length = ratio x width
|
|
||||||
coverage_cap: 0.45 # initial placement limit; endpoint growth uses target_coverage
|
|
||||||
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
|
|
||||||
clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page
|
|
||||||
candidate_count: 12 # try alternatives before skipping a cell
|
|
||||||
target_coverage: 0.50 # extend accepted strokes into free space, without overlap
|
|
||||||
|
|
||||||
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
||||||
# Millimetres are measured on the vector page declared above.
|
# Millimetres are measured on the vector page declared above.
|
||||||
|
|||||||
@@ -94,25 +94,14 @@ texture_patch_views:
|
|||||||
print:
|
print:
|
||||||
base_fill: owned # white ground, already split by the seam
|
base_fill: owned # white ground, already split by the seam
|
||||||
|
|
||||||
# Random Stroke Pattern (`orisys-design stroke-texture`).
|
# Camera-grid capsule strokes (`orisys-design stroke-texture`). Geometry and output size
|
||||||
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera
|
# follow texture_patch. This mode currently accepts single-view jobs only.
|
||||||
# cell's print-page footprint so a cell the projection stretches gets a proportionally
|
|
||||||
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
|
|
||||||
# Field reference: the inline comments below. These defaults are an image-space experiment,
|
|
||||||
# not a print master: `width_min_mm: 0.11` is below the demonstrated 0.2 mm dot (see
|
|
||||||
# docs/probe-findings.md). The resolved values are written to _stroke_pattern_params.json and
|
|
||||||
# recorded in the stroke-texture manifest, which is what makes a run reproducible.
|
|
||||||
stroke_pattern:
|
stroke_pattern:
|
||||||
seed: 7 # per-cell offset and angle; reproducible
|
seed: 7
|
||||||
min_cell_pixels: 20 # cells smaller than this are skipped
|
width_ratio: 0.42
|
||||||
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre
|
length_ratio: 0.84
|
||||||
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel
|
orientation_mode: random
|
||||||
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm)
|
proposals: 100000
|
||||||
width_min_mm: 0.11
|
|
||||||
width_max_mm: 0.28
|
|
||||||
length_ratio: 2.4 # length = ratio x width
|
|
||||||
coverage_cap: 0.45 # skip a mark that would exceed this local fill
|
|
||||||
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
|
|
||||||
|
|
||||||
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
||||||
# Millimetres are measured on the vector page declared above.
|
# Millimetres are measured on the vector page declared above.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -28,12 +27,6 @@ from orisys_sensor_design.manifest import (
|
|||||||
write_export_job,
|
write_export_job,
|
||||||
write_manifest,
|
write_manifest,
|
||||||
)
|
)
|
||||||
from orisys_sensor_design.stroke_pattern import (
|
|
||||||
STROKE_MODE,
|
|
||||||
StrokePatternConfigError,
|
|
||||||
resolve_stroke_settings,
|
|
||||||
write_stroke_params,
|
|
||||||
)
|
|
||||||
from orisys_sensor_design.printability import (
|
from orisys_sensor_design.printability import (
|
||||||
REPORT_FILENAME,
|
REPORT_FILENAME,
|
||||||
PrintabilityConfigError,
|
PrintabilityConfigError,
|
||||||
@@ -42,6 +35,12 @@ from orisys_sensor_design.printability import (
|
|||||||
write_csv,
|
write_csv,
|
||||||
write_report,
|
write_report,
|
||||||
)
|
)
|
||||||
|
from orisys_sensor_design.stroke_pattern import (
|
||||||
|
STROKE_MODE,
|
||||||
|
StrokePatternConfigError,
|
||||||
|
resolve_stroke_settings,
|
||||||
|
write_stroke_params,
|
||||||
|
)
|
||||||
from orisys_sensor_design.texture import (
|
from orisys_sensor_design.texture import (
|
||||||
TextureConfigError,
|
TextureConfigError,
|
||||||
build_texture_job,
|
build_texture_job,
|
||||||
@@ -479,25 +478,25 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace):
|
def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace):
|
||||||
"""Validate the existing colour job shared by stroke preview and export."""
|
"""Validate the single-view patch geometry job used by the stroke generator."""
|
||||||
|
if args.views:
|
||||||
|
raise ValueError("camera-grid strokes require a single-view job; --views is unsupported")
|
||||||
texture_dir = resolve_texture_output_dir(root, paths, None)
|
texture_dir = resolve_texture_output_dir(root, paths, None)
|
||||||
job_name = "_texture_views_job.json" if args.views else "_texture_job.json"
|
job_path = (args.source_job or texture_dir / "_texture_job.json").resolve()
|
||||||
job_path = (args.source_job or texture_dir / job_name).resolve()
|
|
||||||
if not job_path.is_file():
|
if not job_path.is_file():
|
||||||
raise FileNotFoundError(f"colour texture job missing: {job_path}")
|
raise FileNotFoundError(f"texture job missing: {job_path}")
|
||||||
job = json.loads(job_path.read_text(encoding="utf-8"))
|
job = json.loads(job_path.read_text(encoding="utf-8"))
|
||||||
expected_schema = (
|
expected_schema = "orisys-sensor-design/texture-job-v3"
|
||||||
"orisys-sensor-design/texture-job-multiview-v1"
|
|
||||||
if args.views else "orisys-sensor-design/texture-job-v3"
|
|
||||||
)
|
|
||||||
if job.get("schema") != expected_schema:
|
if job.get("schema") != expected_schema:
|
||||||
raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}")
|
raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}")
|
||||||
if Path(job["revision_dir"]).resolve() != paths.root.resolve():
|
if Path(job["revision_dir"]).resolve() != paths.root.resolve():
|
||||||
raise ValueError("source job belongs to a different revision")
|
raise ValueError("source job belongs to a different revision")
|
||||||
if Path(job["source_blend"]).resolve() != paths.source_blend.resolve():
|
if Path(job["source_blend"]).resolve() != paths.source_blend.resolve():
|
||||||
raise ValueError("source job was built from a different Blender scene")
|
raise ValueError("source job was built from a different Blender scene")
|
||||||
entries = job["pairs" if args.views else "outputs"]
|
if not isinstance(job["settings"], dict) or not isinstance(job["objects"], dict):
|
||||||
source_ids = [str(entry["id" if args.views else "uv_map"]) for entry in entries]
|
raise ValueError("texture job needs settings and objects")
|
||||||
|
entries = job["outputs"]
|
||||||
|
source_ids = [str(entry["uv_map"]) for entry in entries]
|
||||||
if (not source_ids or len(source_ids) != len(set(source_ids))
|
if (not source_ids or len(source_ids) != len(set(source_ids))
|
||||||
or any(not source_id or not all(
|
or any(not source_id or not all(
|
||||||
char.isalnum() or char in "_-" for char in source_id
|
char.isalnum() or char in "_-" for char in source_id
|
||||||
@@ -507,7 +506,7 @@ def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespac
|
|||||||
|
|
||||||
|
|
||||||
def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
|
def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
|
||||||
"""Resolve the stroke tuning block, then validate the shared colour job."""
|
"""Resolve stroke tuning, then validate the shared patch geometry job."""
|
||||||
root = repo_root(revision_dir)
|
root = repo_root(revision_dir)
|
||||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
errors = validate_revision_metadata(paths)
|
errors = validate_revision_metadata(paths)
|
||||||
@@ -523,44 +522,31 @@ def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace):
|
|||||||
for message in str(exc).splitlines():
|
for message in str(exc).splitlines():
|
||||||
print(f"ERROR: {message}", file=sys.stderr)
|
print(f"ERROR: {message}", file=sys.stderr)
|
||||||
return None, 2
|
return None, 2
|
||||||
# The flags remain per-run overrides; `design.yaml` is the default they change, so a
|
overrides = {key: getattr(args, key, None) for key in
|
||||||
# tuning value lives with the part instead of in whoever's shell history ran it last.
|
("seed", "width_ratio", "length_ratio", "orientation_mode", "proposals")}
|
||||||
if args.camera_width_fraction is not None:
|
overrides = {key: value for key, value in overrides.items() if value is not None}
|
||||||
if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0:
|
try:
|
||||||
print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr)
|
settings = resolve_stroke_settings({**settings, **overrides})
|
||||||
return None, 2
|
except StrokePatternConfigError as exc:
|
||||||
settings["width_fraction"] = args.camera_width_fraction
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
# `_stroke_cli_context` is only reached from `stroke-texture`, which has this flag.
|
return None, 2
|
||||||
if args.seed is not None:
|
|
||||||
settings["seed"] = args.seed
|
|
||||||
try:
|
try:
|
||||||
source = _stroke_source_job(paths, root, args)
|
source = _stroke_source_job(paths, root, args)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
return None, 3
|
return None, 3
|
||||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr)
|
print(f"ERROR: invalid texture job: {exc}", file=sys.stderr)
|
||||||
return None, 2
|
return None, 2
|
||||||
return (paths, *source, settings), 0
|
return (paths, *source, settings), 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||||
"""Export one PNG/SVG/PDF stroke set per colour source and record provenance."""
|
"""Export camera-grid stroke artwork and record its geometry and tuning."""
|
||||||
context, code = _stroke_cli_context(revision_dir, args)
|
context, code = _stroke_cli_context(revision_dir, args)
|
||||||
if context is None:
|
if context is None:
|
||||||
return code
|
return code
|
||||||
paths, job_path, _job, entries, source_ids, settings = context
|
paths, job_path, _job, entries, source_ids, settings = context
|
||||||
for entry in entries:
|
|
||||||
for fmt in ("png", "svg"):
|
|
||||||
try:
|
|
||||||
source = Path(entry["artifacts"][fmt])
|
|
||||||
except (KeyError, TypeError) as exc:
|
|
||||||
print(f"ERROR: colour job has no {fmt.upper()} artifact: {exc}",
|
|
||||||
file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if not source.is_file() or source.stat().st_size == 0:
|
|
||||||
print(f"ERROR: source colour {fmt.upper()} missing: {source}", file=sys.stderr)
|
|
||||||
return 3
|
|
||||||
output_dir = (args.output_dir or job_path.parent / "strokes").resolve()
|
output_dir = (args.output_dir or job_path.parent / "strokes").resolve()
|
||||||
outputs = {
|
outputs = {
|
||||||
source_id: {
|
source_id: {
|
||||||
@@ -571,16 +557,15 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
}
|
}
|
||||||
manifest_path = output_dir / "stroke_texture_manifest.json"
|
manifest_path = output_dir / "stroke_texture_manifest.json"
|
||||||
temporary = manifest_path.with_suffix(".json.tmp")
|
temporary = manifest_path.with_suffix(".json.tmp")
|
||||||
if manifest_path.exists() or temporary.exists() or any(path.exists() for artifacts in outputs.values()
|
if manifest_path.exists() or temporary.exists() or (output_dir / "_stroke_pattern_params.json").exists() or any(path.exists() for artifacts in outputs.values()
|
||||||
for path in artifacts.values()):
|
for path in artifacts.values()):
|
||||||
print("ERROR: stroke artifacts already exist; choose a fresh --output-dir",
|
print("ERROR: stroke artifacts already exist; choose a fresh --output-dir",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
params_path = write_stroke_params(settings, output_dir)
|
params_path = write_stroke_params(settings, output_dir)
|
||||||
code = _run_blender_script(
|
code = _run_blender_script(
|
||||||
"random_stroke_pattern.py", paths.source_blend,
|
"camera_grid_stroke.py", paths.source_blend,
|
||||||
["--job", str(job_path), "--output-dir", str(output_dir),
|
["--job", str(job_path), "--output-dir", str(output_dir),
|
||||||
"--formats", "png", "svg", "pdf",
|
|
||||||
"--artifact-prefix", "stroke_texture", "--params", str(params_path)],
|
"--artifact-prefix", "stroke_texture", "--params", str(params_path)],
|
||||||
)
|
)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -592,18 +577,13 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
|
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
|
||||||
return 3
|
return 3
|
||||||
manifest = {
|
manifest = {
|
||||||
"schema": "orisys-sensor-design/stroke-texture-manifest-v1",
|
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
|
||||||
"revision_dir": str(paths.root),
|
"revision_dir": str(paths.root),
|
||||||
"source_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
|
"source_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
|
||||||
"source_blend": {"path": str(paths.source_blend),
|
"source_blend": {"path": str(paths.source_blend),
|
||||||
"sha256": sha256_file(paths.source_blend)},
|
"sha256": sha256_file(paths.source_blend)},
|
||||||
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
|
|
||||||
# overrides actually produced, because the flags alone no longer
|
|
||||||
# describe the run. The design.yaml and params hashes pin the tuning either way.
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"mode": STROKE_MODE,
|
"mode": STROKE_MODE,
|
||||||
"seed": settings["seed"],
|
|
||||||
"camera_width_fraction": settings["width_fraction"],
|
|
||||||
"resolved": dict(settings),
|
"resolved": dict(settings),
|
||||||
"design_yaml": {"path": str(paths.design_yaml),
|
"design_yaml": {"path": str(paths.design_yaml),
|
||||||
"sha256": sha256_file(paths.design_yaml)},
|
"sha256": sha256_file(paths.design_yaml)},
|
||||||
@@ -611,13 +591,10 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
},
|
},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{"id": source_id,
|
{"id": source_id,
|
||||||
"sources": {fmt: {"path": str(Path(entry["artifacts"][fmt])),
|
|
||||||
"sha256": sha256_file(Path(entry["artifacts"][fmt]))}
|
|
||||||
for fmt in ("png", "svg")},
|
|
||||||
"artifacts": {fmt: {"path": str(outputs[source_id][fmt]),
|
"artifacts": {fmt: {"path": str(outputs[source_id][fmt]),
|
||||||
"sha256": sha256_file(outputs[source_id][fmt])}
|
"sha256": sha256_file(outputs[source_id][fmt])}
|
||||||
for fmt in ("png", "svg", "pdf")}}
|
for fmt in ("png", "svg", "pdf")}}
|
||||||
for source_id, entry in zip(source_ids, entries)
|
for source_id in source_ids
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
@@ -2274,29 +2251,29 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
|
|
||||||
stroke_p = sub.add_parser(
|
stroke_p = sub.add_parser(
|
||||||
"stroke-texture",
|
"stroke-texture",
|
||||||
help="Export Random Stroke Pattern PNG/SVG/PDF from colour artwork",
|
help="Export camera-grid capsule strokes from a texture geometry job",
|
||||||
)
|
)
|
||||||
stroke_p.add_argument("revision_dir", type=Path)
|
stroke_p.add_argument("revision_dir", type=Path)
|
||||||
stroke_p.add_argument(
|
stroke_p.add_argument(
|
||||||
"--views", action="store_true",
|
"--views", action="store_true",
|
||||||
help="Use the existing two-view colour job and preserve its print ownership",
|
help="Unsupported for camera-grid strokes; single-view jobs only",
|
||||||
)
|
)
|
||||||
stroke_p.add_argument(
|
stroke_p.add_argument(
|
||||||
"--source-job", type=Path, default=None,
|
"--source-job", type=Path, default=None,
|
||||||
help="Existing colour job (default: revision build/textures job)",
|
help="Existing texture geometry job (default: revision build/textures job)",
|
||||||
)
|
)
|
||||||
stroke_p.add_argument(
|
stroke_p.add_argument(
|
||||||
"--output-dir", type=Path, default=None,
|
"--output-dir", type=Path, default=None,
|
||||||
help="Fresh output directory (default: strokes beside the colour job)",
|
help="Fresh output directory (default: strokes beside the texture job)",
|
||||||
)
|
)
|
||||||
stroke_p.add_argument(
|
stroke_p.add_argument(
|
||||||
"--seed", type=int, default=None,
|
"--seed", type=int, default=None,
|
||||||
help="Override stroke_pattern.seed for this run",
|
help="Override stroke_pattern.seed for this run",
|
||||||
)
|
)
|
||||||
stroke_p.add_argument(
|
stroke_p.add_argument("--width-ratio", type=float, default=None)
|
||||||
"--camera-width-fraction", type=float, default=None,
|
stroke_p.add_argument("--length-ratio", type=float, default=None)
|
||||||
help="Override stroke_pattern.width_fraction for this run",
|
stroke_p.add_argument("--orientation-mode", choices=("random", "optimized"), default=None)
|
||||||
)
|
stroke_p.add_argument("--proposals", type=int, default=None)
|
||||||
|
|
||||||
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
|
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
|
||||||
manifest_p.add_argument("revision_dir", type=Path)
|
manifest_p.add_argument("revision_dir", type=Path)
|
||||||
|
|||||||
@@ -1,162 +1,72 @@
|
|||||||
"""Tuning for the Random Stroke Pattern: the `stroke_pattern` block in `design.yaml`.
|
"""Configuration for camera-grid capsule strokes generated from a texture job."""
|
||||||
|
|
||||||
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
|
|
||||||
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
|
|
||||||
footprint on the print page — so a cell the projection stretches gets a proportionally larger
|
|
||||||
mark — then clamped. This module owns the live knobs, their defaults and
|
|
||||||
ranges on the host side.
|
|
||||||
|
|
||||||
Why the keys are exactly these
|
|
||||||
------------------------------
|
|
||||||
Everything else in the generator is either derived or dead:
|
|
||||||
|
|
||||||
* ``formats`` is **not** a knob. ``stroke-texture`` fixes its artifact set at png+svg+pdf,
|
|
||||||
because that set is part of the command's contract; a yaml value could only disagree with it.
|
|
||||||
* The generator's old per-cell size scale is **dead for this mode** — only the deleted
|
|
||||||
dashed/dotted modes read it — so it is gone rather than exposed. No key here can be inert.
|
|
||||||
|
|
||||||
Precedence is ``these defaults < design.yaml < CLI flag``, matching how the texture commands
|
|
||||||
already layer the revision block under their flags.
|
|
||||||
|
|
||||||
The Blender script keeps its own copy of these names and defaults, because it runs under Blender
|
|
||||||
while this module is stdlib-only — the same split as the ``::report::`` prefix constants. A test
|
|
||||||
pins the two copies against each other, so drift fails rather than silently changing behaviour.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any
|
||||||
|
|
||||||
#: The only mode the generator implements. Recorded in the job so a run is self-describing.
|
STROKE_MODE = "camera_grid_capsules"
|
||||||
STROKE_MODE = "camera_compensated_width"
|
|
||||||
|
|
||||||
#: Every knob, at the values the experiment shipped with. Mirror of
|
|
||||||
#: ``random_stroke_pattern.DEFAULT_PARAMS``; a test asserts they agree.
|
|
||||||
STROKE_DEFAULTS: dict[str, Any] = {
|
STROKE_DEFAULTS: dict[str, Any] = {
|
||||||
"seed": 7,
|
"seed": 7,
|
||||||
"min_cell_pixels": 20,
|
"width_ratio": 0.42,
|
||||||
"jitter_fraction": 0.66,
|
"length_ratio": 0.84,
|
||||||
"angle_span_deg": 180.0,
|
"orientation_mode": "random",
|
||||||
"width_fraction": 0.5,
|
"proposals": 100000,
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
|
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
|
||||||
|
STROKE_INT_KEYS = frozenset({"seed", "proposals"})
|
||||||
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
|
|
||||||
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels", "candidate_count"})
|
|
||||||
|
|
||||||
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
|
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
|
||||||
|
|
||||||
|
|
||||||
class StrokePatternConfigError(ValueError):
|
class StrokePatternConfigError(ValueError):
|
||||||
"""The `stroke_pattern` block cannot produce artwork."""
|
"""The stroke settings cannot produce artwork."""
|
||||||
|
|
||||||
|
|
||||||
def validate_stroke_block(block: Any) -> list[str]:
|
def validate_stroke_block(block: Any) -> list[str]:
|
||||||
"""Return every reason this block is unusable; empty when it is fine.
|
"""Validate the subset of camera-grid stroke settings provided by a revision."""
|
||||||
|
|
||||||
Strict about unknown keys on purpose: the generator would ignore a misspelled knob and the
|
|
||||||
run would look successful while the value did nothing.
|
|
||||||
"""
|
|
||||||
if block is None:
|
if block is None:
|
||||||
return []
|
return []
|
||||||
where = "stroke_pattern"
|
|
||||||
if not isinstance(block, Mapping):
|
if not isinstance(block, Mapping):
|
||||||
return [f"{where}: must be a mapping"]
|
return ["stroke_pattern: must be a mapping"]
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
extra = sorted(set(block) - STROKE_KEYS)
|
extra = sorted(set(block) - STROKE_KEYS)
|
||||||
if extra:
|
if extra:
|
||||||
errors.append(
|
errors.append(f"stroke_pattern: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}")
|
||||||
f"{where}: unknown key(s) {extra}; allowed: {sorted(STROKE_KEYS)}"
|
for key in STROKE_INT_KEYS & block.keys():
|
||||||
)
|
value = block[key]
|
||||||
for key, value in block.items():
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
if key not in STROKE_KEYS:
|
errors.append(f"stroke_pattern.{key}: must be an integer")
|
||||||
continue
|
for key in ("width_ratio", "length_ratio"):
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
if key in block:
|
||||||
errors.append(f"{where}.{key}: must be a number (got {value!r})")
|
value = block[key]
|
||||||
continue
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||||
if key in STROKE_INT_KEYS and not isinstance(value, int):
|
errors.append(f"stroke_pattern.{key}: must be a finite number")
|
||||||
errors.append(f"{where}.{key}: must be an integer (got {value!r})")
|
if "orientation_mode" in block and block["orientation_mode"] not in ("random", "optimized"):
|
||||||
continue
|
errors.append("stroke_pattern.orientation_mode: must be random or optimized")
|
||||||
if value != value or value in (float("inf"), float("-inf")):
|
|
||||||
errors.append(f"{where}.{key}: must be finite (got {value!r})")
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return errors
|
return errors
|
||||||
|
merged = {**STROKE_DEFAULTS, **block}
|
||||||
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
|
if merged["proposals"] < 1:
|
||||||
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
|
errors.append("stroke_pattern.proposals: must be >= 1")
|
||||||
if "candidate_count" in block and block["candidate_count"] < 1:
|
if not 0 < merged["width_ratio"] <= merged["length_ratio"] < 1:
|
||||||
errors.append(f"{where}.candidate_count: must be >= 1 (got {block['candidate_count']!r})")
|
errors.append("stroke_pattern: require 0 < width_ratio <= length_ratio < 1")
|
||||||
if "clearance_mm" in block and block["clearance_mm"] < 0:
|
|
||||||
errors.append(f"{where}.clearance_mm: must be >= 0 (got {block['clearance_mm']!r})")
|
|
||||||
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
|
|
||||||
errors.append(
|
|
||||||
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
|
|
||||||
)
|
|
||||||
if "angle_span_deg" in block and block["angle_span_deg"] < 0.0:
|
|
||||||
errors.append(
|
|
||||||
f"{where}.angle_span_deg: must be >= 0 (got {block['angle_span_deg']!r})"
|
|
||||||
)
|
|
||||||
for key in ("width_fraction", "width_min_mm", "width_max_mm", "length_ratio",
|
|
||||||
"neighbourhood_mm"):
|
|
||||||
if key in block and block[key] <= 0.0:
|
|
||||||
errors.append(f"{where}.{key}: must be > 0 (got {block[key]!r})")
|
|
||||||
if "coverage_cap" in block and not 0.0 < block["coverage_cap"] <= 1.0:
|
|
||||||
errors.append(
|
|
||||||
f"{where}.coverage_cap: must be within (0, 1] (got {block['coverage_cap']!r})"
|
|
||||||
)
|
|
||||||
if "target_coverage" in block and not 0.0 <= block["target_coverage"] < 1.0:
|
|
||||||
errors.append(
|
|
||||||
f"{where}.target_coverage: must be within [0, 1) (got {block['target_coverage']!r})"
|
|
||||||
)
|
|
||||||
merged = {**STROKE_DEFAULTS, **{k: v for k, v in block.items() if k in STROKE_KEYS}}
|
|
||||||
if merged["width_min_mm"] > merged["width_max_mm"]:
|
|
||||||
errors.append(
|
|
||||||
f"{where}: width_min_mm {merged['width_min_mm']:g} must be <= width_max_mm "
|
|
||||||
f"{merged['width_max_mm']:g}, or the clamp inverts and every mark takes the minimum"
|
|
||||||
)
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def resolve_stroke_settings(block: Any) -> dict[str, Any]:
|
def resolve_stroke_settings(block: Any) -> dict[str, Any]:
|
||||||
"""The block merged over the defaults, or raise naming every problem."""
|
"""Merge a revision's settings over the generator defaults."""
|
||||||
errors = validate_stroke_block(block)
|
errors = validate_stroke_block(block)
|
||||||
if errors:
|
if errors:
|
||||||
raise StrokePatternConfigError("\n".join(errors))
|
raise StrokePatternConfigError("\n".join(errors))
|
||||||
settings = dict(STROKE_DEFAULTS)
|
return {**STROKE_DEFAULTS, **(block or {})}
|
||||||
if isinstance(block, Mapping):
|
|
||||||
settings.update({key: block[key] for key in STROKE_KEYS if key in block})
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def write_stroke_params(settings: Mapping[str, Any], output_dir: Path) -> Path:
|
def write_stroke_params(settings: Mapping[str, Any], output_dir: Path) -> Path:
|
||||||
"""Write the resolved tuning beside the artifacts, so a run records what it used."""
|
"""Write resolved settings next to generated artwork."""
|
||||||
output_dir = Path(output_dir)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
path = output_dir / STROKE_PARAMS_FILENAME
|
path = output_dir / STROKE_PARAMS_FILENAME
|
||||||
path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(dict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"STROKE_DEFAULTS",
|
|
||||||
"STROKE_INT_KEYS",
|
|
||||||
"STROKE_KEYS",
|
|
||||||
"STROKE_MODE",
|
|
||||||
"STROKE_PARAMS_FILENAME",
|
|
||||||
"StrokePatternConfigError",
|
|
||||||
"resolve_stroke_settings",
|
|
||||||
"validate_stroke_block",
|
|
||||||
"write_stroke_params",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Check laser-mark clearance in physical page coordinates."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "blender" / "scripts"))
|
|
||||||
|
|
||||||
from stroke_geometry import has_clearance # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_crossing_capsules_are_rejected() -> None:
|
|
||||||
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
|
||||||
assert not has_clearance(((0, -0.2), (0, 0.2), 0.1), existing, 0.03)
|
|
||||||
|
|
||||||
|
|
||||||
def test_near_parallel_capsules_need_edge_spacing() -> None:
|
|
||||||
existing = [((-0.2, 0), (0.2, 0), 0.1)]
|
|
||||||
assert not has_clearance(((-0.2, 0.22), (0.2, 0.22), 0.1), existing, 0.03)
|
|
||||||
assert has_clearance(((-0.2, 0.24), (0.2, 0.24), 0.1), existing, 0.03)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disjoint_collinear_capsules_can_clear() -> None:
|
|
||||||
existing = [((0, 0), (0.2, 0), 0.1)]
|
|
||||||
assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_ignores_only_its_original_capsule() -> None:
|
|
||||||
original = ((0, 0), (0.2, 0), 0.1)
|
|
||||||
neighbour = ((0.6, 0), (0.8, 0), 0.1)
|
|
||||||
assert has_clearance(((-0.1, 0), (0.3, 0), 0.1), [original, neighbour], 0.03,
|
|
||||||
skip_index=0)
|
|
||||||
assert not has_clearance(((-0.1, 0), (0.4, 0), 0.1), [original, neighbour], 0.03,
|
|
||||||
skip_index=0)
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
"""Run with python -m pytest tests/test_stroke_pattern.py (no Blender needed).
|
"""Host and Blender stroke configuration must stay in sync."""
|
||||||
|
|
||||||
The stroke generator runs under Blender and this module does not, so the two copies of the
|
|
||||||
tuning names and defaults cannot be shared by import. These tests pin them against each other by
|
|
||||||
parsing the script, which is the only way drift can be caught rather than silently changing what
|
|
||||||
a run produces.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
@@ -15,177 +10,45 @@ import pytest
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from orisys_sensor_design import stroke_pattern
|
from orisys_sensor_design import stroke_pattern
|
||||||
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
from orisys_sensor_design.manifest import repo_root
|
||||||
|
|
||||||
SCRIPT = repo_root() / "blender" / "scripts" / "random_stroke_pattern.py"
|
SCRIPT = repo_root() / "blender" / "scripts" / "camera_grid_stroke.py"
|
||||||
|
|
||||||
|
|
||||||
def script_constant(name: str):
|
def _constant(name: str):
|
||||||
"""Read a module-level literal out of the Blender script without importing it.
|
|
||||||
|
|
||||||
The script imports `bpy` at module scope, so it cannot be imported here. Parsing keeps this
|
|
||||||
a real check on the file rather than on a copy of it.
|
|
||||||
"""
|
|
||||||
tree = ast.parse(SCRIPT.read_text(encoding="utf-8"))
|
tree = ast.parse(SCRIPT.read_text(encoding="utf-8"))
|
||||||
for node in tree.body:
|
for node in tree.body:
|
||||||
if isinstance(node, ast.Assign):
|
if isinstance(node, ast.Assign) and any(
|
||||||
targets = [t for t in node.targets if isinstance(t, ast.Name)]
|
isinstance(target, ast.Name) and target.id == name for target in node.targets
|
||||||
if any(t.id == name for t in targets):
|
):
|
||||||
return ast.literal_eval(node.value)
|
return ast.literal_eval(node.value)
|
||||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
raise AssertionError(name)
|
||||||
if node.target.id == name:
|
|
||||||
return ast.literal_eval(node.value)
|
|
||||||
raise AssertionError(f"{name} is not a module-level literal in {SCRIPT.name}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- drift against the generator
|
def test_generator_contract():
|
||||||
|
assert _constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
|
||||||
|
assert _constant("MODE") == stroke_pattern.STROKE_MODE
|
||||||
def test_defaults_match_the_generator() -> None:
|
|
||||||
"""The one test that matters: the host and the script must agree on every value."""
|
|
||||||
assert script_constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
|
|
||||||
|
|
||||||
|
|
||||||
def test_key_set_matches_the_generator() -> None:
|
|
||||||
assert set(script_constant("DEFAULT_PARAMS")) == set(stroke_pattern.STROKE_KEYS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mode_matches_the_generator() -> None:
|
|
||||||
assert script_constant("MODE") == stroke_pattern.STROKE_MODE
|
|
||||||
|
|
||||||
|
|
||||||
def test_declared_defaults_pass_our_own_validation() -> None:
|
|
||||||
"""A default the host would reject would make an untouched revision fail validation."""
|
|
||||||
assert stroke_pattern.validate_stroke_block(stroke_pattern.STROKE_DEFAULTS) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_script_no_longer_offers_other_modes() -> None:
|
|
||||||
"""Pins the simplification: reviving a second mode must be deliberate, not drift."""
|
|
||||||
assert script_constant("MODE") == "camera_compensated_width"
|
|
||||||
source = SCRIPT.read_text(encoding="utf-8")
|
|
||||||
for removed in ("PHYSICAL_WIDTH_MM", "camera_scaled_size", "--modes"):
|
|
||||||
assert removed not in source, f"{removed} should be gone after the mode reduction"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_host_cannot_import_the_generator() -> None:
|
|
||||||
"""Why the two copies exist: the script needs bpy, so the host may never import it."""
|
|
||||||
assert "import bpy" in SCRIPT.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------ validation
|
|
||||||
|
|
||||||
|
|
||||||
def test_absent_block_is_valid() -> None:
|
|
||||||
assert stroke_pattern.validate_stroke_block(None) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_block_must_be_a_mapping() -> None:
|
|
||||||
errors = stroke_pattern.validate_stroke_block(["seed"])
|
|
||||||
assert errors and "must be a mapping" in errors[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_key_is_rejected_by_name() -> None:
|
|
||||||
errors = stroke_pattern.validate_stroke_block({"width_fracton": 0.5})
|
|
||||||
assert any("width_fracton" in error for error in errors)
|
|
||||||
assert any("allowed" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_numeric_is_rejected() -> None:
|
|
||||||
errors = stroke_pattern.validate_stroke_block({"seed": "seven"})
|
|
||||||
assert any("must be a number" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_boolean_is_not_a_number() -> None:
|
|
||||||
"""`off`/`on` parse to booleans in YAML 1.1, which is a trap worth naming."""
|
|
||||||
errors = stroke_pattern.validate_stroke_block({"length_ratio": True})
|
|
||||||
assert any("must be a number" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("key", "value", "expected"),
|
|
||||||
[
|
|
||||||
("min_cell_pixels", 0, "must be >= 1"),
|
|
||||||
("jitter_fraction", 1.5, "within [0, 1]"),
|
|
||||||
("jitter_fraction", -0.1, "within [0, 1]"),
|
|
||||||
("angle_span_deg", -1.0, "must be >= 0"),
|
|
||||||
("width_fraction", 0.0, "must be > 0"),
|
|
||||||
("width_min_mm", -0.1, "must be > 0"),
|
|
||||||
("length_ratio", 0.0, "must be > 0"),
|
|
||||||
("coverage_cap", 0.0, "within (0, 1]"),
|
|
||||||
("coverage_cap", 1.5, "within (0, 1]"),
|
|
||||||
("neighbourhood_mm", 0.0, "must be > 0"),
|
|
||||||
("clearance_mm", -0.01, "must be >= 0"),
|
|
||||||
("candidate_count", 0, "must be >= 1"),
|
|
||||||
("target_coverage", -0.01, "within [0, 1)"),
|
|
||||||
("target_coverage", 1.0, "within [0, 1)"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_range_rules(key: str, value, expected: str) -> None:
|
|
||||||
errors = stroke_pattern.validate_stroke_block({key: value})
|
|
||||||
assert any(expected in error for error in errors), errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_must_be_an_integer() -> None:
|
|
||||||
errors = stroke_pattern.validate_stroke_block({"seed": 7.5})
|
|
||||||
assert any("integer" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_inverted_width_clamp_is_rejected_and_explains_itself() -> None:
|
|
||||||
"""min > max would make every mark take the minimum, silently."""
|
|
||||||
errors = stroke_pattern.validate_stroke_block(
|
|
||||||
{"width_min_mm": 0.4, "width_max_mm": 0.2}
|
|
||||||
)
|
|
||||||
assert any("clamp inverts" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_width_bounds_are_compared_after_merging_with_defaults() -> None:
|
|
||||||
"""Raising only the minimum must still be caught against the default maximum."""
|
|
||||||
errors = stroke_pattern.validate_stroke_block({"width_min_mm": 0.5})
|
|
||||||
assert any("clamp inverts" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------------- merge
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_block_yields_the_defaults() -> None:
|
|
||||||
assert stroke_pattern.resolve_stroke_settings(None) == stroke_pattern.STROKE_DEFAULTS
|
|
||||||
assert stroke_pattern.resolve_stroke_settings({}) == stroke_pattern.STROKE_DEFAULTS
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_block_keeps_its_siblings() -> None:
|
|
||||||
resolved = stroke_pattern.resolve_stroke_settings({"length_ratio": 4.0})
|
|
||||||
assert resolved["length_ratio"] == 4.0
|
|
||||||
assert resolved["width_fraction"] == stroke_pattern.STROKE_DEFAULTS["width_fraction"]
|
|
||||||
assert set(resolved) == set(stroke_pattern.STROKE_DEFAULTS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_block_raises_with_every_problem() -> None:
|
|
||||||
with pytest.raises(stroke_pattern.StrokePatternConfigError) as caught:
|
|
||||||
stroke_pattern.resolve_stroke_settings({"width_fraction": -1.0, "bogus": 1})
|
|
||||||
message = str(caught.value)
|
|
||||||
assert "width_fraction" in message and "bogus" in message
|
|
||||||
|
|
||||||
|
|
||||||
def test_written_params_round_trip(tmp_path: Path) -> None:
|
|
||||||
settings = stroke_pattern.resolve_stroke_settings({"angle_span_deg": 90.0})
|
|
||||||
path = stroke_pattern.write_stroke_params(settings, tmp_path / "out")
|
|
||||||
assert path.name == stroke_pattern.STROKE_PARAMS_FILENAME
|
|
||||||
assert json.loads(path.read_text(encoding="utf-8")) == settings
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------ revisions
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
||||||
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
|
def test_revision_settings(sensor):
|
||||||
"""The YAML records both legacy and Xynova manufacturing settings."""
|
path = repo_root() / "sensors" / sensor / "r01" / "design.yaml"
|
||||||
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
|
block = yaml.safe_load(path.read_text(encoding="utf-8"))["stroke_pattern"]
|
||||||
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
|
assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
|
||||||
block = design.get("stroke_pattern")
|
|
||||||
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
|
|
||||||
resolved = stroke_pattern.resolve_stroke_settings(block)
|
@pytest.mark.parametrize("block", [
|
||||||
expected = dict(stroke_pattern.STROKE_DEFAULTS)
|
{"width_min_mm": 0.2}, {"width_ratio": 0}, {"width_ratio": 0.9},
|
||||||
if sensor == "finger-xynova":
|
{"length_ratio": 1}, {"seed": 1.5}, {"proposals": 0},
|
||||||
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12,
|
{"orientation_mode": "unknown"},
|
||||||
target_coverage=0.50)
|
])
|
||||||
assert resolved == expected
|
def test_invalid_settings(block):
|
||||||
|
assert stroke_pattern.validate_stroke_block(block)
|
||||||
|
with pytest.raises(stroke_pattern.StrokePatternConfigError):
|
||||||
|
stroke_pattern.resolve_stroke_settings(block)
|
||||||
|
|
||||||
|
|
||||||
|
def test_params_round_trip(tmp_path: Path):
|
||||||
|
settings = stroke_pattern.resolve_stroke_settings({"seed": 13})
|
||||||
|
path = stroke_pattern.write_stroke_params(settings, tmp_path)
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8")) == settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The stroke CLI must preserve colour sources and verify all exported formats."""
|
"""The stroke CLI uses the texture geometry job and verifies its outputs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,96 +12,54 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
|
|||||||
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
||||||
|
|
||||||
|
|
||||||
def _args(job: Path, output_dir: Path) -> argparse.Namespace:
|
def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(
|
||||||
views=False,
|
views=views, source_job=job, output_dir=output_dir,
|
||||||
source_job=job,
|
width_ratio=0.4, length_ratio=0.8, orientation_mode=None,
|
||||||
output_dir=output_dir,
|
proposals=None, seed=13,
|
||||||
camera_width_fraction=0.5,
|
|
||||||
seed=13,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _job(tmp_path: Path) -> tuple[Path, Path, Path]:
|
def _job(tmp_path: Path) -> Path:
|
||||||
paths = RevisionPaths.from_revision_dir(REVISION)
|
paths = RevisionPaths.from_revision_dir(REVISION)
|
||||||
png = tmp_path / "colour.png"
|
job = tmp_path / "_texture_job.json"
|
||||||
svg = tmp_path / "colour.svg"
|
job.write_text(json.dumps({
|
||||||
png.write_bytes(b"colour pixels")
|
"schema": "orisys-sensor-design/texture-job-v3",
|
||||||
svg.write_bytes(b"<svg>colour geometry</svg>")
|
"revision_dir": str(paths.root), "source_blend": str(paths.source_blend),
|
||||||
job_path = tmp_path / "_texture_job.json"
|
"settings": {"nx": 80, "ny": 80},
|
||||||
job_path.write_text(
|
"objects": {"mesh": "sensing_surface", "camera": "cam_sensor"},
|
||||||
json.dumps(
|
"outputs": [{"uv_map": "compute"}],
|
||||||
{
|
}), encoding="utf-8")
|
||||||
"schema": "orisys-sensor-design/texture-job-v3",
|
return job
|
||||||
"revision_dir": str(paths.root),
|
|
||||||
"source_blend": str(paths.source_blend),
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"uv_map": "compute",
|
|
||||||
"artifacts": {"png": str(png), "svg": str(svg)},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return job_path, png, svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch):
|
def test_exports_from_geometry_job(tmp_path, monkeypatch):
|
||||||
job, png, svg = _job(tmp_path)
|
job = _job(tmp_path)
|
||||||
output_dir = tmp_path / "strokes"
|
output_dir = tmp_path / "strokes"
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def run_blender(script, blend, args):
|
def run_blender(script, blend, args):
|
||||||
calls.append((script, blend, args))
|
calls.append((script, blend, args))
|
||||||
# exist_ok: the CLI writes _stroke_pattern_params.json into the output directory before
|
|
||||||
# it launches Blender, so the directory already exists.
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for fmt in ("png", "svg", "pdf"):
|
for fmt in ("png", "svg", "pdf"):
|
||||||
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0
|
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0
|
||||||
assert calls[0][0] == "random_stroke_pattern.py"
|
assert calls[0][0] == "camera_grid_stroke.py"
|
||||||
# `--seed` and `--camera-width-fraction` are overrides folded into the params file, so the
|
params_path = Path(calls[0][2][calls[0][2].index("--params") + 1])
|
||||||
# contract is now the effective tuning the script reads.
|
params = json.loads(params_path.read_text(encoding="utf-8"))
|
||||||
params = json.loads(
|
|
||||||
Path(calls[0][2][calls[0][2].index("--params") + 1]).read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
assert params["seed"] == 13
|
assert params["seed"] == 13
|
||||||
assert params["width_fraction"] == 0.5
|
assert params["width_ratio"] == 0.4
|
||||||
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
||||||
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1"
|
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v2"
|
||||||
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
||||||
assert manifest["outputs"][0]["sources"]["png"]["sha256"] == sha256_file(png)
|
|
||||||
assert manifest["outputs"][0]["sources"]["svg"]["sha256"] == sha256_file(svg)
|
|
||||||
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
|
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
|
||||||
assert png.read_bytes() == b"colour pixels"
|
|
||||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
|
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_stroke_cli_refuses_missing_source_svg(tmp_path, monkeypatch):
|
def test_rejects_multiview(tmp_path, monkeypatch):
|
||||||
job, _png, svg = _job(tmp_path)
|
job = _job(tmp_path)
|
||||||
svg.unlink()
|
monkeypatch.setattr(cli, "_run_blender_script", lambda *_: 99)
|
||||||
monkeypatch.setattr(
|
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "out", views=True)) == 2
|
||||||
cli,
|
|
||||||
"_run_blender_script",
|
|
||||||
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
|
|
||||||
)
|
|
||||||
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_stroke_cli_rejects_incomplete_source_job(tmp_path, monkeypatch):
|
|
||||||
job, _png, _svg = _job(tmp_path)
|
|
||||||
data = json.loads(job.read_text(encoding="utf-8"))
|
|
||||||
del data["outputs"][0]["artifacts"]["svg"]
|
|
||||||
job.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
cli,
|
|
||||||
"_run_blender_script",
|
|
||||||
lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")),
|
|
||||||
)
|
|
||||||
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 2
|
|
||||||
|
|||||||