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
+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",
]