382 lines
13 KiB
Python
382 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Quick top-view visualizer for camera paths.
|
|
|
|
Example:
|
|
py -3 nuke_CAM_exporter/testing_tools/plot_top_view.py ^
|
|
--config nuke_CAM_exporter/configs/camera_config_v01.json ^
|
|
--output nuke_CAM_exporter/testing_tools/top_view.png
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Sequence, Tuple
|
|
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib import patches
|
|
|
|
NUKE_SWAP_AXES = True
|
|
NUKE_INVERT_HORIZONTAL = True
|
|
NUKE_INVERT_VERTICAL = True
|
|
NUKE_HORIZONTAL_LABEL = "+Z (frame left) <- -> -Z (frame right)"
|
|
NUKE_VERTICAL_LABEL = "+X (frame down) v ^ -X (frame up)"
|
|
|
|
|
|
def euler_zxy_to_matrix(rx_rad: float, ry_rad: float, rz_rad: float) -> List[List[float]]:
|
|
sx, cx = math.sin(rx_rad), math.cos(rx_rad)
|
|
sy, cy = math.sin(ry_rad), math.cos(ry_rad)
|
|
sz, cz = math.sin(rz_rad), math.cos(rz_rad)
|
|
|
|
Rx = (
|
|
(1.0, 0.0, 0.0),
|
|
(0.0, cx, -sx),
|
|
(0.0, sx, cx),
|
|
)
|
|
Ry = (
|
|
(cy, 0.0, sy),
|
|
(0.0, 1.0, 0.0),
|
|
(-sy, 0.0, cy),
|
|
)
|
|
Rz = (
|
|
(cz, -sz, 0.0),
|
|
(sz, cz, 0.0),
|
|
(0.0, 0.0, 1.0),
|
|
)
|
|
|
|
def mat_mul(a, b):
|
|
return [
|
|
[
|
|
a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c]
|
|
for c in range(3)
|
|
]
|
|
for r in range(3)
|
|
]
|
|
|
|
return mat_mul(Ry, mat_mul(Rx, Rz))
|
|
|
|
|
|
def load_track_samples(
|
|
translation_path: Path,
|
|
rotation_path: Path | None,
|
|
) -> List[dict]:
|
|
samples: List[dict] = []
|
|
|
|
def append_sample(tx: float, ty: float, tz: float, rx_deg: float | None, ry_deg: float | None, rz_deg: float | None):
|
|
forward = None
|
|
if rx_deg is not None and ry_deg is not None and rz_deg is not None:
|
|
R = euler_zxy_to_matrix(
|
|
math.radians(rx_deg),
|
|
math.radians(ry_deg),
|
|
math.radians(rz_deg),
|
|
)
|
|
# Camera in Nuke looks toward -Z (viewing down the negative Z axis)
|
|
forward = (-R[0][2], -R[1][2], -R[2][2])
|
|
samples.append({"translation": (tx, ty, tz), "forward": forward})
|
|
|
|
lines = [l.strip() for l in translation_path.read_text().splitlines() if l.strip()]
|
|
if rotation_path:
|
|
rot_lines = [l.strip() for l in rotation_path.read_text().splitlines() if l.strip()]
|
|
if len(lines) != len(rot_lines):
|
|
raise ValueError("Translation and rotation ASCI files must have the same number of lines.")
|
|
for t_line, r_line in zip(lines, rot_lines):
|
|
tx, ty, tz = map(float, t_line.split()[:3])
|
|
rx_deg, ry_deg, rz_deg = map(float, r_line.split()[:3])
|
|
append_sample(tx, ty, tz, rx_deg, ry_deg, rz_deg)
|
|
else:
|
|
for line in lines:
|
|
parts = line.split()
|
|
if len(parts) < 6:
|
|
raise ValueError(
|
|
"Combined ASCI expected 6 columns per line (Tx Rx Ty Ry Tz Rz). "
|
|
f"Got {len(parts)} columns: {line}"
|
|
)
|
|
tx = float(parts[0])
|
|
rx_deg = float(parts[1])
|
|
ty = float(parts[2])
|
|
ry_deg = float(parts[3])
|
|
tz = float(parts[4])
|
|
rz_deg = float(parts[5])
|
|
append_sample(tx, ty, tz, rx_deg, ry_deg, rz_deg)
|
|
|
|
return samples
|
|
|
|
|
|
def parse_pose_file(path: Path, limit: int | None) -> List[dict]:
|
|
rows: List[dict] = []
|
|
lines = [l.strip() for l in path.read_text().splitlines() if l.strip()]
|
|
for line in lines[1:]: # skip header
|
|
values = list(map(float, line.split()))
|
|
if len(values) != 19:
|
|
raise ValueError(f"Expected 19 columns per pose row, got {len(values)}")
|
|
rotation = [
|
|
[values[7], values[8], values[9]],
|
|
[values[11], values[12], values[13]],
|
|
[values[15], values[16], values[17]],
|
|
]
|
|
translation = (values[10], values[14], values[18])
|
|
rows.append({"R": rotation, "t": translation})
|
|
if limit is not None and len(rows) >= limit:
|
|
break
|
|
return rows
|
|
|
|
|
|
def scale_rows(rows: Iterable[Tuple[float, float, float]], scale: float) -> List[Tuple[float, float, float]]:
|
|
if scale == 1.0:
|
|
return list(rows)
|
|
return [(x * scale, y * scale, z * scale) for x, y, z in rows]
|
|
|
|
|
|
def split_axes(rows: Sequence[Tuple[float, float, float]]) -> Tuple[List[float], List[float], List[float]]:
|
|
xs, ys, zs = [], [], []
|
|
for x, y, z in rows:
|
|
xs.append(x)
|
|
ys.append(y)
|
|
zs.append(z)
|
|
return xs, ys, zs
|
|
|
|
|
|
def load_config(path: Path | None) -> dict:
|
|
if not path:
|
|
return {}
|
|
data = json.loads(path.read_text())
|
|
data["_config_dir"] = path.parent
|
|
return data
|
|
|
|
|
|
def resolve_path(config: dict, key: str) -> Path | None:
|
|
value = config.get(key)
|
|
if value is None:
|
|
return None
|
|
candidate = Path(value)
|
|
if not candidate.is_absolute() and "_config_dir" in config:
|
|
candidate = (config["_config_dir"] / candidate).resolve()
|
|
return candidate
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Top-view plot of camera path (Nuke-style top view).")
|
|
parser.add_argument("--config", type=Path, help="Optional JSON config (same format as converter).")
|
|
parser.add_argument("--translation", type=Path, help="Translation ASCI file (Tx Ty Tz).")
|
|
parser.add_argument("--rotation", type=Path, help="Rotation ASCI file (Rx Ry Rz) when exports are split.")
|
|
parser.add_argument(
|
|
"--pose",
|
|
type=Path,
|
|
help="Converted pose TXT (to overlay world-to-camera translations). Optional.",
|
|
)
|
|
parser.add_argument("--unit-scale", type=float, default=None, help="Scale to apply to translation vectors.")
|
|
parser.add_argument("--limit", type=int, help="Maximum number of frames to plot.")
|
|
parser.add_argument("--output", type=Path, help="Optional PNG to save instead of showing interactively.")
|
|
parser.add_argument("--show", action="store_true", help="Force interactive window even when saving.")
|
|
square = parser.add_argument_group("workspace overlays")
|
|
square.add_argument(
|
|
"--square-size",
|
|
type=float,
|
|
help="Explicit side length for centered square (meters). Overrides auto sizing.",
|
|
)
|
|
square.add_argument(
|
|
"--square-padding",
|
|
type=float,
|
|
default=0.15,
|
|
help="Extra fractional padding when auto-sizing the square (default 0.15).",
|
|
)
|
|
square.add_argument(
|
|
"--square-color",
|
|
default="#00b894",
|
|
help="Line color for the square/highlight overlays.",
|
|
)
|
|
square.add_argument(
|
|
"--highlight-quadrant",
|
|
choices=["++", "+-", "-+", "--"],
|
|
default="+-",
|
|
help="Quadrant to shade (horizontal sign first, default '+-').",
|
|
)
|
|
square.add_argument(
|
|
"--highlight-alpha",
|
|
type=float,
|
|
default=0.15,
|
|
help="Alpha used for quadrant shading.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
|
|
config = load_config(args.config)
|
|
|
|
def resolved_path(cli_value: Path | None, config_key: str) -> Path | None:
|
|
if cli_value:
|
|
return cli_value.resolve()
|
|
return resolve_path(config, config_key)
|
|
|
|
translation_path = resolved_path(args.translation, "input_translation")
|
|
rotation_path = resolved_path(args.rotation, "input_rotation")
|
|
if translation_path is None:
|
|
translation_path = resolved_path(None, "input")
|
|
rotation_path = None
|
|
if translation_path is None:
|
|
parser.error("Translation source missing (pass --translation or provide input/_translation in config).")
|
|
|
|
pose_path = resolved_path(args.pose, "output")
|
|
unit_scale = args.unit_scale if args.unit_scale is not None else config.get("unit_scale", 1.0)
|
|
|
|
frame_cfg = config.get("frame_range", {})
|
|
start_idx = frame_cfg.get("start", 0)
|
|
limit = args.limit
|
|
if limit is None:
|
|
limit = frame_cfg.get("max_frames")
|
|
if limit is None and frame_cfg.get("end") is not None:
|
|
limit = max(0, frame_cfg["end"] - start_idx)
|
|
|
|
track_samples = load_track_samples(translation_path, rotation_path)
|
|
track_samples = track_samples[start_idx:]
|
|
if limit is not None:
|
|
track_samples = track_samples[:limit]
|
|
|
|
for sample in track_samples:
|
|
tx, ty, tz = sample["translation"]
|
|
sample["translation"] = (tx * unit_scale, ty * unit_scale, tz * unit_scale)
|
|
|
|
translations_scaled = [sample["translation"] for sample in track_samples]
|
|
tx, _, tz = split_axes(translations_scaled)
|
|
|
|
def orient_axes(xs: Sequence[float], zs: Sequence[float]) -> Tuple[List[float], List[float]]:
|
|
horiz, vert = (list(zs), list(xs)) if NUKE_SWAP_AXES else (list(xs), list(zs))
|
|
if NUKE_INVERT_HORIZONTAL:
|
|
horiz = [-val for val in horiz]
|
|
if NUKE_INVERT_VERTICAL:
|
|
vert = [-val for val in vert]
|
|
return horiz, vert
|
|
|
|
def orient_point(x: float, z: float) -> Tuple[float, float]:
|
|
hx, hy = orient_axes([x], [z])
|
|
return hx[0], hy[0]
|
|
|
|
hx, hy = orient_axes(tx, tz)
|
|
track_screen_points = list(zip(hx, hy))
|
|
|
|
fig, ax = plt.subplots(figsize=(8, 6))
|
|
ax.axhline(0.0, color="#666666", linewidth=0.8, alpha=0.6)
|
|
ax.axvline(0.0, color="#666666", linewidth=0.8, alpha=0.6)
|
|
ax.scatter([0], [0], c="#222222", s=20, marker="x", label="Origin", zorder=5)
|
|
|
|
ax.scatter(hx, hy, s=12, c="tab:green", label="ASCI center (scaled)")
|
|
ax.plot(hx, hy, color="tab:green", alpha=0.3)
|
|
|
|
pose_rows: List[dict] | None = None
|
|
pose_screen_points: List[Tuple[float, float]] | None = None
|
|
if pose_path:
|
|
pose_rows = parse_pose_file(pose_path, None)
|
|
pose_rows = pose_rows[start_idx:]
|
|
if limit is not None:
|
|
pose_rows = pose_rows[:limit]
|
|
pose_translations = [row["t"] for row in pose_rows]
|
|
px, _, pz = split_axes(pose_translations)
|
|
phx, phy = orient_axes(px, pz)
|
|
pose_screen_points = list(zip(phx, phy))
|
|
ax.scatter(phx, phy, s=12, c="tab:purple", label="Pose t (world-to-camera)")
|
|
ax.plot(phx, phy, color="tab:purple", alpha=0.3)
|
|
|
|
ax.set_xlabel(NUKE_HORIZONTAL_LABEL)
|
|
ax.set_ylabel(NUKE_VERTICAL_LABEL)
|
|
|
|
if hx and hy:
|
|
if args.square_size is not None:
|
|
half = args.square_size / 2.0
|
|
else:
|
|
max_extent = max(max(abs(val) for val in hx), max(abs(val) for val in hy), 1e-6)
|
|
half = max_extent * (1.0 + args.square_padding)
|
|
ax.set_xlim(-half, half)
|
|
ax.set_ylim(-half, half)
|
|
square = patches.Rectangle(
|
|
(-half, -half),
|
|
half * 2,
|
|
half * 2,
|
|
linewidth=1.2,
|
|
edgecolor=args.square_color,
|
|
facecolor="none",
|
|
linestyle="-",
|
|
zorder=2,
|
|
)
|
|
ax.add_patch(square)
|
|
|
|
if args.highlight_quadrant:
|
|
x0 = 0.0 if args.highlight_quadrant[0] == "+" else -half
|
|
y0 = 0.0 if args.highlight_quadrant[1] == "+" else -half
|
|
width = half
|
|
height = half
|
|
highlight = patches.Rectangle(
|
|
(x0, y0),
|
|
width,
|
|
height,
|
|
facecolor=args.square_color,
|
|
alpha=args.highlight_alpha,
|
|
edgecolor="none",
|
|
zorder=1,
|
|
)
|
|
ax.add_patch(highlight)
|
|
pad = half * 0.04
|
|
label_kwargs = dict(color="#414141", fontsize=9, fontweight="semibold")
|
|
ax.text(-half + pad, pad, "+Z", ha="left", va="bottom", **label_kwargs)
|
|
ax.text(half - pad, pad, "-Z", ha="right", va="bottom", **label_kwargs)
|
|
ax.text(-pad, -half + pad, "+X", ha="right", va="top", **label_kwargs)
|
|
ax.text(-pad, half - pad, "-X", ha="right", va="bottom", **label_kwargs)
|
|
nuke_forward_samples = [s for s in track_samples if s["forward"] is not None]
|
|
if nuke_forward_samples:
|
|
arrow_scale = max(half * 0.2, 0.05)
|
|
head_width = half * 0.05
|
|
head_length = half * 0.08
|
|
for idx, label in ((0, "Start"), (-1, "End")):
|
|
base_x, base_y = track_screen_points[idx]
|
|
forward = track_samples[idx]["forward"]
|
|
dir_x, dir_y = orient_point(forward[0], forward[2])
|
|
ax.arrow(
|
|
base_x,
|
|
base_y,
|
|
dir_x * arrow_scale,
|
|
dir_y * arrow_scale,
|
|
head_width=head_width,
|
|
head_length=head_length,
|
|
length_includes_head=True,
|
|
color="tab:blue",
|
|
linewidth=1.5,
|
|
alpha=0.9,
|
|
zorder=4,
|
|
)
|
|
ax.text(
|
|
base_x,
|
|
base_y,
|
|
label,
|
|
color="tab:blue",
|
|
fontsize=8,
|
|
fontweight="bold",
|
|
ha="center",
|
|
va="center",
|
|
zorder=5,
|
|
)
|
|
ax.scatter([], [], marker="^", color="tab:blue", label="Nuke cam forward")
|
|
ax.set_title("Camera path (top view)")
|
|
ax.axis("equal")
|
|
ax.grid(True, linestyle="--", alpha=0.3)
|
|
ax.legend()
|
|
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
fig.savefig(args.output, dpi=200, bbox_inches="tight")
|
|
print(f"Saved plot to {args.output}")
|
|
if not args.show:
|
|
plt.close(fig)
|
|
return
|
|
|
|
plt.show()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|