fix cli redundant images
finger-xynova first design completed
This commit is contained in:
+165
-26
@@ -8,9 +8,10 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_sensor_design import __version__
|
||||
from orisys_sensor_design import __version__, console
|
||||
from orisys_sensor_design.manifest import (
|
||||
RevisionPaths,
|
||||
build_export_job,
|
||||
@@ -149,6 +150,13 @@ def resolve_blender_executable(root: Path | None = None) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
#: Reports the multi-view driver writes next to the textures. The host reads these back
|
||||
#: rather than re-deriving them, so a printed decision cannot describe a run that did not
|
||||
#: happen. Names are owned by `blender/scripts/random_patch_multiview.py`.
|
||||
MULTIVIEW_GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
|
||||
MULTIVIEW_PRINT_VIEWS_FILENAME = "_print_views.json"
|
||||
|
||||
|
||||
def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
|
||||
root = repo_root()
|
||||
blender = resolve_blender_executable(root)
|
||||
@@ -174,8 +182,66 @@ def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
|
||||
"--",
|
||||
*extra_args,
|
||||
]
|
||||
print("Running:", " ".join(cmd))
|
||||
return subprocess.call(cmd)
|
||||
# The child cannot see our argparse namespace, and the job JSON must stay a pure record of
|
||||
# what to build, so the level travels in the environment instead of in argv or the job.
|
||||
env = dict(os.environ)
|
||||
env["ORISYS_CONSOLE_LEVEL"] = console.verbosity_name()
|
||||
# Rebuildable, pasteable: the old `Running: …` printed an unquoted argv that could not be
|
||||
# pasted back into a shell and appeared on every run whether or not anything went wrong.
|
||||
console.detail("command: " + " ".join(f'"{part}"' if " " in part else part for part in cmd))
|
||||
# The child's output is captured rather than streamed so it can be filtered by level, which
|
||||
# means nothing would appear for the length of the run. Announce it, or a 20 s command
|
||||
# looks hung.
|
||||
console.report(f" Blender: {script} running…")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completed = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
except OSError as exc:
|
||||
print(f"ERROR: could not start Blender ({blender}): {exc}", file=sys.stderr)
|
||||
return 2
|
||||
elapsed = time.monotonic() - started
|
||||
_forward_blender_output(script, completed.stdout, completed.stderr, completed.returncode, elapsed)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def _forward_blender_output(
|
||||
script: str,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
returncode: int,
|
||||
elapsed: float,
|
||||
) -> None:
|
||||
"""Route a Blender run's output by level, and replay everything when it fails.
|
||||
|
||||
The child prefixes its lines (`::report::` / `::detail::`); anything unprefixed is
|
||||
Blender's own chatter. On success the report lines are forwarded and the rest is held for
|
||||
`--verbose`. On failure **both** streams are replayed verbatim at every level, prefixes
|
||||
stripped, so a quiet failure is still fully diagnosable — a quiet run must never be a
|
||||
silent one.
|
||||
"""
|
||||
if returncode != 0:
|
||||
print(
|
||||
f"ERROR: blender exited {returncode} after {elapsed:.2f}s "
|
||||
f"running {script}; full output follows",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for raw in stdout.splitlines():
|
||||
print(console.classify(raw)[1], file=sys.stderr)
|
||||
for line in stderr.splitlines():
|
||||
print(line, file=sys.stderr)
|
||||
return
|
||||
|
||||
for raw in stdout.splitlines():
|
||||
kind, text = console.classify(raw)
|
||||
if kind == "report":
|
||||
console.report(f" {text}")
|
||||
elif kind == "detail":
|
||||
console.detail(f" {text}")
|
||||
else:
|
||||
console.detail(raw)
|
||||
for line in stderr.splitlines():
|
||||
console.warn(line if line.startswith("WARN") else f"WARN: {line}")
|
||||
console.report(f" Blender: {script} ok in {elapsed:.2f}s (full log with --verbose)")
|
||||
|
||||
|
||||
def cmd_validate(revision_dir: Path, blender: bool) -> int:
|
||||
@@ -402,11 +468,12 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
"""Generate one independent texture per production view.
|
||||
"""Generate one owned 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.
|
||||
Each pair writes its own PNG/SVG/PDF. Every loop triangle is assigned to exactly one pair
|
||||
(`blender/scripts/texture_merge.py`) and each file emits only the fragments of the
|
||||
triangles it owns, so two files cannot ink the same surface. A seam margin keeps the two
|
||||
apart by more than the two-pass registration error.
|
||||
"""
|
||||
from orisys_sensor_design import texture_views
|
||||
|
||||
@@ -431,10 +498,8 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
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}")
|
||||
# The job path is a fact about an intermediate file, not a deliverable: verbose only.
|
||||
console.detail(f"Wrote multi-view texture job: {job_path}")
|
||||
|
||||
code = _run_blender_script(
|
||||
"random_patch_multiview.py",
|
||||
@@ -460,21 +525,72 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
return 3
|
||||
|
||||
artifact_count = sum(len(artifacts) for _, artifacts in targets)
|
||||
print(f"OK: wrote {artifact_count} multi-view artifact(s) to {output_dir}")
|
||||
if console.get_level() == console.QUIET:
|
||||
# A file list and nothing else, so stdout can be piped and emptiness is unambiguous.
|
||||
console.artifact_paths(
|
||||
path for _pair, artifacts in targets for path in artifacts.values()
|
||||
)
|
||||
return 0
|
||||
|
||||
# The decisions are read back from what the run itself wrote, not re-derived here, so the
|
||||
# console cannot describe a partition that did not happen.
|
||||
merge = job["merge"]
|
||||
gap = job["seam"]["gap"]
|
||||
margin = "off" if not gap["enabled"] else f"{gap['total_mm']:g}mm {gap['apply']}"
|
||||
console.report(f"OK: {len(pairs)} view(s) -> {artifact_count} file(s) in {output_dir}")
|
||||
console.report(
|
||||
f" merge: mode={merge['mode']} limit={merge['incidence_limit_deg']:g}deg margin={margin}"
|
||||
)
|
||||
|
||||
ground_truth = None
|
||||
truth_path = output_dir / MULTIVIEW_GROUND_TRUTH_FILENAME
|
||||
if truth_path.is_file():
|
||||
try:
|
||||
ground_truth = json.loads(truth_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
ground_truth = None
|
||||
if ground_truth:
|
||||
ownership = ground_truth.get("ownership") or {}
|
||||
described = len(ownership.get("owner") or {})
|
||||
total_triangles = len(ground_truth.get("triangle_vertices") or [])
|
||||
hole = ownership.get("hole_mm2")
|
||||
fraction = ownership.get("hole_fraction")
|
||||
if total_triangles:
|
||||
console.report(
|
||||
f" partition: own={described:,}/{total_triangles:,} triangles"
|
||||
+ (
|
||||
f" hole={hole:.3f}mm^2 ({fraction:.4f})"
|
||||
if isinstance(hole, (int, float)) and isinstance(fraction, (int, float))
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
print_views = None
|
||||
views_path = output_dir / MULTIVIEW_PRINT_VIEWS_FILENAME
|
||||
if views_path.is_file():
|
||||
try:
|
||||
print_views = json.loads(views_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print_views = None
|
||||
if print_views:
|
||||
# `region=` names the per-view camera role; the region camera OWNS triangles, the
|
||||
# pattern camera SUPPLIES fragments. Keeping the two nouns apart is what stops
|
||||
# 43,402 and 32,593 reading as a contradiction.
|
||||
by_id = {view.get("id"): view for view in print_views.get("views", [])}
|
||||
for pair, _artifacts in targets:
|
||||
view = by_id.get(pair.id) or {}
|
||||
console.report(
|
||||
f" [{pair.id}] region={pair.camera} -> {pair.uv_map}"
|
||||
f" owns {view.get('owned_triangles', 0):,} triangles,"
|
||||
f" emits {view.get('emitted_fragments', 0):,} fragments"
|
||||
)
|
||||
else:
|
||||
for pair, _artifacts in targets:
|
||||
console.report(f" [{pair.id}] region={pair.camera} -> {pair.uv_map}")
|
||||
|
||||
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)"
|
||||
)
|
||||
for _format_name, path in artifacts.items():
|
||||
console.report(f" [{pair.id}] file {path.name} {path.stat().st_size:,} B")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1829,8 +1945,31 @@ def _axes_arg(raw: str) -> list[str]:
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
# `--quiet` / `--verbose` are stripped here rather than added to all fourteen subparsers:
|
||||
# one place to keep consistent, and they work in any position, including before the
|
||||
# subcommand, which a per-subparser flag would not.
|
||||
raw = list(sys.argv[1:] if argv is None else argv)
|
||||
quiet = "--quiet" in raw
|
||||
verbose = "--verbose" in raw
|
||||
try:
|
||||
console.set_level(console.level_from_flags(quiet=quiet, verbose=verbose))
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
argv = [item for item in raw if item not in ("--quiet", "--verbose")]
|
||||
|
||||
parser = argparse.ArgumentParser(prog="orisys-design")
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Only the artifacts, one path per line (stripped before subcommand parsing)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Also print internals, the rebuilt Blender command and its full log",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene")
|
||||
@@ -1919,7 +2058,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
|
||||
texture_views_p = sub.add_parser(
|
||||
"texture-views",
|
||||
help="Generate one independent texture per production view (no merge)",
|
||||
help="Generate one owned texture per production view (partitioned, no double ink)",
|
||||
)
|
||||
texture_views_p.add_argument("revision_dir", type=Path)
|
||||
texture_views_p.add_argument(
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Console levels for the `orisys-design` CLI.
|
||||
|
||||
Three levels, one rule each:
|
||||
|
||||
* **default** — what was *decided* and what was *written*. Short enough to read at a glance,
|
||||
complete enough to answer "did it work, what did it decide, where are the files".
|
||||
* **--quiet** — stdout is exactly the artifact paths, one per line, nothing else. The output
|
||||
is a file list, so it can be piped, and empty stdout unambiguously means nothing was written.
|
||||
* **--verbose** — the default verbatim, plus everything the default elides. A superset, never
|
||||
a different contract.
|
||||
|
||||
Why the Blender side has to be told
|
||||
-----------------------------------
|
||||
The Blender scripts are separate processes and cannot see the argparse namespace, so the level
|
||||
is passed through the job's ``extra_args`` as ``--verbosity``. It is deliberately *not* a job
|
||||
JSON key: the job is a content-addressed record of what to build, and a verbosity flag in it
|
||||
would change the file between two runs that build the same thing.
|
||||
|
||||
Classification of a child process's output
|
||||
------------------------------------------
|
||||
The Blender side prefixes its lines so the parent can decide what to forward, because the
|
||||
child's stdout is otherwise indistinguishable from Blender's own chatter::
|
||||
|
||||
::report:: <line> scene facts — forwarded at default and above
|
||||
::detail:: <line> internals — forwarded at verbose only
|
||||
<anything else> Blender itself — forwarded at verbose only
|
||||
|
||||
On failure the parent replays **both** streams verbatim at every level, prefixes stripped, so
|
||||
a quiet failure is still fully diagnosable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
QUIET = 0
|
||||
DEFAULT = 1
|
||||
VERBOSE = 2
|
||||
|
||||
#: Kept in step with the driver by `test_console_contract.py`; the two processes cannot share
|
||||
#: a module because one runs under Blender and the host is stdlib-only.
|
||||
REPORT_PREFIX = "::report::"
|
||||
DETAIL_PREFIX = "::detail::"
|
||||
|
||||
_level = DEFAULT
|
||||
|
||||
|
||||
def set_level(level: int) -> None:
|
||||
global _level
|
||||
_level = level
|
||||
|
||||
|
||||
def get_level() -> int:
|
||||
return _level
|
||||
|
||||
|
||||
def verbosity_name(level: int | None = None) -> str:
|
||||
"""The level as a word, for handing to a child process through the environment."""
|
||||
value = _level if level is None else level
|
||||
return {QUIET: "quiet", DEFAULT: "default", VERBOSE: "verbose"}.get(value, "default")
|
||||
|
||||
|
||||
def level_from_name(name: str | None) -> int:
|
||||
"""Parse `ORISYS_CONSOLE_LEVEL`. Unknown values fall back to default, never to quiet."""
|
||||
return {"quiet": QUIET, "verbose": VERBOSE}.get((name or "").strip().lower(), DEFAULT)
|
||||
|
||||
|
||||
def level_from_flags(quiet: bool = False, verbose: bool = False) -> int:
|
||||
"""Map argparse flags to a level. `--quiet` wins, and the two are mutually exclusive."""
|
||||
if quiet and verbose:
|
||||
raise ValueError("--quiet and --verbose are mutually exclusive")
|
||||
if quiet:
|
||||
return QUIET
|
||||
if verbose:
|
||||
return VERBOSE
|
||||
return DEFAULT
|
||||
|
||||
|
||||
def report(message: str) -> None:
|
||||
"""A decision or fact worth seeing by default."""
|
||||
if _level >= DEFAULT:
|
||||
print(message)
|
||||
|
||||
|
||||
def detail(message: str) -> None:
|
||||
"""An internal, elided unless `--verbose`."""
|
||||
if _level >= VERBOSE:
|
||||
print(message)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
"""Non-fatal, always on stderr, at every level."""
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def artifact_paths(paths) -> None:
|
||||
"""`--quiet` stdout: the artifact list and nothing else, in order."""
|
||||
for path in paths:
|
||||
print(path)
|
||||
|
||||
|
||||
def classify(line: str) -> tuple[str, str]:
|
||||
"""Split a child-process line into ``(kind, text)``.
|
||||
|
||||
``kind`` is ``"report"``, ``"detail"`` or ``"raw"`` (Blender's own output).
|
||||
"""
|
||||
if line.startswith(REPORT_PREFIX):
|
||||
return "report", line[len(REPORT_PREFIX):].lstrip()
|
||||
if line.startswith(DETAIL_PREFIX):
|
||||
return "detail", line[len(DETAIL_PREFIX):].lstrip()
|
||||
return "raw", line
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT",
|
||||
"DETAIL_PREFIX",
|
||||
"QUIET",
|
||||
"REPORT_PREFIX",
|
||||
"VERBOSE",
|
||||
"artifact_paths",
|
||||
"classify",
|
||||
"detail",
|
||||
"get_level",
|
||||
"level_from_flags",
|
||||
"level_from_name",
|
||||
"report",
|
||||
"set_level",
|
||||
"verbosity_name",
|
||||
"warn",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Multi-view production textures: one independent texture per production UV layout.
|
||||
"""Multi-view production textures: one owned texture per production UV layout.
|
||||
|
||||
Scope — **step 1 only**. Each pair generates its own PNG/SVG/PDF, with:
|
||||
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;
|
||||
@@ -63,8 +63,68 @@ 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"})
|
||||
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs", "merge", "seam", "registration",
|
||||
"print"})
|
||||
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed", "pattern_camera"})
|
||||
_MERGE_KEYS = frozenset({
|
||||
"mode", "incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
|
||||
"max_hole_fraction", "component_policy",
|
||||
})
|
||||
_SEAM_KEYS = frozenset({"boundary", "pins", "gap"})
|
||||
_GAP_KEYS = frozenset({"enabled", "apply", "treatment", "alignment_tolerance_mm", "total_mm"})
|
||||
|
||||
#: How the margin is applied.
|
||||
#:
|
||||
#: ``post_process`` clip the already-generated UV polygons against the seam's offset
|
||||
#: half-space. Exact, straight-edged, and the width is the requested
|
||||
#: *physical* distance because the half-space is derived in the surface and
|
||||
#: mapped through the triangle's own Jacobian. Default.
|
||||
#: ``geometry_trim`` drop every fragment that comes within the margin. Cheap, but a fragment
|
||||
#: is kept or dropped whole, so the edge snaps to fragment boundaries and
|
||||
#: the band comes out sawtoothed and 0-to-one-fragment too wide. Kept for
|
||||
#: comparison and for the record.
|
||||
#:
|
||||
#: ``no_print`` the band carries nothing. Implemented.
|
||||
#: ``reduced_density`` a coarser/dithered pattern in the band so the two passes blend instead
|
||||
#: of butting together. Accepted by the schema, refused at resolve time
|
||||
#: until it exists, so it cannot silently do nothing.
|
||||
GAP_APPLY_MODES = ("post_process", "geometry_trim")
|
||||
GAP_DEFAULT_APPLY = "post_process"
|
||||
GAP_TREATMENTS = ("no_print", "reduced_density")
|
||||
GAP_DEFAULT_TREATMENT = "no_print"
|
||||
GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM = 0.1
|
||||
_PRINT_KEYS = frozenset({"base_fill"})
|
||||
|
||||
#: What is drawn under the colour patches, i.e. the white ground the marker sits on.
|
||||
#:
|
||||
#: Every artifact also carries a full-page black `<rect id="background-fill">`. That
|
||||
#: background *is* a print element, so it must not be confused with "no print": the only way
|
||||
#: to keep a region unprinted is to leave it at the background, and the only way to make it
|
||||
#: print white is to draw the mesh layer there.
|
||||
#:
|
||||
#: ``owned`` white mesh layer over every triangle this view owns. The default: it is the
|
||||
#: same white ground the single-view `compute` texture carries, **split by the
|
||||
#: seam**, so the two files tile the part without either one painting white over
|
||||
#: the other's territory.
|
||||
#: ``inked`` white only under triangles that actually receive a patch. Narrower ground;
|
||||
#: leaves owned-but-unpatterned surface at the background.
|
||||
#: ``none`` no white layer at all. Only the coloured patches print. Use when no white
|
||||
#: ground is wanted; note that the region then reads as the black background and
|
||||
#: is indistinguishable from the split mask.
|
||||
BASE_FILL_MODES = ("none", "inked", "owned")
|
||||
PRINT_DEFAULTS = {"base_fill": "owned"}
|
||||
|
||||
#: Mirrors `blender/scripts/texture_merge.py`, which owns the real defaults. Duplicated
|
||||
#: here (rather than imported) because the host is stdlib-only and must not import a
|
||||
#: Blender-side module; a test asserts the two agree.
|
||||
MERGE_DEFAULTS = {
|
||||
"mode": "computed",
|
||||
"incidence_limit_deg": 62.0,
|
||||
"min_footprint_mm2": 2.0e-3,
|
||||
"min_triangle_coverage": 0.60,
|
||||
"max_hole_fraction": 0.02,
|
||||
"component_policy": "best_coverage",
|
||||
}
|
||||
|
||||
#: 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.
|
||||
@@ -77,14 +137,17 @@ class ViewPairConfigError(TextureConfigError):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViewPair:
|
||||
"""One production UV layout, and the pattern camera whose cells colour it.
|
||||
"""One production view: its region camera, its UV layout, and the pattern source.
|
||||
|
||||
``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.
|
||||
``camera`` is the **region** camera — the production view that decides which surface this
|
||||
pair may own (via visibility and incidence). ``pattern_camera`` is where the ink comes
|
||||
from, normally the sensing camera, so every layout carries the same surface pattern.
|
||||
``uv_map`` is the layout the pattern is written into.
|
||||
"""
|
||||
|
||||
id: str
|
||||
camera: str
|
||||
pattern_camera: str
|
||||
uv_map: str
|
||||
target: TextureOutputTarget
|
||||
seed: int | None
|
||||
@@ -93,6 +156,7 @@ class ViewPair:
|
||||
return {
|
||||
"id": self.id,
|
||||
"camera": self.camera,
|
||||
"pattern_camera": self.pattern_camera,
|
||||
"uv_map": self.uv_map,
|
||||
"seed": self.seed,
|
||||
"artifacts": {
|
||||
@@ -150,6 +214,7 @@ def validate_views_block(
|
||||
seen_ids: set[str] = set()
|
||||
seen_files: dict[str, str] = {}
|
||||
seen_camera_layout: dict[tuple[str, str], str] = {}
|
||||
seen_region: dict[str, str] = {}
|
||||
for index, pair in enumerate(pairs):
|
||||
where = f"{VIEWS_BLOCK_KEY}.pairs[{index}]"
|
||||
if not isinstance(pair, Mapping):
|
||||
@@ -160,18 +225,16 @@ def validate_views_block(
|
||||
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"):
|
||||
# `camera` is the REGION camera: the production view that decides what this pair may
|
||||
# own, so it is required. The pattern source is separate (`pattern_camera`).
|
||||
for key in ("id", "camera", "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")
|
||||
errors.append(f"{where}.{key}: must be a non-empty object name/string")
|
||||
if "pattern_camera" in pair:
|
||||
value = pair.get("pattern_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)"
|
||||
)
|
||||
errors.append(f"{where}.pattern_camera: must be a non-empty object name")
|
||||
pair_id = pair.get("id")
|
||||
if isinstance(pair_id, str) and pair_id.strip():
|
||||
if pair_id in seen_ids:
|
||||
@@ -192,9 +255,21 @@ def validate_views_block(
|
||||
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 "?"
|
||||
# Two pairs sharing a REGION camera claim the same surface, and ownership would hand
|
||||
# everything to whichever comes first in priority order while the other writes an
|
||||
# empty texture. That is always a config mistake, not a subtlety.
|
||||
region = pair.get("camera")
|
||||
if isinstance(region, str) and region.strip():
|
||||
if region in seen_region:
|
||||
errors.append(
|
||||
f"{where}.camera: region camera {region!r} is already used by pair "
|
||||
f"{seen_region[region]!r}; both would claim the same surface region and "
|
||||
"one of them would own nothing"
|
||||
)
|
||||
seen_region[region] = str(pair_id)
|
||||
# Compare on the *effective* pattern 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("pattern_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:
|
||||
@@ -204,6 +279,149 @@ def validate_views_block(
|
||||
"would be identical"
|
||||
)
|
||||
seen_camera_layout[key] = str(pair_id)
|
||||
|
||||
errors.extend(validate_merge_block(block.get("merge")))
|
||||
errors.extend(validate_seam_block(block.get("seam")))
|
||||
errors.extend(validate_print_block(block.get("print")))
|
||||
return errors
|
||||
|
||||
|
||||
def validate_print_block(print_block: Any) -> list[str]:
|
||||
"""Validate the optional ``print`` sub-block (what is deposited where)."""
|
||||
if print_block is None:
|
||||
return []
|
||||
where = f"{VIEWS_BLOCK_KEY}.print"
|
||||
if not isinstance(print_block, Mapping):
|
||||
return [f"{where}: must be a mapping"]
|
||||
errors: list[str] = []
|
||||
extra = sorted(set(print_block) - _PRINT_KEYS)
|
||||
if extra:
|
||||
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_PRINT_KEYS)}")
|
||||
mode = print_block.get("base_fill", PRINT_DEFAULTS["base_fill"])
|
||||
if mode not in BASE_FILL_MODES:
|
||||
errors.append(f"{where}.base_fill: must be one of {list(BASE_FILL_MODES)} (got {mode!r})")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_merge_block(merge: Any) -> list[str]:
|
||||
"""Validate the optional ``merge`` sub-block (ownership policy)."""
|
||||
if merge is None:
|
||||
return []
|
||||
errors: list[str] = []
|
||||
where = f"{VIEWS_BLOCK_KEY}.merge"
|
||||
if not isinstance(merge, Mapping):
|
||||
return [f"{where}: must be a mapping"]
|
||||
extra = sorted(set(merge) - _MERGE_KEYS)
|
||||
if extra:
|
||||
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_MERGE_KEYS)}")
|
||||
mode = merge.get("mode", MERGE_DEFAULTS["mode"])
|
||||
if mode not in ("computed", "authored"):
|
||||
errors.append(f"{where}.mode: must be 'computed' or 'authored' (got {mode!r})")
|
||||
component_policy = merge.get("component_policy", MERGE_DEFAULTS["component_policy"])
|
||||
if component_policy not in ("best_coverage", "pinned"):
|
||||
errors.append(
|
||||
f"{where}.component_policy: must be 'best_coverage' or 'pinned' "
|
||||
f"(got {component_policy!r})"
|
||||
)
|
||||
for key in ("incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
|
||||
"max_hole_fraction"):
|
||||
if key in merge and not isinstance(merge[key], (int, float)):
|
||||
errors.append(f"{where}.{key}: must be a number")
|
||||
for key in ("min_triangle_coverage", "max_hole_fraction"):
|
||||
value = merge.get(key)
|
||||
if isinstance(value, (int, float)) and not 0.0 <= float(value) <= 1.0:
|
||||
errors.append(f"{where}.{key}: must be in [0, 1] (got {value!r})")
|
||||
limit = merge.get("incidence_limit_deg")
|
||||
if isinstance(limit, (int, float)) and not 0.0 <= float(limit) <= 90.0:
|
||||
errors.append(f"{where}.incidence_limit_deg: must be in [0, 90] (got {limit!r})")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_seam_block(seam: Any) -> list[str]:
|
||||
"""Validate the optional ``seam`` sub-block (authored cut and its margin)."""
|
||||
if seam is None:
|
||||
return []
|
||||
where = f"{VIEWS_BLOCK_KEY}.seam"
|
||||
if not isinstance(seam, Mapping):
|
||||
return [f"{where}: must be a mapping"]
|
||||
errors: list[str] = []
|
||||
extra = sorted(set(seam) - _SEAM_KEYS)
|
||||
if extra:
|
||||
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_SEAM_KEYS)}")
|
||||
boundary = seam.get("boundary", "use_seam")
|
||||
if boundary != "use_seam":
|
||||
errors.append(f"{where}.boundary: only 'use_seam' is supported (got {boundary!r})")
|
||||
errors.extend(validate_gap_block(seam.get("gap")))
|
||||
pins = seam.get("pins") or []
|
||||
if not isinstance(pins, list):
|
||||
errors.append(f"{where}.pins: must be a list")
|
||||
return errors
|
||||
seen: set[int] = set()
|
||||
for index, pin in enumerate(pins):
|
||||
pat = f"{where}.pins[{index}]"
|
||||
if not isinstance(pin, Mapping):
|
||||
errors.append(f"{pat}: must be a mapping")
|
||||
continue
|
||||
extra = sorted(set(pin) - {"component", "view", "reason"})
|
||||
if extra:
|
||||
errors.append(f"{pat}: unknown key(s) {extra}; allowed: ['component', 'reason', 'view']")
|
||||
component = pin.get("component")
|
||||
if not isinstance(component, int) or isinstance(component, bool) or component < 0:
|
||||
errors.append(f"{pat}.component: must be a non-negative integer")
|
||||
elif component in seen:
|
||||
errors.append(f"{pat}.component: duplicate pin for component {component}")
|
||||
else:
|
||||
seen.add(component)
|
||||
view = pin.get("view")
|
||||
if not isinstance(view, str) or not view.strip():
|
||||
errors.append(f"{pat}.view: must be a non-empty view id")
|
||||
reason = pin.get("reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
errors.append(f"{pat}.reason: must be a non-empty string (say why)")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_gap_block(gap: Any) -> list[str]:
|
||||
"""Validate the optional ``seam.gap`` sub-block (the margin along the seam).
|
||||
|
||||
``gap: false`` disables the margin explicitly; omitting the block enables it at the
|
||||
default alignment tolerance.
|
||||
"""
|
||||
if gap is None:
|
||||
return []
|
||||
where = f"{VIEWS_BLOCK_KEY}.seam.gap"
|
||||
if gap is False:
|
||||
return []
|
||||
if not isinstance(gap, Mapping):
|
||||
return [f"{where}: must be a mapping, or false to disable"]
|
||||
errors: list[str] = []
|
||||
extra = sorted(set(gap) - _GAP_KEYS)
|
||||
if extra:
|
||||
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_GAP_KEYS)}")
|
||||
if "enabled" in gap and not isinstance(gap["enabled"], bool):
|
||||
errors.append(f"{where}.enabled: must be true or false")
|
||||
apply_mode = gap.get("apply", GAP_DEFAULT_APPLY)
|
||||
if apply_mode not in GAP_APPLY_MODES:
|
||||
errors.append(f"{where}.apply: must be one of {list(GAP_APPLY_MODES)} (got {apply_mode!r})")
|
||||
treatment = gap.get("treatment", GAP_DEFAULT_TREATMENT)
|
||||
if treatment not in GAP_TREATMENTS:
|
||||
errors.append(
|
||||
f"{where}.treatment: must be one of {list(GAP_TREATMENTS)} (got {treatment!r})"
|
||||
)
|
||||
tolerance = gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM)
|
||||
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
|
||||
errors.append(f"{where}.alignment_tolerance_mm: must be a number")
|
||||
elif float(tolerance) <= 0.0:
|
||||
errors.append(
|
||||
f"{where}.alignment_tolerance_mm: must be > 0 (got {tolerance!r}); "
|
||||
"use gap: false or omit the block for no margin"
|
||||
)
|
||||
total = gap.get("total_mm")
|
||||
if total is not None:
|
||||
if not isinstance(total, (int, float)) or isinstance(total, bool):
|
||||
errors.append(f"{where}.total_mm: must be a number or null")
|
||||
elif float(total) <= 0.0:
|
||||
errors.append(f"{where}.total_mm: must be > 0 (got {total!r})")
|
||||
return errors
|
||||
|
||||
|
||||
@@ -232,8 +450,8 @@ def resolve_view_pairs(
|
||||
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`.
|
||||
# View) and define each pair's REGION for ownership; neither is the pattern source, so
|
||||
# the pattern 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] = []
|
||||
@@ -247,7 +465,8 @@ def resolve_view_pairs(
|
||||
pairs.append(
|
||||
ViewPair(
|
||||
id=pair_id,
|
||||
camera=str(raw.get("camera") or pattern_camera),
|
||||
camera=str(raw["camera"]),
|
||||
pattern_camera=str(raw.get("pattern_camera") or pattern_camera),
|
||||
uv_map=str(raw["uv_map"]),
|
||||
target=TextureOutputTarget(
|
||||
uv_map=str(raw["uv_map"]),
|
||||
@@ -259,16 +478,95 @@ def resolve_view_pairs(
|
||||
return tuple(pairs)
|
||||
|
||||
|
||||
def resolve_merge_policy(block: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Merge the optional ``merge`` sub-block over the calibrated defaults."""
|
||||
policy = dict(MERGE_DEFAULTS)
|
||||
if block:
|
||||
merge = block.get("merge") or {}
|
||||
if isinstance(merge, Mapping):
|
||||
for key in _MERGE_KEYS:
|
||||
if key in merge:
|
||||
policy[key] = merge[key]
|
||||
return policy
|
||||
|
||||
|
||||
def resolve_seam_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Resolve the optional ``seam`` sub-block into the job's seam settings.
|
||||
|
||||
The margin defaults to ON at the 0.1 mm alignment tolerance, because a band along the
|
||||
seam is what absorbs two-pass registration error. ``seam.gap: false`` turns it off.
|
||||
"""
|
||||
seam = (block or {}).get("seam") or {}
|
||||
pins = seam.get("pins") or []
|
||||
raw_gap = seam.get("gap")
|
||||
if raw_gap is False:
|
||||
gap_settings = {
|
||||
"enabled": False,
|
||||
"requested": False,
|
||||
"apply": GAP_DEFAULT_APPLY,
|
||||
"treatment": GAP_DEFAULT_TREATMENT,
|
||||
"alignment_tolerance_mm": 0.0,
|
||||
"total_mm": 0.0,
|
||||
"inset_mm": 0.0,
|
||||
}
|
||||
else:
|
||||
gap = raw_gap if isinstance(raw_gap, Mapping) else {}
|
||||
treatment = str(gap.get("treatment", GAP_DEFAULT_TREATMENT))
|
||||
if treatment != "no_print":
|
||||
raise ViewPairConfigError(
|
||||
f"{VIEWS_BLOCK_KEY}.seam.gap.treatment: {treatment!r} is accepted by the "
|
||||
"schema but is not implemented yet; only 'no_print' does anything today. "
|
||||
"Refusing rather than silently treating the band as 'no_print'."
|
||||
)
|
||||
apply_mode = str(gap.get("apply", GAP_DEFAULT_APPLY))
|
||||
tolerance = float(gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM))
|
||||
total = gap.get("total_mm")
|
||||
# Two passes each placed to within `tolerance` can close on each other by twice that,
|
||||
# so the band that guarantees no overlap is 2 x tolerance wide, half per file.
|
||||
total_mm = (2.0 * tolerance) if total is None else float(total)
|
||||
requested = bool(gap.get("enabled", True))
|
||||
gap_settings = {
|
||||
"enabled": requested and total_mm > 0.0,
|
||||
"requested": requested,
|
||||
"apply": apply_mode,
|
||||
"treatment": treatment,
|
||||
"alignment_tolerance_mm": tolerance,
|
||||
"total_mm": total_mm,
|
||||
"inset_mm": total_mm / 2.0,
|
||||
}
|
||||
return {
|
||||
"boundary": seam.get("boundary", "use_seam"),
|
||||
"gap": gap_settings,
|
||||
"pins": [
|
||||
{"component": int(pin["component"]), "view": str(pin["view"]),
|
||||
"reason": str(pin.get("reason", ""))}
|
||||
for pin in pins
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def resolve_print_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Resolve the optional ``print`` sub-block into the job's print settings."""
|
||||
settings = dict(PRINT_DEFAULTS)
|
||||
sub = (block or {}).get("print") or {}
|
||||
if isinstance(sub, Mapping) and "base_fill" in sub:
|
||||
settings["base_fill"] = str(sub["base_fill"])
|
||||
return settings
|
||||
|
||||
|
||||
def build_multiview_job(
|
||||
paths: RevisionPaths,
|
||||
settings: TexturePatchSettings,
|
||||
pairs: Sequence[ViewPair],
|
||||
output_dir: Path,
|
||||
block: Mapping[str, Any] | None = None,
|
||||
) -> 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})
|
||||
if block is None:
|
||||
block = load_views_block(paths)
|
||||
pattern_cameras = sorted({pair.pattern_camera for pair in pairs})
|
||||
return {
|
||||
"schema": MULTIVIEW_JOB_SCHEMA,
|
||||
"mode": "patches-multiview",
|
||||
@@ -276,8 +574,11 @@ def build_multiview_job(
|
||||
"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.
|
||||
# production cameras merely chose the layouts and own the regions.
|
||||
"pattern_camera": pattern_cameras[0] if len(pattern_cameras) == 1 else pattern_cameras,
|
||||
"merge": resolve_merge_policy(block),
|
||||
"seam": resolve_seam_settings(block),
|
||||
"print": resolve_print_settings(block),
|
||||
"settings": settings.to_job_settings(),
|
||||
"pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user