added multi-view support on full object texture
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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.
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user