added multi-view support on full object texture

This commit is contained in:
ykcheungab
2026-09-28 10:47:00 +08:00
parent dd6eaefdf6
commit 88922f21ef
15 changed files with 345423 additions and 6 deletions
+343
View File
@@ -0,0 +1,343 @@
"""Multi-view production texture generator — step 1: one independent texture per view.
blender -b sensors/finger/r01/source/geometry.blend \\
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json>
Each *pair* in the job is ``(camera, uv_map, artifacts)`` and is generated exactly like a
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour
table is seeded, and the result is written into that UV layout. There is no ownership
partition, no seam handling and no merge — two pairs may cover the same surface region and
the overlap is deliberately left unresolved (see ``texture_views.py`` for why the merge has
to happen in surface space, and why it is a later step).
This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are
untouched, and the helpers below are imported from it rather than forked, so the raster,
SVG, PDF, clipping and colour code paths stay identical between the two entry points.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any
import bpy
import numpy as np
# Blender -P does not consistently include the script directory on sys.path.
_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 ( # noqa: E402
ConfigError,
SceneError,
_prepare_mesh_object,
_require_object,
_require_uv_layer,
_uv_centre,
build_patch_geometry,
generate_patch_texture,
precompute_cell_colors,
)
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
DEFAULT_MESH = "sensing_surface"
ARTIFACT_FORMATS = ("png", "svg", "pdf")
_DEFAULT_COLOR_R = 0.05
def _argv_after_double_dash() -> list[str]:
argv = sys.argv
if "--" in argv:
return argv[argv.index("--") + 1 :]
return []
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Multi-view colored-patch texture generator (job JSON only)"
)
parser.add_argument("--job", required=True, type=Path, help="path to the job JSON")
return parser.parse_args(argv)
def _load_job(path: Path) -> dict[str, Any]:
try:
with path.open(encoding="utf-8") as handle:
data = json.load(handle)
except OSError as exc:
raise ConfigError(f"cannot read job {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"job {path} is not valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise ConfigError(f"job {path} must be a JSON object")
return data
def _as_int(value: Any, label: str, minimum: int = 1) -> int:
if not isinstance(value, int) or isinstance(value, bool):
raise ConfigError(f"{label}: must be an integer (got {value!r})")
if value < minimum:
raise ConfigError(f"{label}: must be >= {minimum} (got {value!r})")
return int(value)
def _as_positive_number(value: Any, label: str) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise ConfigError(f"{label}: must be a number (got {value!r})")
if float(value) <= 0.0:
raise ConfigError(f"{label}: must be > 0 (got {value!r})")
return float(value)
def _as_color(value: Any, label: str) -> tuple[float, float, float, float]:
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
raise ConfigError(f"{label}: must be 3 or 4 numbers (got {value!r})")
channels = [float(v) for v in value]
if any(v < 0.0 or v > 1.0 for v in channels):
raise ConfigError(f"{label}: channels must be in [0, 1] (got {value!r})")
if len(channels) == 3:
channels.append(1.0)
return (channels[0], channels[1], channels[2], channels[3])
def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate every pair up front so a partial run is never produced."""
raw = job.get("pairs")
if not isinstance(raw, list) or not raw:
raise ConfigError("job 'pairs' must be a non-empty list")
pairs: list[dict[str, Any]] = []
seen_ids: set[str] = set()
seen_paths: dict[str, str] = {}
for index, entry in enumerate(raw):
where = f"pairs[{index}]"
if not isinstance(entry, dict):
raise ConfigError(f"{where}: must be a mapping")
pair_id = entry.get("id")
if not isinstance(pair_id, str) or not pair_id.strip():
raise ConfigError(f"{where}.id: must be a non-empty string")
if pair_id in seen_ids:
raise ConfigError(f"{where}.id: duplicate pair id {pair_id!r}")
seen_ids.add(pair_id)
camera = entry.get("camera")
if not isinstance(camera, str) or not camera.strip():
raise ConfigError(f"{where}.camera: must be a non-empty string")
uv_map = entry.get("uv_map")
if not isinstance(uv_map, str) or not uv_map.strip():
raise ConfigError(f"{where}.uv_map: must be a non-empty string")
artifacts = entry.get("artifacts")
if not isinstance(artifacts, dict):
raise ConfigError(f"{where}.artifacts: must be a mapping")
paths: dict[str, str] = {}
for format_name in ARTIFACT_FORMATS:
value = artifacts.get(format_name)
if not isinstance(value, str) or not value.strip():
raise ConfigError(
f"{where}.artifacts.{format_name}: must be a non-empty path"
)
key = str(Path(value)).lower()
if key in seen_paths:
raise ConfigError(
f"{where}.artifacts.{format_name}: {value!r} is also written by "
f"{seen_paths[key]}"
)
seen_paths[key] = f"{pair_id}/{format_name}"
paths[format_name] = value
seed = entry.get("seed")
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
raise ConfigError(f"{where}.seed: must be an integer or null")
pairs.append(
{
"id": pair_id,
"camera": camera,
"uv_map": uv_map,
"seed": None if seed is None else int(seed),
"artifacts": paths,
}
)
return pairs
def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
if job.get("schema") != SCHEMA:
raise ConfigError(
f"job schema must be {SCHEMA!r} (got {job.get('schema')!r})"
)
objects = job.get("objects") or {}
if not isinstance(objects, dict):
raise ConfigError("job 'objects' must be a mapping")
settings = job.get("settings") or {}
if not isinstance(settings, dict):
raise ConfigError("job 'settings' must be a mapping")
vector = settings.get("vector") or {}
if not isinstance(vector, dict):
raise ConfigError("job 'settings.vector' must be a mapping")
cross = settings.get("cross") or {}
if not isinstance(cross, dict):
raise ConfigError("job 'settings.cross' must be a mapping")
seed = settings.get("seed")
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})")
return {
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
"width": _as_int(settings.get("width", 2048), "settings.width"),
"height": _as_int(settings.get("height", 2048), "settings.height"),
"vector_width_mm": _as_positive_number(
vector.get("width_mm", 164.0), "settings.vector.width_mm"
),
"vector_height_mm": _as_positive_number(
vector.get("height_mm", 160.0), "settings.vector.height_mm"
),
"nx": _as_int(settings.get("nx", 80), "settings.nx"),
"ny": _as_int(settings.get("ny", 45), "settings.ny"),
"edge_sample_grid": _as_int(
settings.get("edge_sample_grid", 5), "settings.edge_sample_grid", minimum=2
),
"color_r": float(settings.get("color_r", _DEFAULT_COLOR_R)),
"seed": None if seed is None else int(seed),
"cross_enabled": bool(cross.get("enabled", True)),
"cross_length": _as_int(cross.get("length_px", 40), "settings.cross.length_px"),
"cross_thickness": _as_int(
cross.get("thickness_px", 3), "settings.cross.thickness_px"
),
"cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"),
"pairs": _resolve_pairs(job),
}
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
config = _resolve_config(_load_job(args.job.resolve()))
except (ConfigError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
# Resolve every object and UV layer before rendering anything, so a missing camera or
# UV map cannot leave a half-written set of artifacts behind.
try:
obj = _prepare_mesh_object(config["mesh"])
resolved = [
(
pair,
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"),
_require_uv_layer(obj, pair["uv_map"]),
)
for pair in config["pairs"]
]
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
for pair, _cam, _layer in resolved:
for format_name, output in pair["artifacts"].items():
try:
Path(output).parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"ERROR: cannot create {format_name.upper()} directory for view "
f"{pair['id']!r} ({Path(output).parent}): {exc}",
file=sys.stderr,
)
return 2
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
start_time = time.time()
print(
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
f"{config['width']}x{config['height']} raster, "
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm"
)
for pair, cam, uv_layer in resolved:
pair_id = pair["id"]
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
# A colour table per pair: cell (i, j) indexes a different place on the surface in
# each camera's grid, so sharing one table would place the same colour on unrelated
# surface while looking like consistency.
if pair["seed"] is not None:
np.random.seed(pair["seed"])
print(f" colour seed {pair['seed']}")
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
try:
geometry = build_patch_geometry(
obj,
cam,
scene,
depsgraph,
[pair["uv_map"]],
config["nx"],
config["ny"],
config["edge_sample_grid"],
)
except Exception as exc: # noqa: BLE001 - surface the real cause to stderr
print(
f"ERROR: patch geometry failed for view {pair_id!r}: {exc}", file=sys.stderr
)
return 1
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
try:
generate_patch_texture(
obj,
cam,
scene,
depsgraph,
nx=config["nx"],
ny=config["ny"],
r=config["color_r"],
texture_width=config["width"],
texture_height=config["height"],
output_path=pair["artifacts"]["png"],
svg_path=pair["artifacts"]["svg"],
pdf_path=pair["artifacts"]["pdf"],
vector_width_mm=config["vector_width_mm"],
vector_height_mm=config["vector_height_mm"],
uv_centre=uv_centre,
cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
cross_color=config["cross_color"],
edge_sample_grid=config["edge_sample_grid"],
uv_layer=uv_layer,
image_name=f"PatchTexture_{pair_id}",
patch_colors=patch_colors,
geometry=geometry[pair["uv_map"]],
)
except Exception as exc: # noqa: BLE001
print(
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
file=sys.stderr,
)
return 1
for format_name, output in pair["artifacts"].items():
path = Path(output)
if not path.is_file() or path.stat().st_size == 0:
print(
f"ERROR: {format_name.upper()} artifact was not written for view "
f"{pair_id!r}: {path}",
file=sys.stderr,
)
return 1
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved)
print(f"Multi-view complete: {artifact_count} file(s)")
for pair, _cam, _layer in resolved:
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
for format_name, output in pair["artifacts"].items():
path = Path(output)
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)")
print(f"Completed in {time.time() - start_time:.2f} seconds.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,58 @@
{
"schema": "orisys-sensor-design/texture-job-multiview-v1",
"mode": "patches-multiview",
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01",
"source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01\\source\\geometry.blend",
"objects": {
"mesh": "sensing_surface"
},
"pattern_camera": "cam_sensor",
"settings": {
"width": 3280,
"height": 3200,
"nx": 80,
"ny": 45,
"color_r": 0.05,
"edge_sample_grid": 5,
"seed": 7,
"vector": {
"width_mm": 16.4,
"height_mm": 16.0
},
"cross": {
"enabled": true,
"length_px": 40,
"thickness_px": 3,
"color": [
1.0,
0.0,
0.0,
1.0
]
}
},
"pairs": [
{
"id": "uv_1",
"camera": "cam_sensor",
"uv_map": "uv_1",
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf"
}
},
{
"id": "uv_2",
"camera": "cam_sensor",
"uv_map": "uv_2",
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf"
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.0 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.1 MiB

@@ -0,0 +1,672 @@
{
"schema": "orisys-sensor-design/texture-report-v2",
"generated_at": "2026-09-28T02:21:11+00:00",
"revision": "sensors/finger/r01",
"settings": {
"printer_dpi": 600.0,
"dot_pitch_mm": 0.042333333333333334,
"min_dots": 2.0,
"min_feature_mm": 0.1,
"min_edge_mm": 0.05,
"max_sliver_ratio": 4.0,
"min_patch_area_mm2": 0.0001
},
"maps": [
{
"page_mm": {
"width_mm": 164.0,
"height_mm": 160.0
},
"mm_per_px": 0.05,
"uniform_scale": true,
"raster_dpi": 508.0,
"nominal_cell": {
"width_mm": 0.205,
"height_mm": 0.355556,
"dots": 4.84
},
"patches": {
"total": 2917,
"material": 2912,
"debris": 5,
"below_dot": 6,
"degenerate": 0,
"nonconvex": 9
},
"min_width_mm": {
"min": 0.032822,
"p5": 0.58219,
"p50": 1.233413,
"method": "calipers (non-convex polygons fell back to their shortest edge)"
},
"min_edge_mm": {
"min": 0.276854,
"p5": 0.600091,
"p50": 1.228724
},
"max_sliver_ratio": 146.155,
"coverage": {
"patch_area_fraction": 0.7471,
"mesh_polygons": 1975
},
"colours": {
"distinct": 2917,
"min_rgb_distance": 1.0
},
"reference_cross": {
"thickness_mm": 0.15,
"printable": true
},
"page_mismatch": {
"artifact_mm": [
164.0,
160.0
],
"declared_mm": [
16.4,
16.0
],
"reason": "artifact page 164x160 mm differs from design.yaml 16.4x16 mm; regenerate the texture to apply the configured page"
},
"notes": [
"5 patch(es) below the 0.0001 mm^2 material area are excluded from feature statistics and listed as debris"
],
"checks": [
{
"name": "min_feature_mm",
"required": 0.1,
"actual": 0.032822,
"passed": false
},
{
"name": "min_feature_dots",
"required": 2.0,
"actual": 0.77533,
"passed": false
},
{
"name": "min_edge_mm",
"required": 0.05,
"actual": 0.276854,
"passed": true
},
{
"name": "max_sliver_ratio",
"required": 4.0,
"actual": 146.154636,
"passed": false
}
],
"verdict": "fail",
"offenders": [
{
"kind": "material",
"id": "patch-2595",
"fill": "#9a4876",
"centroid_px": [
1142.307,
577.574
],
"centroid_mm": [
57.11536,
28.87872
],
"min_width_mm": 0.032822,
"sliver_ratio": 146.155,
"area_mm2": 0.078727
},
{
"kind": "material",
"id": "patch-2598",
"fill": "#9a6390",
"centroid_px": [
2007.509,
498.341
],
"centroid_mm": [
100.375453,
24.917057
],
"min_width_mm": 0.047991,
"sliver_ratio": 102.228,
"area_mm2": 0.117729
},
{
"kind": "material",
"id": "patch-2664",
"fill": "#67a8f8",
"centroid_px": [
2618.491,
854.453
],
"centroid_mm": [
130.924551,
42.72267
],
"min_width_mm": 0.068591,
"sliver_ratio": 42.846,
"area_mm2": 0.100813
},
{
"kind": "material",
"id": "patch-2600",
"fill": "#e75dab",
"centroid_px": [
2189.953,
572.06
],
"centroid_mm": [
109.49767,
28.603009
],
"min_width_mm": 0.080539,
"sliver_ratio": 57.757,
"area_mm2": 0.187325
},
{
"kind": "material",
"id": "patch-2597",
"fill": "#9c559b",
"centroid_px": [
1307.836,
508.956
],
"centroid_mm": [
65.391818,
25.447803
],
"min_width_mm": 0.103625,
"sliver_ratio": 47.925,
"area_mm2": 0.257315
},
{
"kind": "debris",
"id": "patch-2717",
"fill": "#629404",
"centroid_px": [
494.197,
1096.279
],
"centroid_mm": [
24.709843,
54.813965
],
"min_width_mm": 1.662e-07,
"sliver_ratio": 10194679.42,
"area_mm2": 1.408e-07
},
{
"kind": "debris",
"id": "patch-2718",
"fill": "#5bb453",
"centroid_px": [
2809.491,
1067.939
],
"centroid_mm": [
140.474533,
53.39696
],
"min_width_mm": 2e-06,
"sliver_ratio": 1027278.32,
"area_mm2": 1e-06
},
{
"kind": "debris",
"id": "patch-2916",
"fill": "#ec42c5",
"centroid_px": [
3158.28,
1797.455
],
"centroid_mm": [
157.914017,
89.872763
],
"min_width_mm": 5e-06,
"sliver_ratio": 115994.843,
"area_mm2": 1e-06
}
],
"uv_map": "patch_texture_compute",
"file": {
"name": "patch_texture_compute.svg",
"relative_path": "build/textures/finger/r01/patch_texture_compute.svg",
"absolute_path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_compute.svg",
"size_bytes": 704084,
"sha256": "70844a74572da2cfe773d198b4c60f532288b80263734567d541a66c0b4796dc"
}
},
{
"page_mm": {
"width_mm": 16.4,
"height_mm": 16.0
},
"mm_per_px": 0.005,
"uniform_scale": true,
"raster_dpi": 5080.0,
"nominal_cell": {
"width_mm": 0.205,
"height_mm": 0.355556,
"dots": 4.84
},
"patches": {
"total": 22791,
"material": 20574,
"debris": 2202,
"below_dot": 13543,
"degenerate": 15,
"nonconvex": 0
},
"min_width_mm": {
"min": 0.00046,
"p5": 0.003697,
"p50": 0.036017,
"method": "calipers"
},
"min_edge_mm": {
"min": 0.000537,
"p5": 0.009792,
"p50": 0.062787
},
"max_sliver_ratio": 1049.393,
"coverage": {
"patch_area_fraction": 0.7967,
"mesh_polygons": 1974
},
"colours": {
"distinct": 2923,
"min_rgb_distance": 1.0
},
"reference_cross": {
"thickness_mm": 0.015,
"printable": false
},
"page_mismatch": null,
"notes": [
"2202 patch(es) below the 0.0001 mm^2 material area are excluded from feature statistics and listed as debris"
],
"checks": [
{
"name": "min_feature_mm",
"required": 0.1,
"actual": 0.00046,
"passed": false
},
{
"name": "min_feature_dots",
"required": 2.0,
"actual": 0.010864,
"passed": false
},
{
"name": "min_edge_mm",
"required": 0.05,
"actual": 0.000537,
"passed": false
},
{
"name": "max_sliver_ratio",
"required": 4.0,
"actual": 1049.393324,
"passed": false
}
],
"verdict": "fail",
"offenders": [
{
"kind": "material",
"id": "patch-2655",
"fill": "#48abfd",
"centroid_px": [
111.025,
594.975
],
"centroid_mm": [
0.555124,
2.974876
],
"min_width_mm": 0.00046,
"sliver_ratio": 1049.393,
"area_mm2": 0.000111
},
{
"kind": "material",
"id": "patch-3253",
"fill": "#44c7ec",
"centroid_px": [
0.67,
1032.332
],
"centroid_mm": [
0.003351,
5.16166
],
"min_width_mm": 0.000493,
"sliver_ratio": 842.374,
"area_mm2": 0.000103
},
{
"kind": "material",
"id": "patch-9492",
"fill": "#da5c08",
"centroid_px": [
1373.739,
585.03
],
"centroid_mm": [
6.868695,
2.92515
],
"min_width_mm": 0.000507,
"sliver_ratio": 847.226,
"area_mm2": 0.000109
},
{
"kind": "material",
"id": "patch-5358",
"fill": "#0894db",
"centroid_px": [
1221.343,
708.967
],
"centroid_mm": [
6.106714,
3.544834
],
"min_width_mm": 0.000522,
"sliver_ratio": 883.239,
"area_mm2": 0.00012
},
{
"kind": "material",
"id": "patch-0656",
"fill": "#b6fb45",
"centroid_px": [
451.088,
2165.694
],
"centroid_mm": [
2.255439,
10.828471
],
"min_width_mm": 0.000529,
"sliver_ratio": 1033.893,
"area_mm2": 0.000144
},
{
"kind": "debris",
"id": "patch-2118",
"fill": "#7a5f00",
"centroid_px": [
996.46,
1644.774
],
"centroid_mm": [
4.9823,
8.223871
],
"min_width_mm": 2.236e-07,
"sliver_ratio": 5.0,
"area_mm2": 1.279e-13
},
{
"kind": "debris",
"id": "patch-20855",
"fill": "#a0d66f",
"centroid_px": [
1614.739,
2279.677
],
"centroid_mm": [
8.073696,
11.398383
],
"min_width_mm": 5e-07,
"sliver_ratio": 2.0,
"area_mm2": 2.487e-13
},
{
"kind": "debris",
"id": "patch-21626",
"fill": "#5e6b98",
"centroid_px": [
1393.9,
2083.076
],
"centroid_mm": [
6.9695,
10.415379
],
"min_width_mm": 5e-07,
"sliver_ratio": 2.0,
"area_mm2": 2.487e-13
}
],
"uv_map": "patch_texture_uv_1",
"file": {
"name": "patch_texture_uv_1.svg",
"relative_path": "build/textures/finger/r01/patch_texture_uv_1.svg",
"absolute_path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"size_bytes": 3219555,
"sha256": "3fd26513275a741c787a68194ee24a47301887401bb2bbdaa0e4ceae7454c58f"
}
},
{
"page_mm": {
"width_mm": 16.4,
"height_mm": 16.0
},
"mm_per_px": 0.005,
"uniform_scale": true,
"raster_dpi": 5080.0,
"nominal_cell": {
"width_mm": 0.205,
"height_mm": 0.355556,
"dots": 4.84
},
"patches": {
"total": 22791,
"material": 20482,
"debris": 2296,
"below_dot": 13585,
"degenerate": 13,
"nonconvex": 0
},
"min_width_mm": {
"min": 0.000362,
"p5": 0.003905,
"p50": 0.035707,
"method": "calipers"
},
"min_edge_mm": {
"min": 0.00053,
"p5": 0.009992,
"p50": 0.06249
},
"max_sliver_ratio": 1565.284,
"coverage": {
"patch_area_fraction": 0.7965,
"mesh_polygons": 1974
},
"colours": {
"distinct": 2923,
"min_rgb_distance": 1.0
},
"reference_cross": {
"thickness_mm": 0.015,
"printable": false
},
"page_mismatch": null,
"notes": [
"2296 patch(es) below the 0.0001 mm^2 material area are excluded from feature statistics and listed as debris"
],
"checks": [
{
"name": "min_feature_mm",
"required": 0.1,
"actual": 0.000362,
"passed": false
},
{
"name": "min_feature_dots",
"required": 2.0,
"actual": 0.008559,
"passed": false
},
{
"name": "min_edge_mm",
"required": 0.05,
"actual": 0.00053,
"passed": false
},
{
"name": "max_sliver_ratio",
"required": 4.0,
"actual": 1565.284127,
"passed": false
}
],
"verdict": "fail",
"offenders": [
{
"kind": "material",
"id": "patch-9358",
"fill": "#d0c36c",
"centroid_px": [
3012.515,
1907.994
],
"centroid_mm": [
15.062573,
9.53997
],
"min_width_mm": 0.000362,
"sliver_ratio": 1565.284,
"area_mm2": 0.000103
},
{
"kind": "material",
"id": "patch-5352",
"fill": "#f60614",
"centroid_px": [
3046.712,
1483.229
],
"centroid_mm": [
15.23356,
7.416147
],
"min_width_mm": 0.000392,
"sliver_ratio": 1422.414,
"area_mm2": 0.000109
},
{
"kind": "material",
"id": "patch-3691",
"fill": "#e32912",
"centroid_px": [
1906.727,
582.625
],
"centroid_mm": [
9.533633,
2.913127
],
"min_width_mm": 0.000518,
"sliver_ratio": 784.45,
"area_mm2": 0.000105
},
{
"kind": "material",
"id": "patch-1044",
"fill": "#12eddd",
"centroid_px": [
2058.879,
706.126
],
"centroid_mm": [
10.294394,
3.53063
],
"min_width_mm": 0.000544,
"sliver_ratio": 823.786,
"area_mm2": 0.000122
},
{
"kind": "material",
"id": "patch-9612",
"fill": "#7066cd",
"centroid_px": [
3189.712,
1539.224
],
"centroid_mm": [
15.948559,
7.696118
],
"min_width_mm": 0.000545,
"sliver_ratio": 772.636,
"area_mm2": 0.000115
},
{
"kind": "debris",
"id": "patch-2118",
"fill": "#7a5f00",
"centroid_px": [
2282.367,
1645.343
],
"centroid_mm": [
11.411837,
8.226715
],
"min_width_mm": 2.236e-07,
"sliver_ratio": 5.0,
"area_mm2": 1.208e-13
},
{
"kind": "debris",
"id": "patch-14399",
"fill": "#501821",
"centroid_px": [
1592.662,
2336.05
],
"centroid_mm": [
7.963312,
11.68025
],
"min_width_mm": 3.536e-07,
"sliver_ratio": 2.0,
"area_mm2": 1.208e-13
},
{
"kind": "debris",
"id": "patch-21326",
"fill": "#f77114",
"centroid_px": [
2038.452,
1927.416
],
"centroid_mm": [
10.19226,
9.637081
],
"min_width_mm": 3.536e-07,
"sliver_ratio": 2.0,
"area_mm2": 1.208e-13
}
],
"uv_map": "patch_texture_uv_2",
"file": {
"name": "patch_texture_uv_2.svg",
"relative_path": "build/textures/finger/r01/patch_texture_uv_2.svg",
"absolute_path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"size_bytes": 3236828,
"sha256": "47a3f215547cca65d00084a54e207e4e8be3b23b9b30da00e98ac927d75bbd6c"
}
}
],
"overall": "fail"
}
@@ -0,0 +1,447 @@
{
"schema": "orisys-sensor-design/texture-report-v2",
"generated_at": "2026-09-28T02:20:34+00:00",
"revision": "sensors/finger/r01",
"settings": {
"printer_dpi": 600.0,
"dot_pitch_mm": 0.042333333333333334,
"min_dots": 2.0,
"min_feature_mm": 0.1,
"min_edge_mm": 0.05,
"max_sliver_ratio": 4.0,
"min_patch_area_mm2": 0.0001
},
"maps": [
{
"page_mm": {
"width_mm": 16.4,
"height_mm": 16.0
},
"mm_per_px": 0.005,
"uniform_scale": true,
"raster_dpi": 5080.0,
"nominal_cell": {
"width_mm": 0.205,
"height_mm": 0.355556,
"dots": 4.84
},
"patches": {
"total": 22791,
"material": 20574,
"debris": 2202,
"below_dot": 13543,
"degenerate": 15,
"nonconvex": 0
},
"min_width_mm": {
"min": 0.00046,
"p5": 0.003697,
"p50": 0.036017,
"method": "calipers"
},
"min_edge_mm": {
"min": 0.000537,
"p5": 0.009792,
"p50": 0.062787
},
"max_sliver_ratio": 1049.393,
"coverage": {
"patch_area_fraction": 0.7967,
"mesh_polygons": 1974
},
"colours": {
"distinct": 2923,
"min_rgb_distance": 1.0
},
"reference_cross": {
"thickness_mm": 0.015,
"printable": false
},
"page_mismatch": null,
"notes": [
"2202 patch(es) below the 0.0001 mm^2 material area are excluded from feature statistics and listed as debris"
],
"checks": [
{
"name": "min_feature_mm",
"required": 0.1,
"actual": 0.00046,
"passed": false
},
{
"name": "min_feature_dots",
"required": 2.0,
"actual": 0.010864,
"passed": false
},
{
"name": "min_edge_mm",
"required": 0.05,
"actual": 0.000537,
"passed": false
},
{
"name": "max_sliver_ratio",
"required": 4.0,
"actual": 1049.393324,
"passed": false
}
],
"verdict": "fail",
"offenders": [
{
"kind": "material",
"id": "patch-2655",
"fill": "#48abfd",
"centroid_px": [
111.025,
594.975
],
"centroid_mm": [
0.555124,
2.974876
],
"min_width_mm": 0.00046,
"sliver_ratio": 1049.393,
"area_mm2": 0.000111
},
{
"kind": "material",
"id": "patch-3253",
"fill": "#44c7ec",
"centroid_px": [
0.67,
1032.332
],
"centroid_mm": [
0.003351,
5.16166
],
"min_width_mm": 0.000493,
"sliver_ratio": 842.374,
"area_mm2": 0.000103
},
{
"kind": "material",
"id": "patch-9492",
"fill": "#da5c08",
"centroid_px": [
1373.739,
585.03
],
"centroid_mm": [
6.868695,
2.92515
],
"min_width_mm": 0.000507,
"sliver_ratio": 847.226,
"area_mm2": 0.000109
},
{
"kind": "material",
"id": "patch-5358",
"fill": "#0894db",
"centroid_px": [
1221.343,
708.967
],
"centroid_mm": [
6.106714,
3.544834
],
"min_width_mm": 0.000522,
"sliver_ratio": 883.239,
"area_mm2": 0.00012
},
{
"kind": "material",
"id": "patch-0656",
"fill": "#b6fb45",
"centroid_px": [
451.088,
2165.694
],
"centroid_mm": [
2.255439,
10.828471
],
"min_width_mm": 0.000529,
"sliver_ratio": 1033.893,
"area_mm2": 0.000144
},
{
"kind": "debris",
"id": "patch-2118",
"fill": "#7a5f00",
"centroid_px": [
996.46,
1644.774
],
"centroid_mm": [
4.9823,
8.223871
],
"min_width_mm": 2.236e-07,
"sliver_ratio": 5.0,
"area_mm2": 1.279e-13
},
{
"kind": "debris",
"id": "patch-20855",
"fill": "#a0d66f",
"centroid_px": [
1614.739,
2279.677
],
"centroid_mm": [
8.073696,
11.398383
],
"min_width_mm": 5e-07,
"sliver_ratio": 2.0,
"area_mm2": 2.487e-13
},
{
"kind": "debris",
"id": "patch-21626",
"fill": "#5e6b98",
"centroid_px": [
1393.9,
2083.076
],
"centroid_mm": [
6.9695,
10.415379
],
"min_width_mm": 5e-07,
"sliver_ratio": 2.0,
"area_mm2": 2.487e-13
}
],
"uv_map": "patch_texture_uv_1",
"file": {
"name": "patch_texture_uv_1.svg",
"relative_path": "build/textures/finger/r01/patch_texture_uv_1.svg",
"absolute_path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"size_bytes": 3219555,
"sha256": "3fd26513275a741c787a68194ee24a47301887401bb2bbdaa0e4ceae7454c58f"
}
},
{
"page_mm": {
"width_mm": 16.4,
"height_mm": 16.0
},
"mm_per_px": 0.005,
"uniform_scale": true,
"raster_dpi": 5080.0,
"nominal_cell": {
"width_mm": 0.205,
"height_mm": 0.355556,
"dots": 4.84
},
"patches": {
"total": 22791,
"material": 20482,
"debris": 2296,
"below_dot": 13585,
"degenerate": 13,
"nonconvex": 0
},
"min_width_mm": {
"min": 0.000362,
"p5": 0.003905,
"p50": 0.035707,
"method": "calipers"
},
"min_edge_mm": {
"min": 0.00053,
"p5": 0.009992,
"p50": 0.06249
},
"max_sliver_ratio": 1565.284,
"coverage": {
"patch_area_fraction": 0.7965,
"mesh_polygons": 1974
},
"colours": {
"distinct": 2923,
"min_rgb_distance": 1.0
},
"reference_cross": {
"thickness_mm": 0.015,
"printable": false
},
"page_mismatch": null,
"notes": [
"2296 patch(es) below the 0.0001 mm^2 material area are excluded from feature statistics and listed as debris"
],
"checks": [
{
"name": "min_feature_mm",
"required": 0.1,
"actual": 0.000362,
"passed": false
},
{
"name": "min_feature_dots",
"required": 2.0,
"actual": 0.008559,
"passed": false
},
{
"name": "min_edge_mm",
"required": 0.05,
"actual": 0.00053,
"passed": false
},
{
"name": "max_sliver_ratio",
"required": 4.0,
"actual": 1565.284127,
"passed": false
}
],
"verdict": "fail",
"offenders": [
{
"kind": "material",
"id": "patch-9358",
"fill": "#d0c36c",
"centroid_px": [
3012.515,
1907.994
],
"centroid_mm": [
15.062573,
9.53997
],
"min_width_mm": 0.000362,
"sliver_ratio": 1565.284,
"area_mm2": 0.000103
},
{
"kind": "material",
"id": "patch-5352",
"fill": "#f60614",
"centroid_px": [
3046.712,
1483.229
],
"centroid_mm": [
15.23356,
7.416147
],
"min_width_mm": 0.000392,
"sliver_ratio": 1422.414,
"area_mm2": 0.000109
},
{
"kind": "material",
"id": "patch-3691",
"fill": "#e32912",
"centroid_px": [
1906.727,
582.625
],
"centroid_mm": [
9.533633,
2.913127
],
"min_width_mm": 0.000518,
"sliver_ratio": 784.45,
"area_mm2": 0.000105
},
{
"kind": "material",
"id": "patch-1044",
"fill": "#12eddd",
"centroid_px": [
2058.879,
706.126
],
"centroid_mm": [
10.294394,
3.53063
],
"min_width_mm": 0.000544,
"sliver_ratio": 823.786,
"area_mm2": 0.000122
},
{
"kind": "material",
"id": "patch-9612",
"fill": "#7066cd",
"centroid_px": [
3189.712,
1539.224
],
"centroid_mm": [
15.948559,
7.696118
],
"min_width_mm": 0.000545,
"sliver_ratio": 772.636,
"area_mm2": 0.000115
},
{
"kind": "debris",
"id": "patch-2118",
"fill": "#7a5f00",
"centroid_px": [
2282.367,
1645.343
],
"centroid_mm": [
11.411837,
8.226715
],
"min_width_mm": 2.236e-07,
"sliver_ratio": 5.0,
"area_mm2": 1.208e-13
},
{
"kind": "debris",
"id": "patch-14399",
"fill": "#501821",
"centroid_px": [
1592.662,
2336.05
],
"centroid_mm": [
7.963312,
11.68025
],
"min_width_mm": 3.536e-07,
"sliver_ratio": 2.0,
"area_mm2": 1.208e-13
},
{
"kind": "debris",
"id": "patch-21326",
"fill": "#f77114",
"centroid_px": [
2038.452,
1927.416
],
"centroid_mm": [
10.19226,
9.637081
],
"min_width_mm": 3.536e-07,
"sliver_ratio": 2.0,
"area_mm2": 1.208e-13
}
],
"uv_map": "patch_texture_uv_2",
"file": {
"name": "patch_texture_uv_2.svg",
"relative_path": "build/textures/finger/r01/patch_texture_uv_2.svg",
"absolute_path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"size_bytes": 3236828,
"sha256": "47a3f215547cca65d00084a54e207e4e8be3b23b9b30da00e98ac927d75bbd6c"
}
}
],
"overall": "fail"
}
+30
View File
@@ -50,6 +50,36 @@ texture_patch:
thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross
# Multi-view production textures (`orisys-design texture-views`). STEP 1 ONLY: each pair
# writes its own independent PNG/SVG/PDF. There is no ownership partition, no seam handling
# and no merge, so the two layouts may cover the same surface region and that overlap is
# deliberately left unresolved.
#
# The PATTERN is grounded on the sensing camera, not on the production views. `uv_1` and
# `uv_2` are two different *parameterisations* of the one surface pattern that
# `texture_compute` carries, so the printed marker stays a single pattern across the whole
# surface. `cam_uv_1` / `cam_uv_2` are the AUTHORING cameras used in Blender (Project from
# View (Bounds)) to create those layouts — they are not pattern sources. Passing cam_sensor
# as the projection camera with uv_1 / uv_2 as the interpolated layers is exactly what
# patch_projection.build_patch_geometry already supports.
#
# Rendering settings (page size, grid, palette, seed) come from `texture_patch` above,
# because they are decisions about the printed page rather than about a particular layout.
# `produce`/`compute` and `orisys-design texture` are untouched.
#
# Scope note: `uv_1`/`uv_2` are related by roughly a 90 degree rotation with a flip — no
# rigid or affine transform maps one onto the other, because each is a different projection
# of a curved surface. Resolving the overlap therefore has to happen in surface space and is
# a later step; see src/orisys_sensor_design/texture_views.py.
texture_patch_views:
enabled: true
pattern_camera: cam_sensor # same pattern source as `compute`
pairs:
- id: uv_1 # layout authored from cam_uv_1
uv_map: uv_1
- id: uv_2 # layout authored from cam_uv_2
uv_map: uv_2
# Post-generation printability inspection (`orisys-design inspect`). Advisory
# only: it reports a verdict but never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above.
Binary file not shown.
+139 -6
View File
@@ -401,6 +401,83 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
return 0
def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate one independent texture per production view.
Step 1 of the multi-angle work: each pair writes its own PNG/SVG/PDF from its own
camera into its own UV layout. No ownership partition, no seam handling and no merge —
two pairs may cover the same surface region and that overlap is left unresolved.
"""
from orisys_sensor_design import texture_views
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return 1
overrides = texture_overrides_from_args(args)
try:
settings = texture_views.resolve_views_settings(paths, overrides)
pairs = texture_views.resolve_view_pairs(paths, settings)
output_dir = resolve_texture_output_dir(root, paths, args.output_dir)
except TextureConfigError as exc:
for message in str(exc).splitlines():
print(f"ERROR: {message}", file=sys.stderr)
return 2
targets = texture_views.multiview_output_paths(output_dir, pairs)
job = texture_views.build_multiview_job(paths, settings, pairs, output_dir)
job_path = texture_views.write_multiview_job(job, output_dir)
print(f"Wrote multi-view texture job: {job_path}")
print(f" {len(pairs)} view(s), step 1 (independent textures, no merge)")
for pair, _artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r} seed={pair.seed}")
code = _run_blender_script(
"random_patch_multiview.py",
paths.source_blend,
["--job", str(job_path)],
)
if code != 0:
return code
missing = [
(pair, format_name, path)
for pair, artifacts in targets
for format_name, path in artifacts.items()
if not path.is_file() or path.stat().st_size == 0
]
if missing:
for pair, format_name, path in missing:
print(
f"ERROR: Blender finished but the {format_name.upper()} artifact for view "
f"{pair.id!r} is missing or empty: {path}",
file=sys.stderr,
)
return 3
artifact_count = sum(len(artifacts) for _, artifacts in targets)
print(f"OK: wrote {artifact_count} multi-view artifact(s) to {output_dir}")
for pair, artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r}")
for format_name, path in artifacts.items():
print(f" {format_name.upper()}: {path}")
print(
f" mode=patches-multiview {settings.width}x{settings.height} "
f"grid={settings.nx}x{settings.ny} "
f"vector={settings.vector_width_mm:g}x{settings.vector_height_mm:g}mm "
f"mesh={settings.mesh!r}"
)
print(
" NOTE: overlap between views is NOT resolved in this step; "
"merging needs surface-space ownership (see texture_views.py)"
)
return 0
def _print_map_summary(entry: dict) -> None:
"""Print the human-readable summary for one measured UV map."""
record = entry.get("file") or {}
@@ -502,12 +579,26 @@ def cmd_inspect(revision_dir: Path, args: argparse.Namespace) -> int:
maps: list[tuple[str, Path]] = []
missing: list[Path] = []
for target, artifacts in targets:
svg_path = artifacts["svg"]
if not svg_path.is_file() or svg_path.stat().st_size == 0:
missing.append(svg_path)
else:
maps.append((target.uv_map, svg_path))
explicit = list(getattr(args, "svg", None) or [])
if explicit:
# Grade artifacts that are not named by texture_patch.uv_outputs — the per-view
# textures from `texture-views` deliberately do not extend that list, because
# extending it would make `orisys-design texture` generate them too. Additive: the
# configured-map path below is unchanged when --svg is absent. --svg wins over
# --uv-map, since an explicit path is a more specific request.
for raw in explicit:
path = Path(raw)
if not path.is_file() or path.stat().st_size == 0:
missing.append(path)
else:
maps.append((path.stem, path.resolve()))
else:
for target, artifacts in targets:
svg_path = artifacts["svg"]
if not svg_path.is_file() or svg_path.stat().st_size == 0:
missing.append(svg_path)
else:
maps.append((target.uv_map, svg_path))
if missing:
for path in missing:
print(
@@ -1826,6 +1917,40 @@ def main(argv: list[str] | None = None) -> None:
help="Reference cross RGBA colour in 0-1",
)
texture_views_p = sub.add_parser(
"texture-views",
help="Generate one independent texture per production view (no merge)",
)
texture_views_p.add_argument("revision_dir", type=Path)
texture_views_p.add_argument(
"--output-dir",
type=Path,
default=None,
help="Output directory (default: build/textures/<sensor>/<revision>)",
)
texture_views_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name")
texture_views_p.add_argument("--width", type=int, default=None, help="Texture width in pixels")
texture_views_p.add_argument("--height", type=int, default=None, help="Texture height in pixels")
texture_views_p.add_argument("--nx", type=int, default=None, help="Patch columns across the frame")
texture_views_p.add_argument("--ny", type=int, default=None, help="Patch rows across the frame")
texture_views_p.add_argument(
"--color-r",
dest="color_r",
type=float,
default=None,
help="Colour separation between neighbouring patches",
)
texture_views_p.add_argument(
"--edge-sample-grid",
dest="edge_sample_grid",
type=int,
default=None,
help="Edge sampling grid for partially covered patches",
)
texture_views_p.add_argument(
"--seed", type=int, default=None, help="Base seed; pair i uses seed + i"
)
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(
@@ -1851,6 +1976,12 @@ def main(argv: list[str] | None = None) -> None:
"--uv-map", dest="uv_map", default=None,
help="Measure only this UV map (default: every configured map)",
)
inspect_p.add_argument(
"--svg", type=Path, action="append", default=None, metavar="PATH",
help="Grade this SVG directly instead of the configured texture_patch.uv_outputs "
"maps (repeatable; for artifacts such as the per-view textures written by "
"`texture-views`). Takes precedence over --uv-map",
)
inspect_p.add_argument(
"--out", type=Path, default=None,
help=f"Report path (default: <output-dir>/{REPORT_FILENAME})",
@@ -2211,6 +2342,8 @@ def main(argv: list[str] | None = None) -> None:
code = cmd_export(args.revision_dir, args.profile, args.housing_collections)
elif args.command == "texture":
code = cmd_texture(args.revision_dir, args)
elif args.command == "texture-views":
code = cmd_texture_views(args.revision_dir, args)
elif args.command == "inspect":
code = cmd_inspect(args.revision_dir, args)
elif args.command == "preview":
+336
View File
@@ -0,0 +1,336 @@
"""Multi-view production textures: one independent texture per production UV layout.
Scope — **step 1 only**. Each pair generates its own PNG/SVG/PDF, with:
* **no ownership partition** — two pairs may cover the same surface region, and the
resulting overlap is *not* resolved here;
* **no seam or merge logic** — nothing is stitched into a single texture;
* **no change to the single-view path** — ``texture_patch``, ``random_patch_dd02c.py`` and
the ``texture-job`` schema are untouched.
The pattern is grounded on the sensing camera, not on the production views
----------------------------------------------------------------------------
The pattern is a property of the **surface**, defined by ``texture_patch.camera``
(``cam_sensor``) — the same camera that defines ``compute``, the runtime layout. A
production entry therefore names only a **UV layout**: ``uv_1`` / ``uv_2`` are different
*parameterisations* of one and the same surface pattern, not two different patterns.
This is what ``patch_projection.build_patch_geometry`` already supports: the camera
argument produces the cell clips (``clips``) and the visibility answers, while the
``uv_names`` argument only chooses which layers the fragment's barycentrics are
interpolated into. Passing ``cam_sensor`` with ``uv_names=['uv_1']`` therefore yields
cam_sensor's cells (and therefore cam_sensor's colours) written into ``uv_1``'s layout.
So ``cam_uv_1`` / ``cam_uv_2`` are the **authoring** cameras used to create the
``uv_1`` / ``uv_2`` layouts (Blender's *Project from View (Bounds)*); they are not pattern
sources. A pair may still override ``camera:`` to source the pattern elsewhere, but the
default — and the reason these layouts exist — is the sensing camera.
Two consequences worth stating, because they are easy to get wrong:
1. **The colour seed is shared, not derived per view.** A per-view seed (base + index)
would paint two different patterns onto one physical surface; the printed marker has to
be one pattern.
2. **No rigid or affine transform relates the layouts.** ``uv_1`` and ``uv_2`` are related
by roughly a 90 degree rotation with a flip (per-triangle UV centroid correlation
``corr(u1, v2) = +0.93`` / ``corr(v1, u2) = -0.93``), and each is a different projection
of a curved surface, so no UV-space transform maps one onto the other. Any later overlap
resolution therefore has to happen in **surface space**, not by overlaying the layouts.
``compute`` is the runtime contract: the shipped SDK unroll map was built from it. It is
never addressed by this module.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
from orisys_sensor_design.manifest import RevisionPaths, load_yaml
from orisys_sensor_design.texture import (
TEXTURE_ARTIFACT_FORMATS,
TextureConfigError,
TextureOutputTarget,
TexturePatchSettings,
resolve_texture_settings,
texture_output_dir,
)
MULTIVIEW_JOB_SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
MULTIVIEW_JOB_FILENAME = "_texture_views_job.json"
VIEWS_BLOCK_KEY = "texture_patch_views"
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs"})
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed"})
#: Default artifact stem per pair. Derived from the pair id so a pair never has to name its
#: own files unless it wants to; ``{id}`` is substituted.
DEFAULT_FILENAME_TEMPLATE = "patch_texture_{id}.png"
class ViewPairConfigError(TextureConfigError):
"""Raised when ``texture_patch_views`` is present but unusable."""
@dataclass(frozen=True)
class ViewPair:
"""One production UV layout, and the pattern camera whose cells colour it.
``camera`` is the **pattern source** (normally the sensing camera), not the view that
authored the layout. ``uv_map`` is the layout the pattern is written into.
"""
id: str
camera: str
uv_map: str
target: TextureOutputTarget
seed: int | None
def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
return {
"id": self.id,
"camera": self.camera,
"uv_map": self.uv_map,
"seed": self.seed,
"artifacts": {
format_name: str(path)
for format_name, path in self.target.artifact_paths(output_dir).items()
},
}
def load_views_block(paths: RevisionPaths) -> dict[str, Any] | None:
"""Return the raw ``texture_patch_views`` block, or None when the revision has none."""
design = load_yaml(paths.design_yaml)
block = design.get(VIEWS_BLOCK_KEY)
if block is None:
return None
if not isinstance(block, dict):
raise ViewPairConfigError(
f"{VIEWS_BLOCK_KEY}: must be a mapping (got {type(block).__name__})"
)
return block
def validate_views_block(
block: Mapping[str, Any],
*,
objects: Mapping[str, str] | None = None,
) -> list[str]:
"""Return human-readable problems with a ``texture_patch_views`` block.
``objects`` is unused today (Blender owns object existence) but is accepted so the
signature does not have to change when camera validation moves host-side.
"""
errors: list[str] = []
unknown = sorted(set(block) - _BLOCK_KEYS)
if unknown:
errors.append(
f"{VIEWS_BLOCK_KEY}: unknown key(s) {unknown}; allowed: {sorted(_BLOCK_KEYS)}"
)
if "enabled" in block and not isinstance(block["enabled"], bool):
errors.append(f"{VIEWS_BLOCK_KEY}.enabled: must be true or false")
pattern_camera = block.get("pattern_camera")
if pattern_camera is not None and (
not isinstance(pattern_camera, str) or not pattern_camera.strip()
):
errors.append(
f"{VIEWS_BLOCK_KEY}.pattern_camera: must be a non-empty object name "
"(or omitted, to use texture_patch.camera)"
)
pairs = block.get("pairs")
if not isinstance(pairs, list) or not pairs:
errors.append(f"{VIEWS_BLOCK_KEY}.pairs: must be a non-empty list")
return errors
seen_ids: set[str] = set()
seen_files: dict[str, str] = {}
seen_camera_layout: dict[tuple[str, str], str] = {}
for index, pair in enumerate(pairs):
where = f"{VIEWS_BLOCK_KEY}.pairs[{index}]"
if not isinstance(pair, Mapping):
errors.append(f"{where}: must be a mapping")
continue
extra = sorted(set(pair) - _PAIR_KEYS)
if extra:
errors.append(
f"{where}: unknown key(s) {extra}; allowed: {sorted(_PAIR_KEYS)}"
)
# `camera` is optional: the pattern source defaults to the sensing camera.
for key in ("id", "uv_map"):
value = pair.get(key)
if not isinstance(value, str) or not value.strip():
errors.append(f"{where}.{key}: must be a non-empty string")
if "camera" in pair:
value = pair.get("camera")
if not isinstance(value, str) or not value.strip():
errors.append(
f"{where}.camera: must be a non-empty object name "
"(omit it to use the pattern camera)"
)
pair_id = pair.get("id")
if isinstance(pair_id, str) and pair_id.strip():
if pair_id in seen_ids:
errors.append(f"{where}.id: duplicate pair id {pair_id!r}")
seen_ids.add(pair_id) # type: ignore[arg-type]
filename = pair.get("filename", _default_filename(pair_id))
if isinstance(filename, str) and filename:
stem = str(Path(filename).with_suffix(".png")).lower()
if stem in seen_files:
errors.append(
f"{where}.filename: {filename!r} collides with pair "
f"{seen_files[stem]!r} (artifacts would overwrite each other)"
)
seen_files[stem] = str(pair_id)
if Path(filename).suffix.lower() not in {".png", ""}:
errors.append(f"{where}.filename: must be a .png name")
seed = pair.get("seed")
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
errors.append(f"{where}.seed: must be an integer or omitted")
uv_map = pair.get("uv_map")
# Compare on the *effective* camera, so two pairs that both default to the sensing
# camera and write the same layout are still caught as a copy-paste.
effective_camera = pair.get("camera") or pattern_camera or "?"
if isinstance(uv_map, str) and uv_map:
key = (str(effective_camera), uv_map)
if key in seen_camera_layout:
errors.append(
f"{where}: pattern camera {effective_camera!r} with UV map {uv_map!r} is "
f"already used by pair {seen_camera_layout[key]!r}; the two textures "
"would be identical"
)
seen_camera_layout[key] = str(pair_id)
return errors
def _default_filename(pair_id: Any) -> str:
return DEFAULT_FILENAME_TEMPLATE.format(id=pair_id)
def resolve_view_pairs(
paths: RevisionPaths,
settings: TexturePatchSettings,
block: Mapping[str, Any] | None = None,
) -> tuple[ViewPair, ...]:
"""Resolve the views block into concrete pairs, or raise ViewPairConfigError."""
if block is None:
block = load_views_block(paths)
if block is None:
raise ViewPairConfigError(
f"this revision declares no {VIEWS_BLOCK_KEY} block, so there are no production "
"views to generate; add it next to texture_patch (the single-view "
"`orisys-design texture` path is unchanged)"
)
errors = validate_views_block(block)
if errors:
raise ViewPairConfigError("\n".join(errors))
if not block.get("enabled", True):
raise ViewPairConfigError(f"{VIEWS_BLOCK_KEY}.enabled is false")
# The pattern source. The production cameras authored the UV layouts (Project from
# View); they are not pattern sources, so the default is the sensing camera that also
# defines `compute`.
pattern_camera = str(block.get("pattern_camera") or settings.camera)
base_seed = settings.seed
pairs: list[ViewPair] = []
for raw in block["pairs"]:
pair_id = str(raw["id"])
# One shared seed, deliberately: the marker is a single physical pattern, and a
# per-view seed would paint two different patterns onto one surface.
seed = raw.get("seed")
if seed is None:
seed = base_seed
pairs.append(
ViewPair(
id=pair_id,
camera=str(raw.get("camera") or pattern_camera),
uv_map=str(raw["uv_map"]),
target=TextureOutputTarget(
uv_map=str(raw["uv_map"]),
filename=str(raw.get("filename") or _default_filename(pair_id)),
),
seed=None if seed is None else int(seed),
)
)
return tuple(pairs)
def build_multiview_job(
paths: RevisionPaths,
settings: TexturePatchSettings,
pairs: Sequence[ViewPair],
output_dir: Path,
) -> dict[str, Any]:
"""Build the JSON job ``random_patch_multiview.py`` consumes."""
if not pairs:
raise ViewPairConfigError("no view pairs to generate")
pattern_cameras = sorted({pair.camera for pair in pairs})
return {
"schema": MULTIVIEW_JOB_SCHEMA,
"mode": "patches-multiview",
"revision_dir": str(paths.root.resolve()),
"source_blend": str(paths.source_blend.resolve()),
"objects": {"mesh": settings.mesh},
# Recorded so a reader can see which camera defined the pattern, and that the
# production cameras merely chose the layouts.
"pattern_camera": pattern_cameras[0] if len(pattern_cameras) == 1 else pattern_cameras,
"settings": settings.to_job_settings(),
"pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs],
}
def write_multiview_job(job: dict[str, Any], output_dir: Path) -> Path:
"""Write ``_texture_views_job.json`` into the output directory."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / MULTIVIEW_JOB_FILENAME
path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
return path
def multiview_output_paths(
output_dir: Path,
pairs: Sequence[ViewPair],
) -> list[tuple[ViewPair, dict[str, Path]]]:
"""Pair each view with its ordered PNG/SVG/PDF artifact paths."""
resolved_dir = Path(output_dir).resolve()
return [
(pair, pair.target.artifact_paths(resolved_dir))
for pair in pairs
]
def resolve_views_settings(
paths: RevisionPaths,
overrides: Mapping[str, Any] | None = None,
) -> TexturePatchSettings:
"""The rendering settings are shared with the single-view path, on purpose.
Page size, grid, palette and seed are decisions about the printed page, not about a
particular view, so they come from ``texture_patch`` and only the *pairs* come from
``texture_patch_views``.
"""
return resolve_texture_settings(paths, overrides)
__all__ = [
"DEFAULT_FILENAME_TEMPLATE",
"MULTIVIEW_JOB_FILENAME",
"MULTIVIEW_JOB_SCHEMA",
"TEXTURE_ARTIFACT_FORMATS",
"VIEWS_BLOCK_KEY",
"ViewPair",
"ViewPairConfigError",
"build_multiview_job",
"load_views_block",
"multiview_output_paths",
"resolve_view_pairs",
"resolve_views_settings",
"texture_output_dir",
"validate_views_block",
"write_multiview_job",
]
+516
View File
@@ -0,0 +1,516 @@
"""Tests for multi-view production texture pairs (`orisys-design texture-views`).
Step 1 scope: one independent texture per ``(camera, UV layout)`` pair. Nothing here needs
Blender — the host module is stdlib-only, which is the point: the pair validation and the job
contract are checkable in CI, and the Blender side consumes a job it did not have to
interpret.
The last group is an isolation guard: the revision on disk now carries a
``texture_patch_views`` block, so these tests pin that the block does not leak into the
existing single-view ``texture_patch`` path or its job.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import pytest
from orisys_sensor_design import texture_views
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.texture import (
TEXTURE_ARTIFACT_FORMATS,
TextureConfigError,
build_texture_job,
resolve_texture_output_dir,
resolve_texture_settings,
)
SENSOR = "finger"
REVISION = "r01"
def _write_revision(
tmp_path: Path,
views_yaml: str = "",
*,
texture_patch_yaml: str | None = None,
) -> Path:
"""Minimal revision with a texture_patch block and an optional views block."""
revision = tmp_path / "sensors" / SENSOR / REVISION
(revision / "profiles").mkdir(parents=True)
(revision / "source").mkdir(parents=True)
(revision / "source" / "assembly.blend").write_bytes(b"")
design = (
"sensor: finger\n"
f"revision: {REVISION}\n"
"meters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n"
)
if texture_patch_yaml is None:
texture_patch_yaml = (
"texture_patch:\n"
" mesh: sensing_surface\n"
" camera: cam_sensor\n"
" uv_outputs:\n"
" produce: patch_texture_produce.png\n"
" compute: patch_texture_compute.png\n"
" width: 512\n"
" height: 256\n"
" nx: 80\n"
" ny: 45\n"
" seed: 7\n"
" vector:\n"
" width_mm: 16.4\n"
" height_mm: 16.0\n"
)
design += texture_patch_yaml + views_yaml
(revision / "design.yaml").write_text(design, encoding="utf-8")
for name in ("compute.yaml", "studio.yaml"):
(revision / "profiles" / name).write_text(
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
)
return revision
def _block(pairs_yaml: str, enabled: str = " enabled: true\n") -> str:
return "texture_patch_views:\n" + enabled + " pairs:\n" + pairs_yaml
#: The shape the r01 revision uses: layouts only. The pattern camera defaults to
#: `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`.
TWO_PAIRS = _block(
" - id: uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" uv_map: uv_2\n"
)
#: Explicit per-pair pattern cameras — an override, not the default.
TWO_PAIRS_EXPLICIT_CAMERAS = _block(
" - id: uv_1\n"
" camera: cam_uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
)
@pytest.fixture()
def real_revision() -> Path:
return repo_root() / "sensors" / SENSOR / REVISION
# ---------------------------------------------------------------- block loading
def test_revision_without_a_views_block_returns_none(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
assert texture_views.load_views_block(paths) is None
def test_missing_views_block_says_the_single_view_path_is_unaffected(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
message = str(excinfo.value)
assert "texture_patch_views" in message
assert "orisys-design texture" in message
def test_views_block_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "texture_patch_views: nope\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError):
texture_views.load_views_block(paths)
# ---------------------------------------------------------------- validation
@pytest.mark.parametrize(
("views_yaml", "expected"),
[
("texture_patch_views: {}\n", "pairs"),
(_block("", enabled=" enabled: maybe\n"), "enabled"),
("texture_patch_views:\n pairs: {}\n", "pairs"),
("texture_patch_views:\n pairs: []\n", "pairs"),
(_block(" - nope\n"), "mapping"),
(_block(" - id: a\n camera: c\n"), "uv_map"),
(
_block(" - id: a\n camera: c\n uv_map: u\n extra: 1\n"),
"unknown key",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: a\n camera: c2\n uv_map: u2\n"),
"duplicate pair id",
),
(
# Two pairs, both defaulting to the sensing camera, writing the same layout.
_block(" - id: a\n uv_map: u\n - id: b\n uv_map: u\n"),
"would be identical",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c\n uv_map: u\n"),
"would be identical",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c2\n uv_map: u2\n"
" filename: patch_texture_a.png\n"),
"collides",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n seed: 1.5\n"),
"seed",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n filename: x.svg\n"),
".png",
),
("texture_patch_views:\n bogus: 1\n pairs: []\n", "unknown key"),
],
)
def test_validation_rejects_bad_blocks(tmp_path: Path, views_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, views_yaml)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
assert expected in str(excinfo.value)
def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
assert [p.id for p in pairs] == ["uv_1", "uv_2"]
assert [p.uv_map for p in pairs] == ["uv_1", "uv_2"]
# The pattern source is the sensing camera, not the layout's authoring camera.
assert [p.camera for p in pairs] == ["cam_sensor", "cam_sensor"]
def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
"""uv_1/uv_2 must carry the same surface pattern as `compute`."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
assert all(p.camera == settings.camera for p in pairs)
def test_pattern_camera_can_be_overridden_at_block_or_pair_level(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, (
"texture_patch_views:\n"
" enabled: true\n"
" pattern_camera: cam_alt\n"
" pairs:\n"
" - id: uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
))
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.camera for p in pairs] == ["cam_alt", "cam_uv_2"]
def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
).replace(" enabled: true\n", " enabled: true\n pattern_camera: ''\n"))
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "pattern_camera" in str(excinfo.value)
def test_disabled_block_is_refused(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
enabled=" enabled: false\n",
))
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
assert "enabled is false" in str(excinfo.value)
# ---------------------------------------------------------------- seeds and filenames
def test_the_colour_seed_is_shared_across_layouts(tmp_path: Path) -> None:
"""One pattern, one palette: a per-layout seed would paint two patterns on one surface."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
assert settings.seed == 7
pairs = texture_views.resolve_view_pairs(paths, settings)
assert [p.seed for p in pairs] == [7, 7]
def test_explicit_pair_seed_wins(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n seed: 99\n"
" - id: uv_2\n uv_map: uv_2\n",
))
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.seed for p in pairs] == [99, 7]
def test_no_base_seed_leaves_pair_seeds_none(tmp_path: Path) -> None:
texture_patch = (
"texture_patch:\n"
" mesh: sensing_surface\n"
" camera: cam_sensor\n"
" uv_outputs:\n"
" produce: patch_texture_produce.png\n"
)
revision = _write_revision(tmp_path, TWO_PAIRS, texture_patch_yaml=texture_patch)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.seed for p in pairs] == [None, None]
def test_default_filenames_come_from_the_pair_id(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.target.filename for p in pairs] == [
"patch_texture_uv_1.png",
"patch_texture_uv_2.png",
]
# ---------------------------------------------------------------- job contract
def test_job_has_one_entry_per_pair_with_absolute_artifacts(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
out = tmp_path / "out"
job = texture_views.build_multiview_job(paths, settings, pairs, out)
assert job["schema"] == texture_views.MULTIVIEW_JOB_SCHEMA
assert job["mode"] == "patches-multiview"
assert job["objects"]["mesh"] == "sensing_surface"
assert "camera" not in job["objects"] # cameras are per pair, not global
assert job["pattern_camera"] == "cam_sensor"
assert [p["id"] for p in job["pairs"]] == ["uv_1", "uv_2"]
assert [p["camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"]
for entry in job["pairs"]:
assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS)
for path in entry["artifacts"].values():
assert Path(path).is_absolute()
# Settings come from texture_patch, so the printed page is decided in one place.
assert job["settings"]["nx"] == 80
assert job["settings"]["vector"]["width_mm"] == pytest.approx(16.4)
def test_job_is_deterministic(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
first = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
second = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_write_job_round_trips(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
out = tmp_path / "out"
job = texture_views.build_multiview_job(paths, settings, pairs, out)
written = texture_views.write_multiview_job(job, out)
assert written.name == texture_views.MULTIVIEW_JOB_FILENAME
assert json.loads(written.read_text(encoding="utf-8")) == job
def test_empty_pair_list_is_refused(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path, TWO_PAIRS))
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError):
texture_views.build_multiview_job(paths, settings, (), tmp_path)
def test_output_paths_are_absolute_and_ordered(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
targets = texture_views.multiview_output_paths(tmp_path / "o", pairs)
assert [pair.id for pair, _ in targets] == ["uv_1", "uv_2"]
for _pair, artifacts in targets:
assert list(artifacts) == list(TEXTURE_ARTIFACT_FORMATS)
assert all(path.is_absolute() for path in artifacts.values())
# ---------------------------------------------------------------- isolation guard
def test_real_revision_declares_exactly_the_two_production_views(
real_revision: Path,
) -> None:
"""Guards the on-disk wiring: uv_1/uv_2, patterned from the sensing camera.
`cam_uv_1`/`cam_uv_2` authored the layouts in Blender; they must NOT appear as pattern
sources, or the printed marker would carry two different patterns.
"""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
assert [(p.id, p.uv_map) for p in pairs] == [("uv_1", "uv_1"), ("uv_2", "uv_2")]
assert [p.camera for p in pairs] == [settings.camera, settings.camera]
assert settings.camera == "cam_sensor"
assert all(p.camera not in {"cam_uv_1", "cam_uv_2"} for p in pairs)
filenames = sorted(p.target.filename for p in pairs)
assert filenames == ["patch_texture_uv_1.png", "patch_texture_uv_2.png"]
# The runtime layout must never be a production view target.
assert all(p.uv_map not in {"compute", "produce"} for p in pairs)
# ...and the palette must match compute's, so the marker is one pattern.
assert [p.seed for p in pairs] == [settings.seed, settings.seed]
def test_views_block_does_not_leak_into_the_single_view_path(real_revision: Path) -> None:
"""`orisys-design texture` must still see only texture_patch's own outputs."""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
assert [t.uv_map for t in settings.outputs] == ["produce", "compute"]
assert settings.camera == "cam_sensor"
job = build_texture_job(paths, settings, real_revision / "build")
assert "pairs" not in job
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
assert job["objects"]["camera"] == "cam_sensor"
def test_views_and_single_view_share_the_page_settings(real_revision: Path) -> None:
"""A page decision lives in texture_patch, so both paths must agree on it."""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
views_settings = texture_views.resolve_views_settings(paths)
assert views_settings.nx == settings.nx
assert views_settings.ny == settings.ny
assert (views_settings.vector_width_mm, views_settings.vector_height_mm) == (
settings.vector_width_mm,
settings.vector_height_mm,
)
assert views_settings.outputs == settings.outputs
def test_views_overrides_do_not_touch_design_yaml(tmp_path: Path) -> None:
"""CLI overrides go into the job only, exactly like the single-view path."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
before = paths.design_yaml.read_text(encoding="utf-8")
namespace = argparse.Namespace(nx=31, ny=31, width=256, height=256)
settings = texture_views.resolve_views_settings(
paths, {"nx": namespace.nx, "ny": namespace.ny, "width": namespace.width,
"height": namespace.height}
)
assert (settings.nx, settings.ny) == (31, 31)
assert paths.design_yaml.read_text(encoding="utf-8") == before
def test_output_dir_override_wins_for_views(real_revision: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(real_revision)
assert resolve_texture_output_dir(repo_root(), paths, tmp_path / "elsewhere") == (
tmp_path / "elsewhere"
).resolve()
# ---------------------------------------------------------------- inspect --svg
def _inspect_namespace(revision: Path, output_dir: Path, **overrides: object):
namespace = argparse.Namespace(
revision_dir=revision,
output_dir=output_dir,
uv_map=None,
out=None,
csv=None,
csv_all=None,
top=5,
printer_dpi=None,
min_dots=None,
min_feature_mm=None,
min_edge_mm=None,
max_sliver_ratio=None,
min_patch_area_mm2=None,
svg=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
def _minimal_svg(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="16.4mm" height="16.0mm" '
'viewBox="0 0 3280 3200" preserveAspectRatio="none">\n'
' <g id="artwork" transform="translate(0 3200) scale(1 -1)">\n'
' <g id="mesh">\n'
' <polygon id="mesh-1" points="0,0 3280,0 3280,3200 0,3200" fill="#ffffff"/>\n'
' </g>\n'
' <g id="patches">\n'
' <polygon id="patch-1" points="10,10 210,10 210,210 10,210" fill="#000000"/>\n'
' </g>\n'
' </g>\n'
"</svg>\n",
encoding="utf-8",
)
return path
def test_inspect_svg_option_grades_an_unconfigured_layout(tmp_path: Path) -> None:
"""The per-view textures are not in texture_patch.uv_outputs; --svg grades them anyway.
``texture-views`` deliberately does not extend ``uv_outputs``, because extending it
would make the single-view ``orisys-design texture`` generate those layouts too.
"""
from orisys_sensor_design import cli
revision = _write_revision(tmp_path, TWO_PAIRS)
output_dir = tmp_path / "textures"
svg = _minimal_svg(output_dir / "patch_texture_uv_1.svg")
args = _inspect_namespace(revision, output_dir, svg=[svg])
assert cli.cmd_inspect(revision, args) == 0
report = json.loads((output_dir / "texture_report.json").read_text(encoding="utf-8"))
assert [entry["uv_map"] for entry in report["maps"]] == ["patch_texture_uv_1"]
def test_inspect_svg_option_reports_a_missing_file_as_exit_three(tmp_path: Path) -> None:
from orisys_sensor_design import cli
revision = _write_revision(tmp_path, TWO_PAIRS)
args = _inspect_namespace(
revision, tmp_path / "textures", svg=[tmp_path / "textures" / "nope.svg"]
)
assert cli.cmd_inspect(revision, args) == 3
def test_inspect_without_svg_still_uses_the_configured_maps(tmp_path: Path) -> None:
"""Additive: absent --svg, the configured-map path is unchanged."""
revision = _write_revision(tmp_path, TWO_PAIRS)
output_dir = tmp_path / "textures"
# No configured-map SVGs written at all -> the configured path reports them missing.
from orisys_sensor_design import cli
assert cli.cmd_inspect(revision, _inspect_namespace(revision, output_dir)) == 3