Files
3sixty2cubemap/pipeline_360.py
T
2026-08-19 17:26:45 +02:00

1586 lines
69 KiB
Python

#!/usr/bin/env python3
"""
pipeline_360.py -- End-to-end 360 photogrammetry prep pipeline for COLMAP.
Merges what used to be four separate scripts (extract_sharpest_frame.py,
generate_sfm_masks.py, project_to_cubemap.py, generate_rig_config.py) into
one tool with one project directory layout. The old multi-session merge
step (merge_frames_masks.py) is gone -- this pipeline is single-session.
Also gone: the Metashape/RealityCapture mask "convention" options that used
to be threaded through every stage. Everything downstream commits to
COLMAP's rig-based reconstruction now, so there was never a real reason for
a mask written for stage 2 to end up in a different format than stage 3
expects. One convention (COLMAP's: black=excluded, filename=<image>.png)
everywhere.
PIPELINE
--------
video.mp4
| [1: extract] sharpest frame per --extract-chunk-size window
v
01_frames/ equirect frames (output_frame_00001.png, ...)
| [2: masks] YOLO-seg, cubemap-projected detection to fix
| pole distortion, reprojected back to equirect
v
02_masks/ equirect masks (output_frame_00001.png.png, ...)
| [3: cubemap] each equirect frame+mask -> 6 perspective faces
v
03_cubemap/
images/ output_frame_00001_F.png, ..._R.png, ...
masks/ output_frame_00001_F.png.png, ...
| [4: rig] COLMAP rig config + nested {FACE}/{stem} tree
v
04_colmap/
images/F/output_frame_00001.png, images/R/..., ...
masks/F/output_frame_00001.png.png, ...
rig_config.json
Run the whole thing:
python pipeline_360.py all --project-dir ./project --video ./raw.mp4
Resume from a later stage (e.g. you already have frames+masks and just
want to redo the cubemap/rig stages with a different --cubemap-face-size):
python pipeline_360.py all --project-dir ./project --start-stage cubemap
Run stages individually (mirrors the original standalone scripts, useful
for debugging a single stage in isolation):
python pipeline_360.py extract --video raw.mp4 --output-dir 01_frames
python pipeline_360.py masks --input-dir 01_frames --output-dir 02_masks
python pipeline_360.py cubemap --frames-dir 01_frames --masks-dir 02_masks \\
--output-frames-dir 03_cubemap/images --output-masks-dir 03_cubemap/masks
python pipeline_360.py rig --images-dir 03_cubemap/images --masks-dir 03_cubemap/masks \\
--output 04_colmap/rig_config.json --link-tree 04_colmap/images \\
--masks-link-tree 04_colmap/masks
Self-test for the rig-config geometry/schema logic (no video/GPU needed):
python pipeline_360.py rig --self-test
Requirements:
pip install opencv-python numpy tqdm py360convert ultralytics --break-system-packages
(ultralytics is only needed for the masks stage; the other three stages
work without it.)
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import multiprocessing
import re
import struct
import sys
import zlib
from collections import defaultdict
from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, as_completed, wait
from math import ceil
from pathlib import Path
from time import monotonic
from typing import Callable, Dict, List, Optional, Tuple
try:
import cv2
except ImportError:
cv2 = None
import numpy as np
try:
from tqdm import tqdm
except ImportError:
tqdm = None
# ==========================================================================
# Shared: logging / progress / cancellation
#
# One shared plumbing layer used by every stage, so the GUI only has to
# understand one line protocol (plain lines = log, "[progress] "-prefixed
# lines = replace the last line) regardless of which stage is running.
# ==========================================================================
Logger = Optional[Callable[[str], None]]
CancelChecker = Optional[Callable[[], bool]]
PROGRESS_PREFIX = "[progress] "
SPINNER_FRAMES = "|/-\\"
MULTIPROCESS_PROGRESS_INTERVAL = 0.2
IMAGE_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"})
FACES: Tuple[str, ...] = ("F", "R", "B", "L", "U", "D")
FACE_NAMES = {"F": "front", "R": "right", "B": "back", "L": "left", "U": "up", "D": "down"}
class PipelineError(Exception):
pass
class PipelineCancelled(PipelineError):
pass
class GuiTqdm(tqdm if tqdm is not None else object):
def __init__(self, *args, logger: Logger = None, **kwargs) -> None:
self.logger = logger
self._last_displayed_message = ""
super().__init__(*args, **kwargs)
def update(self, n=1):
displayed = super().update(n)
if self.logger is not None:
self.display()
return displayed
def display(self, msg=None, pos=None) -> None:
if self.logger is None:
super().display(msg=msg, pos=pos)
return
progress_message = msg if msg is not None else str(self)
cleaned = progress_message.strip()
if cleaned and cleaned != self._last_displayed_message:
self._last_displayed_message = cleaned
emit_progress(cleaned, self.logger)
def emit(message: str, logger: Logger = None) -> None:
if logger is None:
print(message)
else:
logger(message)
def emit_progress(message: str, logger: Logger = None) -> None:
if logger is not None:
logger(f"{PROGRESS_PREFIX}{message}")
def print_stage_header(index: int, total: int, title: str, logger: Logger = None) -> None:
emit("", logger)
emit(f"==== STAGE {index}/{total}: {title} ====", logger)
def create_progress(total: Optional[int], desc: str, logger: Logger = None):
ensure_tqdm_available()
kwargs = {
"total": total, "desc": desc, "unit": "frame",
"dynamic_ncols": True, "leave": False,
"bar_format": "{l_bar}{bar:20}{r_bar}",
}
if logger is None:
return tqdm(**kwargs)
kwargs.update(mininterval=0, miniters=1, ascii=False)
return GuiTqdm(logger=logger, **kwargs)
def ensure_opencv_available() -> None:
if cv2 is None:
raise PipelineError("opencv-python is required. Install with: pip install opencv-python --break-system-packages")
def ensure_tqdm_available() -> None:
if tqdm is None:
raise PipelineError("tqdm is required. Install with: pip install tqdm --break-system-packages")
def ensure_py360convert_available():
try:
import py360convert
except ImportError:
raise PipelineError("py360convert is required. Install with: pip install py360convert --break-system-packages")
return py360convert
def raise_if_cancelled(should_cancel: CancelChecker = None) -> None:
if should_cancel is not None and should_cancel():
raise PipelineCancelled("Processing was cancelled.")
def gather_images(input_dir: Path) -> List[Path]:
frames = sorted(f for f in input_dir.iterdir() if f.suffix.lower() in IMAGE_EXTENSIONS)
if not frames:
raise PipelineError(f"No image files found in {input_dir}")
return frames
# ==========================================================================
# STAGE 1: extract sharpest frame per chunk from a video
# ==========================================================================
#
# Picks the sharpest (least motion-blurred) frame out of every
# --extract-chunk-size window, using variance-of-Laplacian as the sharpness
# metric. Two-pass: analyze every frame's sharpness to a CSV (parallelized
# across --extract-workers, since this is the slow part), then extract just
# the winners.
def get_frame_count(capture) -> Optional[int]:
total = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
return total if total > 0 else None
def compute_sharpness(frame, scale_width: int) -> float:
height, width = frame.shape[:2]
if scale_width > 0 and width > scale_width:
ratio = scale_width / float(width)
frame = cv2.resize(frame, (scale_width, max(1, int(height * ratio))), interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
def build_analysis_ranges(total_frames: int, workers: int) -> List[Tuple[int, int]]:
task_count = max(workers * 8, workers)
range_size = max(1, ceil(total_frames / task_count))
ranges, start = [], 0
while start < total_frames:
end = min(total_frames, start + range_size)
ranges.append((start, end))
start = end
return ranges
def analyze_video_range(video_path: str, start_frame: int, end_frame: int, scale_width: int) -> List[Tuple[int, float]]:
ensure_opencv_available()
capture = cv2.VideoCapture(video_path)
if not capture.isOpened():
raise PipelineError(f"Failed to open video: {video_path}")
capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
results, frame_number = [], start_frame
while frame_number < end_frame:
ok, frame = capture.read()
if not ok:
break
results.append((frame_number, compute_sharpness(frame, scale_width)))
frame_number += 1
capture.release()
return results
def analyze_video_single_process(video_file, metadata_path, scale_width, total_frames, logger=None, should_cancel=None) -> int:
capture = cv2.VideoCapture(str(video_file))
if not capture.isOpened():
raise PipelineError(f"Failed to open video: {video_file}")
frame_number = 0
try:
with metadata_path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["frame", "sharpness"])
with create_progress(total=total_frames, desc="Analyze", logger=logger) as bar:
while True:
raise_if_cancelled(should_cancel)
ok, frame = capture.read()
if not ok:
break
sharpness = compute_sharpness(frame, scale_width)
writer.writerow([frame_number, f"{sharpness:.10f}"])
frame_number += 1
bar.update(1)
except PipelineCancelled:
capture.release()
if metadata_path.exists():
metadata_path.unlink()
raise
capture.release()
return frame_number
def analyze_video_multi_process(video_file, metadata_path, scale_width, total_frames, workers, logger=None, should_cancel=None) -> int:
ranges = build_analysis_ranges(total_frames, workers)
completed: Dict[int, List[Tuple[int, float]]] = {}
next_index, written, spinner, last_refresh = 0, 0, 0, 0.0
try:
with metadata_path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["frame", "sharpness"])
with create_progress(total=total_frames, desc="Analyze", logger=logger) as bar:
with ProcessPoolExecutor(max_workers=workers) as executor:
future_map = {
executor.submit(analyze_video_range, str(video_file), s, e, scale_width): i
for i, (s, e) in enumerate(ranges)
}
bar.set_postfix_str(f"{SPINNER_FRAMES[spinner]} {len(future_map)} ranges")
bar.display()
last_refresh = monotonic()
while future_map:
raise_if_cancelled(should_cancel)
done, _ = wait(future_map, timeout=0.1, return_when=FIRST_COMPLETED)
if not done:
now = monotonic()
if now - last_refresh >= MULTIPROCESS_PROGRESS_INTERVAL:
spinner = (spinner + 1) % len(SPINNER_FRAMES)
bar.set_postfix_str(f"{SPINNER_FRAMES[spinner]} {len(future_map)} ranges")
bar.display()
last_refresh = now
continue
for fut in done:
idx = future_map.pop(fut)
completed[idx] = fut.result()
bar.update(len(completed[idx]))
spinner = (spinner + 1) % len(SPINNER_FRAMES)
bar.set_postfix_str(f"{SPINNER_FRAMES[spinner]} {len(future_map)} ranges")
bar.display()
last_refresh = monotonic()
while next_index in completed:
for frame_number, sharpness in completed.pop(next_index):
writer.writerow([frame_number, f"{sharpness:.10f}"])
written += 1
next_index += 1
bar.set_postfix_str("done")
bar.display()
except PipelineCancelled:
if metadata_path.exists():
metadata_path.unlink()
raise
return written
def analyze_video(video_file, metadata_path, scale_width, workers=4, logger=None, should_cancel=None) -> None:
ensure_opencv_available()
ensure_tqdm_available()
metadata_path.parent.mkdir(parents=True, exist_ok=True)
emit("[1/3] Analyzing sharpness...", logger)
probe = cv2.VideoCapture(str(video_file))
if not probe.isOpened():
raise PipelineError(f"Failed to open video: {video_file}")
total_frames = get_frame_count(probe)
probe.release()
if workers <= 1 or total_frames is None or total_frames <= 1:
n = analyze_video_single_process(video_file, metadata_path, scale_width, total_frames, logger, should_cancel)
else:
n = analyze_video_multi_process(video_file, metadata_path, scale_width, total_frames, workers, logger, should_cancel)
if n == 0:
raise PipelineError("No frames could be read from the video.")
def parse_best_frames(metadata_path: Path, chunk_size: int, logger: Logger = None, should_cancel: CancelChecker = None) -> List[int]:
ensure_tqdm_available()
frame_data: List[Tuple[int, float]] = []
try:
with metadata_path.open("r", encoding="utf-8") as fh:
total_lines = max(0, sum(1 for _ in fh) - 1)
with metadata_path.open("r", newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
with create_progress(total=total_lines or None, desc="Parse", logger=logger) as bar:
for row in reader:
raise_if_cancelled(should_cancel)
try:
frame_data.append((int(row["frame"]), float(row["sharpness"])))
except (KeyError, TypeError, ValueError):
pass
finally:
bar.update(1)
except FileNotFoundError as exc:
raise PipelineError(f"Metadata file not found: {metadata_path}") from exc
if not frame_data:
raise PipelineError("No valid frame data was found in metadata.")
best, chunk = [], []
for item in frame_data:
chunk.append(item)
if len(chunk) == chunk_size:
best.append(max(chunk, key=lambda x: x[1])[0])
chunk = []
if chunk:
best.append(max(chunk, key=lambda x: x[1])[0])
return best
def format_output_filename(pattern: str, index: int, output_format: str) -> str:
try:
name = pattern % index
except (TypeError, ValueError):
if "%d" not in pattern:
name = pattern
else:
raise PipelineError("--extract-output-pattern must be a valid printf-style pattern such as output_frame_%05d.png")
path = Path(name)
target_suffix = f".{output_format}"
if path.suffix.lower() in {".jpg", ".jpeg", ".png"}:
return str(path.with_suffix(target_suffix))
if path.suffix:
raise PipelineError("--extract-output-pattern must end with .png or .jpg, or omit the extension entirely.")
return f"{name}{target_suffix}"
def save_frame(frame, output_file: Path, jpeg_quality: int, output_format: str) -> None:
output_file.parent.mkdir(parents=True, exist_ok=True)
if output_format == "jpg":
ok = cv2.imwrite(str(output_file), frame, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality])
else:
ok = cv2.imwrite(str(output_file), frame)
if not ok:
raise PipelineError(f"Failed to write image: {output_file}")
def extract_frames(video_file, frame_numbers, output_dir, output_pattern, jpeg_quality, output_format, logger=None, should_cancel=None) -> None:
ensure_opencv_available()
ensure_tqdm_available()
if not frame_numbers:
emit("No frames selected for extraction.", logger)
return
output_dir.mkdir(parents=True, exist_ok=True)
targets = set(frame_numbers)
capture = cv2.VideoCapture(str(video_file))
if not capture.isOpened():
raise PipelineError(f"Failed to open video: {video_file}")
emit(f"[3/3] Extracting frames... ({len(frame_numbers)} frames)", logger)
total = get_frame_count(capture)
frame_number, saved, output_index = 0, 0, 1
with create_progress(total=total, desc="Extract", logger=logger) as bar:
while True:
raise_if_cancelled(should_cancel)
ok, frame = capture.read()
if not ok:
break
if frame_number in targets:
name = format_output_filename(output_pattern, output_index, output_format)
save_frame(frame, output_dir / name, jpeg_quality, output_format)
output_index += 1
saved += 1
if saved == len(targets):
bar.update(1)
break
frame_number += 1
bar.update(1)
capture.release()
if saved != len(targets):
raise PipelineError(f"Some selected frames could not be extracted. Expected {len(targets)}, saved {saved}.")
def run_extract_stage(
video: Path, output_dir: Path, chunk_size: int = 30, scale_width: int = 1920, workers: int = 4,
output_pattern: str = "output_frame_%05d.png", output_format: str = "png", jpeg_quality: int = 95,
reuse_metadata: bool = True, logger: Logger = None, should_cancel: CancelChecker = None,
) -> Path:
"""Returns output_dir (where the extracted equirect frames now live)."""
ensure_opencv_available()
ensure_tqdm_available()
if not video.exists():
raise PipelineError(f"Input video was not found: {video}")
if chunk_size <= 0:
raise PipelineError("--extract-chunk-size must be 1 or greater.")
if workers <= 0:
raise PipelineError("--extract-workers must be 1 or greater.")
output_dir.mkdir(parents=True, exist_ok=True)
metadata_path = output_dir / "_sharpness_metadata.csv"
raise_if_cancelled(should_cancel)
if metadata_path.exists() and reuse_metadata:
emit(f"Using existing sharpness metadata: {metadata_path}", logger)
else:
emit(f"Generating sharpness metadata: {metadata_path}", logger)
analyze_video(video, metadata_path, scale_width, workers, logger, should_cancel)
raise_if_cancelled(should_cancel)
emit("[2/3] Parsing metadata...", logger)
best_frames = parse_best_frames(metadata_path, chunk_size, logger, should_cancel)
extract_frames(video, best_frames, output_dir, output_pattern, jpeg_quality, output_format, logger, should_cancel)
emit("Done: sharp frames extracted.", logger)
emit(f"Output directory: {output_dir.resolve()}", logger)
return output_dir
# ==========================================================================
# STAGE 2: YOLO segmentation masks (cubemap-projected detection)
# ==========================================================================
#
# Masks excluded/movable objects (people, vehicles, ...) so they don't
# pollute the SfM point cloud. Detection is run on cube-face reprojections
# rather than the raw equirect frame by default, since equirect projection
# stretches anything near the poles enough that YOLO misses it -- then
# masks are reprojected back to equirect space and merged. Output is always
# COLMAP convention: black(0)=excluded, filename is "<image filename>.png".
COCO_MOVABLE_DEFAULTS = ["person", "car", "bus", "truck", "bicycle", "motorcycle"]
def load_yolo_model(model_name: str, device: str):
try:
from ultralytics import YOLO
except ImportError:
raise PipelineError(
"ultralytics is not installed. Run:\n pip install ultralytics --break-system-packages\n"
"(ROCm users: install the ROCm torch build first so this doesn't pull in a CUDA-only wheel.)"
)
model = YOLO(model_name)
try:
model.to(device)
except Exception as exc:
print(f"[warn] could not move model to device={device} ({exc}); falling back to cpu", file=sys.stderr)
device = "cpu"
return model, device
def build_mask(result, shape_hw, target_ids, dilate_px) -> Tuple[np.ndarray, int]:
h, w = shape_hw
mask = np.zeros((h, w), dtype=np.uint8)
if result.masks is None or result.boxes is None or len(result.boxes) == 0:
return mask, 0
cls_ids = result.boxes.cls.cpu().numpy().astype(int)
seg_masks = result.masks.data.cpu().numpy()
hits = 0
for i, cid in enumerate(cls_ids):
if cid not in target_ids:
continue
m = seg_masks[i]
if m.shape != (h, w):
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
mask[m > 0.5] = 255
hits += 1
if hits and dilate_px > 0:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate_px * 2 + 1, dilate_px * 2 + 1))
mask = cv2.dilate(mask, kernel)
return mask, hits
def _chunked(seq, size):
for i in range(0, len(seq), size):
yield seq[i:i + size]
def run_predict_chunked(model, images, device, conf, imgsz, tile_batch):
all_results = []
for chunk in _chunked(images, max(1, tile_batch)):
results = model.predict(source=chunk, device=device, conf=conf, imgsz=imgsz, verbose=False, retina_masks=True)
all_results.extend(results)
if device != "cpu":
try:
import torch
torch.cuda.empty_cache()
except Exception:
pass
return all_results
def _process_mask_batch_equirect(batch_paths, model, device, conf, imgsz, tile_batch, target_ids, dilate_px):
results = run_predict_chunked(model, [str(p) for p in batch_paths], device, conf, imgsz, tile_batch)
out = []
for result in results:
h, w = result.orig_shape
raw_mask, hits = build_mask(result, (h, w), target_ids, dilate_px)
out.append((raw_mask, hits, (h, w)))
return out
def _process_mask_batch_cubemap(batch_paths, model, device, conf, imgsz, tile_batch, target_ids, dilate_px, face_size, skip_faces):
py360convert = ensure_py360convert_available()
frame_records, flat_images, flat_index = [], [], []
for local_idx, frame_path in enumerate(batch_paths):
img = cv2.imread(str(frame_path))
if img is None:
print(f"[warn] could not read {frame_path}, skipping", file=sys.stderr)
frame_records.append(None)
continue
h, w = img.shape[:2]
faces = py360convert.e2c(img, face_w=face_size, mode="bilinear", cube_format="dict")
frame_records.append({"h": h, "w": w, "faces": faces})
for face_name, face_img in faces.items():
if face_name in skip_faces:
continue
flat_images.append(face_img)
flat_index.append((local_idx, face_name))
results = run_predict_chunked(model, flat_images, device, conf, imgsz, tile_batch) if flat_images else []
per_frame_face_masks = [dict() if rec is not None else None for rec in frame_records]
per_frame_hits = [0] * len(frame_records)
for (local_idx, face_name), result in zip(flat_index, results):
face_img = frame_records[local_idx]["faces"][face_name]
fh, fw = face_img.shape[:2]
raw_mask, hits = build_mask(result, (fh, fw), target_ids, dilate_px)
per_frame_face_masks[local_idx][face_name] = raw_mask
per_frame_hits[local_idx] += hits
out = []
for local_idx, rec in enumerate(frame_records):
if rec is None:
out.append((None, 0, None))
continue
h, w = rec["h"], rec["w"]
mask_dict_3ch = {}
for face_name in FACES:
face_shape = rec["faces"][face_name].shape[:2]
m = per_frame_face_masks[local_idx].get(face_name)
if m is None:
m = np.zeros(face_shape, dtype=np.uint8)
mask_dict_3ch[face_name] = cv2.merge([m, m, m])
equirect_mask_3ch = py360convert.c2e(mask_dict_3ch, h=h, w=w, mode="nearest", cube_format="dict")
raw_equirect_mask = equirect_mask_3ch[:, :, 0]
_, raw_equirect_mask = cv2.threshold(raw_equirect_mask, 127, 255, cv2.THRESH_BINARY)
out.append((raw_equirect_mask, per_frame_hits[local_idx], (h, w)))
return out
def mask_path_for(frame_path: Path, output_dir: Path) -> Path:
"""COLMAP convention only: mask_path/<image filename>.png"""
return output_dir / f"{frame_path.name}.png"
def run_masks_stage(
input_dir: Path, output_dir: Path,
classes: str = ",".join(COCO_MOVABLE_DEFAULTS), model_name: str = "yolo11x-seg.pt", device: str = "cuda",
conf: float = 0.35, imgsz: int = 1280, dilate: int = 6,
projection: str = "cubemap", face_size: int = 1024, skip_faces_raw: str = "",
batch: int = 8, tile_batch: int = 8, empty_if_none: bool = False,
logger: Logger = None, should_cancel: CancelChecker = None,
) -> Path:
"""Returns output_dir (where equirect-space COLMAP-convention masks now live)."""
ensure_opencv_available()
output_dir.mkdir(parents=True, exist_ok=True)
frames = gather_images(input_dir)
emit(f"Found {len(frames)} frames in {input_dir}", logger)
if projection == "cubemap":
ensure_py360convert_available()
skip_faces = {f.strip().upper() for f in skip_faces_raw.split(",") if f.strip()}
unknown = skip_faces - set(FACES)
if unknown:
raise PipelineError(f"--mask-skip-faces has unrecognised face(s) {sorted(unknown)}; valid: {list(FACES)}")
model, device = load_yolo_model(model_name, device)
wanted = {c.strip().lower() for c in classes.split(",") if c.strip()}
name_to_id = {v.lower(): k for k, v in model.names.items()}
unknown_classes = wanted - set(name_to_id.keys())
if unknown_classes:
print(f"[warn] class names not in model label set, ignored: {sorted(unknown_classes)}", file=sys.stderr)
target_ids = {name_to_id[n] for n in wanted if n in name_to_id}
if not target_ids:
raise PipelineError("No valid target classes resolved. Check --mask-classes against the model's class list.")
emit(f"Masking classes: {sorted(wanted & set(name_to_id.keys()))} -> ids {sorted(target_ids)}", logger)
emit(f"Projection: {projection}" + (f" (skipping faces: {sorted(skip_faces)})" if skip_faces else ""), logger)
written = skipped = total_detections = 0
with create_progress(total=len(frames), desc="Masks", logger=logger) as bar:
for batch_start in range(0, len(frames), batch):
raise_if_cancelled(should_cancel)
batch_paths = frames[batch_start:batch_start + batch]
if projection == "cubemap":
batch_results = _process_mask_batch_cubemap(
batch_paths, model, device, conf, imgsz, tile_batch, target_ids, dilate, face_size, skip_faces
)
else:
batch_results = _process_mask_batch_equirect(batch_paths, model, device, conf, imgsz, tile_batch, target_ids, dilate)
for frame_path, (raw_mask, hit_count, _shape) in zip(batch_paths, batch_results):
if raw_mask is None:
bar.update(1)
continue
total_detections += hit_count
out_path = mask_path_for(frame_path, output_dir)
if hit_count == 0 and not empty_if_none:
skipped += 1
bar.update(1)
continue
final_mask = 255 - raw_mask # COLMAP: black(0) = excluded
cv2.imwrite(str(out_path), final_mask)
written += 1
bar.update(1)
emit(f"Done. {written} masks written, {skipped} frame(s) had no target objects (unmasked), "
f"{total_detections} total target-object instances masked.", logger)
emit(f"Masks in: {output_dir}", logger)
return output_dir
# ==========================================================================
# STAGE 3: project equirect frames+masks to 6 cube faces
# ==========================================================================
#
# Neither COLMAP nor Brush has an equirectangular camera model, so this
# splits each equirect frame (and its matching mask, if present) into 6
# perspective cube-face crops -- the "virtual camera rig" images that
# stage 4's rig config describes.
def _cubemap_input_mask_path(frame_path: Path, masks_dir: Path) -> Path:
return masks_dir / f"{frame_path.name}.png"
def cubemap_process_one(args_tuple):
(frame_path_str, masks_dir_str, out_frames_dir_str, out_masks_dir_str,
face_size, skip_faces, jpeg_quality, resume) = args_tuple
import py360convert # per-worker import: required for ProcessPoolExecutor pickling safety
frame_path = Path(frame_path_str)
out_frames_dir = Path(out_frames_dir_str)
out_masks_dir = Path(out_masks_dir_str) if out_masks_dir_str else None
masks_dir = Path(masks_dir_str) if masks_dir_str else None
active_faces = [f for f in FACES if f not in skip_faces]
if resume:
expected = [out_frames_dir / f"{frame_path.stem}_{f}{frame_path.suffix}" for f in active_faces]
if all(e.exists() for e in expected):
return frame_path.name, len(active_faces), 0, "skipped (resume)"
img = cv2.imread(str(frame_path))
if img is None:
return frame_path.name, 0, 0, "unreadable"
h, w = img.shape[:2]
faces = py360convert.e2c(img, face_w=face_size, mode="bilinear", cube_format="dict")
mask_faces = None
if masks_dir is not None and out_masks_dir is not None:
mask_path = _cubemap_input_mask_path(frame_path, masks_dir)
if mask_path.exists():
mask_img = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE)
if mask_img is not None and mask_img.shape[:2] == (h, w):
mask_3ch = cv2.merge([mask_img, mask_img, mask_img])
mask_faces_3ch = py360convert.e2c(mask_3ch, face_w=face_size, mode="nearest", cube_format="dict")
mask_faces = {}
for fname, m3 in mask_faces_3ch.items():
m = m3[:, :, 0]
_, m = cv2.threshold(m, 127, 255, cv2.THRESH_BINARY)
mask_faces[fname] = m
frames_written = masks_written = 0
for face_name in active_faces:
face_img = faces[face_name]
out_frame_name = f"{frame_path.stem}_{face_name}{frame_path.suffix}"
out_path = out_frames_dir / out_frame_name
if frame_path.suffix.lower() in (".jpg", ".jpeg"):
cv2.imwrite(str(out_path), face_img, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality])
else:
cv2.imwrite(str(out_path), face_img)
frames_written += 1
if mask_faces is not None:
cv2.imwrite(str(out_masks_dir / f"{out_frame_name}.png"), mask_faces[face_name])
masks_written += 1
return frame_path.name, frames_written, masks_written, "ok"
def run_cubemap_stage(
frames_dir: Path, output_frames_dir: Path, masks_dir: Optional[Path] = None, output_masks_dir: Optional[Path] = None,
face_size: int = 1600, skip_faces_raw: str = "", jpeg_quality: int = 95, workers: int = 0, resume: bool = False,
logger: Logger = None, should_cancel: CancelChecker = None,
) -> Tuple[Path, Optional[Path]]:
"""Returns (output_frames_dir, output_masks_dir_or_None)."""
ensure_opencv_available()
ensure_py360convert_available()
if masks_dir is not None and output_masks_dir is None:
raise PipelineError("masks_dir given but output_masks_dir missing")
output_frames_dir.mkdir(parents=True, exist_ok=True)
if output_masks_dir is not None:
output_masks_dir.mkdir(parents=True, exist_ok=True)
skip_faces = {f.strip().upper() for f in skip_faces_raw.split(",") if f.strip()}
unknown = skip_faces - set(FACES)
if unknown:
raise PipelineError(f"--cubemap-skip-faces has unrecognised face(s) {sorted(unknown)}; valid: {list(FACES)}")
frames = gather_images(frames_dir)
emit(f"Found {len(frames)} equirect frames in {frames_dir}", logger)
active_faces = [f for f in FACES if f not in skip_faces]
emit(f"Active faces: {active_faces}", logger)
emit(f"Expected output frames: {len(frames) * len(active_faces)}", logger)
tasks = [
(str(f), str(masks_dir) if masks_dir else None, str(output_frames_dir),
str(output_masks_dir) if output_masks_dir else None, face_size, skip_faces, jpeg_quality, resume)
for f in frames
]
pool_workers = workers if workers > 0 else None
total_frames_written = total_masks_written = unreadable = 0
with create_progress(total=len(tasks), desc="Cubemap", logger=logger) as bar:
with ProcessPoolExecutor(max_workers=pool_workers) as executor:
futures = [executor.submit(cubemap_process_one, t) for t in tasks]
for fut in as_completed(futures):
raise_if_cancelled(should_cancel)
name, fw, mw, status = fut.result()
total_frames_written += fw
total_masks_written += mw
if status == "unreadable":
unreadable += 1
print(f"[warn] could not read {name}", file=sys.stderr)
bar.update(1)
emit(f"Done. {total_frames_written} face-crop images written to {output_frames_dir}", logger)
if output_masks_dir:
emit(f"{total_masks_written} face-crop masks written to {output_masks_dir}", logger)
if unreadable:
emit(f"{unreadable} frames were unreadable and skipped", logger)
return output_frames_dir, (output_masks_dir if output_masks_dir else None)
# ==========================================================================
# STAGE 4: COLMAP rig config + nested {FACE}/{stem} tree
# ==========================================================================
#
# Declares that the 6 cube faces from one frame share a single optical
# center with fixed relative rotations, so bundle adjustment solves one
# 6-DoF rig pose per frame instead of 6 independent poses. Also relinks the
# flat "{stem}_{FACE}.ext" output from stage 3 into the nested
# "{FACE}/{stem}.ext" tree COLMAP's rig_configurator requires (it matches
# rig cameras by filename PREFIX only -- see build_colmap_rig_config()'s
# docstring for the schema this assumes and why).
#
# Conventions verified against py360convert source: xyzcube() puts
# +X=right, +Y=up, +Z=front in an endpoint-inclusive linspace grid, so
# fx=fy=(N-1)/2 (not the naive N/2) and F->R is a clean 90deg yaw. See
# FACE_AXES / compute_intrinsics below for the full derivation.
Vec3 = Tuple[float, float, float]
Mat3 = Tuple[Vec3, Vec3, Vec3]
FACE_AXES: Dict[str, Tuple[Vec3, Vec3, Vec3]] = {
# image right image down view direction
"F": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
"R": ((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
"B": ((-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, -1.0)),
"L": ((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
"U": ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
"D": ((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
}
def face_rotation_matrix(face: str) -> Mat3:
right, down, forward = FACE_AXES[face]
return (right, down, forward)
def mat_mul(a: Mat3, b: Mat3) -> Mat3:
return tuple(tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) for i in range(3)) # type: ignore
def mat_transpose(m: Mat3) -> Mat3:
return tuple(tuple(m[j][i] for j in range(3)) for i in range(3)) # type: ignore
def mat_det(m: Mat3) -> float:
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
def assert_valid_rotation(m: Mat3, label: str, tol: float = 1e-9) -> None:
det = mat_det(m)
if abs(det - 1.0) > tol:
raise ValueError(f"{label}: det={det!r}, expected +1 (not a proper rotation)")
ident = mat_mul(m, mat_transpose(m))
for i in range(3):
for j in range(3):
expected = 1.0 if i == j else 0.0
if abs(ident[i][j] - expected) > tol:
raise ValueError(f"{label}: R @ R.T is not identity ({ident!r})")
def quaternion_from_matrix(m: Mat3) -> Tuple[float, float, float, float]:
"""Shepperd's method -> COLMAP's (w, x, y, z) order, sign-canonicalised to w>=0."""
m00, m01, m02 = m[0]
m10, m11, m12 = m[1]
m20, m21, m22 = m[2]
trace = m00 + m11 + m22
if trace > 0.0:
s = math.sqrt(trace + 1.0) * 2.0
w, x, y, z = 0.25 * s, (m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s
elif m00 > m11 and m00 > m22:
s = math.sqrt(1.0 + m00 - m11 - m22) * 2.0
w, x, y, z = (m21 - m12) / s, 0.25 * s, (m01 + m10) / s, (m02 + m20) / s
elif m11 > m22:
s = math.sqrt(1.0 + m11 - m00 - m22) * 2.0
w, x, y, z = (m02 - m20) / s, (m01 + m10) / s, 0.25 * s, (m12 + m21) / s
else:
s = math.sqrt(1.0 + m22 - m00 - m11) * 2.0
w, x, y, z = (m10 - m01) / s, (m02 + m20) / s, (m12 + m21) / s, 0.25 * s
norm = math.sqrt(w * w + x * x + y * y + z * z)
w, x, y, z = w / norm, x / norm, y / norm, z / norm
if w < 0.0:
w, x, y, z = -w, -x, -y, -z
return (w, x, y, z)
def cam_from_rig_rotation(face: str, ref_face: str) -> Tuple[float, float, float, float]:
r_face = face_rotation_matrix(face)
r_ref = face_rotation_matrix(ref_face)
assert_valid_rotation(r_face, f"FACE_AXES[{face}]")
assert_valid_rotation(r_ref, f"FACE_AXES[{ref_face}]")
r = mat_mul(r_face, mat_transpose(r_ref))
assert_valid_rotation(r, f"cam_from_rig[{face}<-{ref_face}]")
return quaternion_from_matrix(r)
def rotation_angle_deg(q: Tuple[float, float, float, float]) -> float:
w = min(1.0, max(-1.0, abs(q[0])))
return math.degrees(2.0 * math.acos(w))
def compute_intrinsics(face_size: int, convention: str) -> Tuple[float, float, float, float]:
"""'linspace' (default, exact for py360convert): f=(N-1)/2. 'half': f=N/2."""
if face_size < 2:
raise ValueError(f"face_size must be >= 2, got {face_size}")
if convention == "linspace":
f = (face_size - 1) / 2.0
elif convention == "half":
f = face_size / 2.0
else:
raise ValueError(f"unknown intrinsics convention: {convention!r}")
c = face_size / 2.0
return (f, f, c, c)
FLAT_RE = re.compile(r"^(?P<stem>.+)_(?P<face>[FRBLUD])$")
MASK_FLAT_RE = re.compile(r"^(?P<stem>.+)_(?P<face>[FRBLUD])\.[A-Za-z0-9]+$")
def _iter_images(root: Path):
for p in sorted(root.rglob("*")):
if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS:
yield p
def scan_cubemap_images(images_dir: Path, layout: str) -> Tuple[Dict[str, Dict[str, Path]], str]:
"""{stem: {FACE: path}}. flat='{stem}_{FACE}.ext', nested='{FACE}/{stem}.ext', auto detects."""
if not images_dir.is_dir():
raise PipelineError(f"--images-dir is not a directory: {images_dir}")
if layout == "auto":
subdirs = {p.name.upper() for p in images_dir.iterdir() if p.is_dir()}
layout = "nested" if subdirs and subdirs <= set(FACES) else "flat"
frames: Dict[str, Dict[str, Path]] = defaultdict(dict)
if layout == "flat":
for path in _iter_images(images_dir):
m = FLAT_RE.match(path.stem)
if m:
frames[m.group("stem")][m.group("face")] = path
else:
for face in FACES:
face_dir = images_dir / face
if face_dir.is_dir():
for path in _iter_images(face_dir):
frames[path.stem][face] = path
if not frames:
expected = "'{stem}_{FACE}.png'" if layout == "flat" else "'{FACE}/{stem}.png'"
raise PipelineError(f"No cubemap face images found under {images_dir} (layout={layout}). Expected {expected}.")
return dict(frames), layout
def scan_cubemap_masks(masks_dir: Path, layout: str) -> Tuple[Dict[str, Dict[str, Path]], str]:
"""Mirrors scan_cubemap_images(); a frame/face with no mask is expected, not an error."""
if not masks_dir.is_dir():
raise PipelineError(f"--masks-dir is not a directory: {masks_dir}")
if layout == "auto":
subdirs = {p.name.upper() for p in masks_dir.iterdir() if p.is_dir()}
layout = "nested" if subdirs and subdirs <= set(FACES) else "flat"
frames: Dict[str, Dict[str, Path]] = defaultdict(dict)
if layout == "flat":
for p in sorted(masks_dir.rglob("*")):
if not p.is_file() or p.suffix.lower() != ".png":
continue
m = MASK_FLAT_RE.match(p.stem)
if m:
frames[m.group("stem")][m.group("face")] = p
else:
for face in FACES:
face_dir = masks_dir / face
if not face_dir.is_dir():
continue
for p in sorted(face_dir.rglob("*")):
if not p.is_file() or p.suffix.lower() != ".png":
continue
frames[Path(p.stem).stem][face] = p # strip outer ".png" mask suffix only
return dict(frames), layout
def _png_dimensions(path: Path) -> Optional[Tuple[int, int]]:
try:
with path.open("rb") as fh:
head = fh.read(24)
except OSError:
return None
if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
return None
return struct.unpack(">II", head[16:24])
def image_dimensions(path: Path) -> Optional[Tuple[int, int]]:
dims = _png_dimensions(path)
if dims is not None:
return dims
try:
from PIL import Image
except ImportError:
return None
try:
with Image.open(path) as im:
return im.size
except OSError:
return None
def detect_face_size(frames: Dict[str, Dict[str, Path]], sample_limit: int = 24) -> int:
seen: Dict[Tuple[int, int], Path] = {}
checked = 0
for faces in frames.values():
for path in faces.values():
dims = image_dimensions(path)
if dims is None:
continue
seen.setdefault(dims, path)
checked += 1
if checked >= sample_limit:
break
if checked >= sample_limit:
break
if not seen:
raise PipelineError("could not read any image dimensions (non-PNG without Pillow?). Pass --rig-face-size explicitly.")
if len(seen) > 1:
detail = ", ".join(f"{w}x{h} (e.g. {p.name})" for (w, h), p in seen.items())
raise PipelineError(f"inconsistent face dimensions: {detail}. Pass --rig-face-size explicitly.")
(width, height), example = next(iter(seen.items()))
if width != height:
raise PipelineError(f"faces must be square, got {width}x{height} ({example.name}).")
return width
def build_colmap_rig_config(present_faces: List[str], ref_face: str, intrinsics: Tuple[float, float, float, float],
camera_model: str = "PINHOLE") -> List[dict]:
"""
SCHEMA (COLMAP >= 3.12, `colmap rig_configurator --rig_config_path`)
Top level: JSON array holding ONE rig definition, reused for every frame
(rig_configurator groups images by name-after-prefix into per-frame
Frames -- you do not emit one rig per source panorama).
[{"cameras": [
{"image_prefix": "F/", "ref_sensor": true,
"camera_model_name": "PINHOLE", "camera_params": [fx,fy,cx,cy]},
{"image_prefix": "R/",
"cam_from_rig_rotation": [w,x,y,z], "cam_from_rig_translation": [0,0,0],
"camera_model_name": "PINHOLE", "camera_params": [fx,fy,cx,cy]}
]}]
Read from colmap/doc/rigs.rst + src/colmap/scene/rig.cc:
* image_prefix: matched via StringStartsWith(); no suffix field exists,
which is why the nested {FACE}/{stem} layout is mandatory.
* exactly one ref_sensor per rig (identity pose, no cam_from_rig_*).
* cam_from_rig_rotation is W-FIRST (Eigen::Quaterniond(w,x,y,z) ctor).
* cam_from_rig_translation all-zero: every face shares one optical center.
Not run against a real COLMAP binary -- verify against your build if
rig_configurator rejects this.
"""
fx, fy, cx, cy = intrinsics
if ref_face not in present_faces:
raise ValueError(f"reference face {ref_face!r} not among present faces {present_faces}")
cameras = []
ordered = [ref_face] + [f for f in FACES if f in present_faces and f != ref_face]
for face in ordered:
entry: dict = {"image_prefix": f"{face}/"}
if face == ref_face:
entry["ref_sensor"] = True
else:
qw, qx, qy, qz = cam_from_rig_rotation(face, ref_face)
entry["cam_from_rig_rotation"] = [qw, qx, qy, qz]
entry["cam_from_rig_translation"] = [0.0, 0.0, 0.0]
entry["camera_model_name"] = camera_model
entry["camera_params"] = [fx, fy, cx, cy]
cameras.append(entry)
return [{"cameras": cameras}]
def write_rig_config(config: List[dict], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
def materialise_nested_tree(frames: Dict[str, Dict[str, Path]], dest: Path, mode: str = "symlink") -> int:
"""Rebuild flat '{stem}_{FACE}.ext' as '{FACE}/{stem}.ext' under dest (COLMAP matches by prefix only)."""
count = 0
for stem, faces in sorted(frames.items()):
for face, src in sorted(faces.items()):
face_dir = dest / face
face_dir.mkdir(parents=True, exist_ok=True)
target = face_dir / f"{stem}{src.suffix}"
_link(src, target, mode)
count += 1
return count
def materialise_mask_tree(mask_frames: Dict[str, Dict[str, Path]], image_frames: Dict[str, Dict[str, Path]],
dest: Path, mode: str = "symlink") -> Tuple[int, int]:
"""Relink masks to '{FACE}/{stem}{image_ext}.png', mirroring materialise_nested_tree()'s image tree.
Returns (linked, orphans) -- an orphan mask has no matching image (stem/face drift)."""
linked = orphans = 0
for stem, faces in sorted(mask_frames.items()):
for face, mask_src in sorted(faces.items()):
image_src = image_frames.get(stem, {}).get(face)
mask_name = f"{stem}{image_src.suffix}.png" if image_src is not None else f"{stem}.png.png"
if image_src is None:
orphans += 1
face_dir = dest / face
face_dir.mkdir(parents=True, exist_ok=True)
_link(mask_src, face_dir / mask_name, mode)
linked += 1
return linked, orphans
def _link(src: Path, target: Path, mode: str) -> None:
if target.exists() or target.is_symlink():
target.unlink()
resolved = src.resolve()
if mode == "symlink":
target.symlink_to(resolved)
elif mode == "hardlink":
target.hardlink_to(resolved)
elif mode == "copy":
target.write_bytes(resolved.read_bytes())
else:
raise ValueError(f"unknown link mode: {mode!r}")
def run_rig_stage(
images_dir: Path, output: Path, masks_dir: Optional[Path] = None,
face_size: Optional[int] = None, layout: str = "auto", ref_face: str = "F",
intrinsics_convention: str = "linspace", link_tree: Optional[Path] = None,
masks_link_tree: Optional[Path] = None, link_mode: str = "symlink",
logger: Logger = None,
) -> Tuple[Path, Optional[Path]]:
"""Returns (link_tree_or_images_dir, masks_link_tree_or_None) -- the paths to hand COLMAP."""
if masks_link_tree is not None and masks_dir is None:
raise PipelineError("masks_link_tree requires masks_dir")
frames, resolved_layout = scan_cubemap_images(images_dir, layout)
present_faces = sorted({f for faces in frames.values() for f in faces}, key=FACES.index)
missing_faces = [f for f in FACES if f not in present_faces]
resolved_face_size = face_size or detect_face_size(frames)
intrinsics = compute_intrinsics(resolved_face_size, intrinsics_convention)
if ref_face not in present_faces:
emit(f"warning: --rig-ref-face {ref_face} not present; using {present_faces[0]} as rig frame.", logger)
ref_face = present_faces[0]
partial = {s: sorted(f) for s, f in frames.items() if set(f) != set(present_faces)}
config = build_colmap_rig_config(present_faces, ref_face, intrinsics)
write_rig_config(config, output)
fx, fy, cx, cy = intrinsics
emit(f"scanned : {images_dir} (layout={resolved_layout})", logger)
emit(f"frames : {len(frames)}", logger)
emit(f"faces present: {','.join(present_faces)}" + (f" absent: {','.join(missing_faces)}" if missing_faces else ""), logger)
emit(f"face size : {resolved_face_size}px" + (" (auto-detected)" if face_size is None else " (override)"), logger)
emit(f"intrinsics : PINHOLE fx={fx:.6g} fy={fy:.6g} cx={cx:.6g} cy={cy:.6g} [{intrinsics_convention}]", logger)
emit(f"rig frame : {ref_face} ({FACE_NAMES[ref_face]}) -- 1 rig definition, {len(frames)} frames, {len(present_faces)} cameras", logger)
if partial:
shown = list(partial.items())[:3]
emit(f"partial frames: {len(partial)} frame(s) missing >=1 face, e.g. " + "; ".join(f"{s}=[{','.join(f)}]" for s, f in shown), logger)
emit(f"wrote : {output}", logger)
final_images_path = images_dir
if link_tree is not None:
n = materialise_nested_tree(frames, link_tree, link_mode)
emit(f"linked : {n} images into {link_tree} ({link_mode})", logger)
final_images_path = link_tree
elif resolved_layout == "flat":
emit("", logger)
emit("IMPORTANT: COLMAP matches rig cameras by filename PREFIX only, so this flat", logger)
emit(" '{stem}_{FACE}.png' naming cannot be used directly with --rig-link-tree unset.", logger)
final_masks_path = None
if masks_dir is not None:
mask_frames, mask_layout = scan_cubemap_masks(masks_dir, "auto")
n_masks = sum(len(f) for f in mask_frames.values())
n_images = sum(len(f) for f in frames.values())
emit("", logger)
emit(f"masks scanned: {masks_dir} (layout={mask_layout})", logger)
emit(f"masks found : {n_masks} across {len(mask_frames)} frame(s) (of {n_images} face images -- missing ones are unmasked)", logger)
unknown_stems = set(mask_frames) - set(frames)
if unknown_stems:
emit(f"warning: {len(unknown_stems)} mask frame(s) have no matching image frame (stem drift?), e.g. "
+ ", ".join(sorted(unknown_stems)[:3]), logger)
if masks_link_tree is not None:
linked, orphans = materialise_mask_tree(mask_frames, frames, masks_link_tree, link_mode)
emit(f"masks linked : {linked} into {masks_link_tree} ({link_mode})", logger)
if orphans:
emit(f"warning: {orphans} mask(s) linked with a fallback name, no matching image found -- verify by hand.", logger)
final_masks_path = masks_link_tree
elif mask_layout == "flat":
emit("", logger)
emit("IMPORTANT: masks must mirror the nested image tree too. Set --rig-masks-link-tree.", logger)
final_masks_path = masks_dir
return final_images_path, final_masks_path
# ==========================================================================
# Full pipeline orchestration ("all")
# ==========================================================================
STAGE_ORDER = ["extract", "masks", "cubemap", "rig"]
def run_pipeline(args, logger: Logger = None, should_cancel: CancelChecker = None) -> None:
project_dir: Path = args.project_dir
frames_dir = project_dir / "01_frames"
masks_dir = project_dir / "02_masks"
cubemap_images_dir = project_dir / "03_cubemap" / "images"
cubemap_masks_dir = project_dir / "03_cubemap" / "masks"
colmap_images_dir = project_dir / "04_colmap" / "images"
colmap_masks_dir = project_dir / "04_colmap" / "masks"
rig_config_path = project_dir / "04_colmap" / "rig_config.json"
start_idx = STAGE_ORDER.index(args.start_stage)
total_stages = 4
if start_idx <= STAGE_ORDER.index("extract"):
if not args.video:
raise PipelineError("--video is required when starting from the extract stage.")
print_stage_header(1, total_stages, "Extract Sharpest Frames", logger)
run_extract_stage(
video=args.video, output_dir=frames_dir,
chunk_size=args.extract_chunk_size, scale_width=args.extract_scale_width, workers=args.extract_workers,
output_pattern=args.extract_output_pattern, output_format=args.extract_output_format,
jpeg_quality=args.extract_jpeg_quality, reuse_metadata=args.extract_reuse_metadata,
logger=logger, should_cancel=should_cancel,
)
else:
emit(f"Skipping extract stage; using existing frames in {frames_dir}", logger)
raise_if_cancelled(should_cancel)
have_masks = False
if args.skip_masks:
emit("Skipping masks stage (--skip-masks).", logger)
elif start_idx <= STAGE_ORDER.index("masks"):
print_stage_header(2, total_stages, "Generate SfM Masks", logger)
run_masks_stage(
input_dir=frames_dir, output_dir=masks_dir,
classes=args.mask_classes, model_name=args.mask_model, device=args.mask_device,
conf=args.mask_conf, imgsz=args.mask_imgsz, dilate=args.mask_dilate,
projection=args.mask_projection, face_size=args.mask_face_size, skip_faces_raw=args.mask_skip_faces,
batch=args.mask_batch, tile_batch=args.mask_tile_batch, empty_if_none=args.mask_empty_if_none,
logger=logger, should_cancel=should_cancel,
)
have_masks = True
else:
have_masks = masks_dir.is_dir() and any(masks_dir.glob("*.png"))
emit(f"Skipping masks stage; {'using' if have_masks else 'no'} existing masks in {masks_dir}", logger)
raise_if_cancelled(should_cancel)
print_stage_header(3, total_stages, "Project to Cubemap", logger)
run_cubemap_stage(
frames_dir=frames_dir, output_frames_dir=cubemap_images_dir,
masks_dir=(masks_dir if have_masks else None), output_masks_dir=(cubemap_masks_dir if have_masks else None),
face_size=args.cubemap_face_size, skip_faces_raw=args.cubemap_skip_faces,
jpeg_quality=args.cubemap_jpeg_quality, workers=args.cubemap_workers, resume=args.cubemap_resume,
logger=logger, should_cancel=should_cancel,
)
raise_if_cancelled(should_cancel)
have_cubemap_masks = have_masks and cubemap_masks_dir.is_dir() and any(cubemap_masks_dir.rglob("*.png"))
print_stage_header(4, total_stages, "Generate Rig Config", logger)
run_rig_stage(
images_dir=cubemap_images_dir, output=rig_config_path,
masks_dir=(cubemap_masks_dir if have_cubemap_masks else None),
ref_face=args.rig_ref_face, intrinsics_convention=args.rig_intrinsics_convention,
link_tree=colmap_images_dir, masks_link_tree=(colmap_masks_dir if have_cubemap_masks else None),
link_mode=args.rig_link_mode, logger=logger,
)
emit("", logger)
emit("==== PIPELINE COMPLETE ====", logger)
emit(f"COLMAP images : {colmap_images_dir}", logger)
if have_cubemap_masks:
emit(f"COLMAP masks : {colmap_masks_dir}", logger)
emit(f"Rig config : {rig_config_path}", logger)
emit("", logger)
emit("Next, run COLMAP:", logger)
mask_flag = f" --ImageReader.mask_path {colmap_masks_dir}" if have_cubemap_masks else ""
emit(f" colmap feature_extractor --database_path db.db --image_path {colmap_images_dir}"
f"{mask_flag} --ImageReader.single_camera_per_folder 1", logger)
emit(f" colmap rig_configurator --database_path db.db --rig_config_path {rig_config_path}", logger)
emit(" colmap exhaustive_matcher --database_path db.db # or vocab_tree_matcher at scale", logger)
emit(f" colmap mapper --database_path db.db --image_path {colmap_images_dir} --output_path {project_dir / '05_sparse'}"
f" --Mapper.ba_refine_sensor_from_rig 0 --Mapper.ba_refine_focal_length 0 --Mapper.ba_refine_extra_params 0", logger)
# ==========================================================================
# CLI
# ==========================================================================
def _add_extract_args(p: argparse.ArgumentParser, prefix: str = "") -> None:
d = f"--{prefix}" if prefix else "--"
p.add_argument(f"{d}chunk-size", dest=f"{prefix}chunk_size".replace("-", "_"), type=int, default=30,
help="Select one sharpest frame per this many frames (default: 30)")
p.add_argument(f"{d}scale-width", dest=f"{prefix}scale_width".replace("-", "_"), type=int, default=1920,
help="Resize width for sharpness analysis; 0 = original size (default: 1920)")
p.add_argument(f"{d}workers", dest=f"{prefix}workers".replace("-", "_"), type=int, default=4,
help="Worker processes for sharpness analysis (default: 4)")
p.add_argument(f"{d}output-pattern", dest=f"{prefix}output_pattern".replace("-", "_"), default="output_frame_%05d.png",
help="printf-style output filename pattern")
p.add_argument(f"{d}output-format", dest=f"{prefix}output_format".replace("-", "_"), choices=("png", "jpg"), default="png")
p.add_argument(f"{d}jpeg-quality", dest=f"{prefix}jpeg_quality".replace("-", "_"), type=int, default=95)
p.add_argument(f"{d}regenerate-metadata", dest=f"{prefix}reuse_metadata".replace("-", "_"), action="store_false", default=True,
help="Ignore existing _sharpness_metadata.csv and regenerate it")
def _add_masks_args(p: argparse.ArgumentParser, prefix: str) -> None:
d = f"--{prefix}"
p.add_argument(f"{d}classes", dest=f"{prefix.replace('-', '_')}classes", default=",".join(COCO_MOVABLE_DEFAULTS))
p.add_argument(f"{d}model", dest=f"{prefix.replace('-', '_')}model", default="yolo11x-seg.pt")
p.add_argument(f"{d}device", dest=f"{prefix.replace('-', '_')}device", default="cuda")
p.add_argument(f"{d}conf", dest=f"{prefix.replace('-', '_')}conf", type=float, default=0.35)
p.add_argument(f"{d}imgsz", dest=f"{prefix.replace('-', '_')}imgsz", type=int, default=1280)
p.add_argument(f"{d}dilate", dest=f"{prefix.replace('-', '_')}dilate", type=int, default=6)
p.add_argument(f"{d}projection", dest=f"{prefix.replace('-', '_')}projection", choices=("cubemap", "equirect"), default="cubemap")
p.add_argument(f"{d}face-size", dest=f"{prefix.replace('-', '_')}face_size", type=int, default=1024)
p.add_argument(f"{d}skip-faces", dest=f"{prefix.replace('-', '_')}skip_faces", default="")
p.add_argument(f"{d}batch", dest=f"{prefix.replace('-', '_')}batch", type=int, default=8)
p.add_argument(f"{d}tile-batch", dest=f"{prefix.replace('-', '_')}tile_batch", type=int, default=8)
p.add_argument(f"{d}empty-if-none", dest=f"{prefix.replace('-', '_')}empty_if_none", action="store_true")
def _add_cubemap_args(p: argparse.ArgumentParser, prefix: str) -> None:
d = f"--{prefix}"
r = lambda name: f"{prefix.replace('-', '_')}{name}" # noqa: E731
p.add_argument(f"{d}face-size", dest=r("face_size"), type=int, default=1600)
p.add_argument(f"{d}skip-faces", dest=r("skip_faces"), default="")
p.add_argument(f"{d}jpeg-quality", dest=r("jpeg_quality"), type=int, default=95)
p.add_argument(f"{d}workers", dest=r("workers"), type=int, default=0, help="0 = all CPU cores")
p.add_argument(f"{d}resume", dest=r("resume"), action="store_true")
def _add_rig_args(p: argparse.ArgumentParser, prefix: str) -> None:
d = f"--{prefix}"
r = lambda name: f"{prefix.replace('-', '_')}{name}" # noqa: E731
p.add_argument(f"{d}ref-face", dest=r("ref_face"), default="F", choices=list(FACES))
p.add_argument(f"{d}intrinsics-convention", dest=r("intrinsics_convention"), choices=("linspace", "half"), default="linspace")
p.add_argument(f"{d}link-mode", dest=r("link_mode"), choices=("symlink", "hardlink", "copy"), default="symlink")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="pipeline_360.py", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--gui-log-output", action="store_true", help=argparse.SUPPRESS)
sub = parser.add_subparsers(dest="command", required=True)
p_all = sub.add_parser("all", help="Run the full pipeline in one project directory.")
p_all.add_argument("--project-dir", required=True, type=Path)
p_all.add_argument("--video", type=Path, default=None, help="Required unless --start-stage skips extraction.")
p_all.add_argument("--start-stage", choices=STAGE_ORDER, default="extract")
p_all.add_argument("--skip-masks", action="store_true", help="Skip YOLO masking entirely.")
_add_extract_args(p_all, prefix="extract-")
_add_masks_args(p_all, prefix="mask-")
_add_cubemap_args(p_all, prefix="cubemap-")
_add_rig_args(p_all, prefix="rig-")
p_extract = sub.add_parser("extract", help="Stage 1 only: extract sharpest frames from a video.")
p_extract.add_argument("--video", required=True, type=Path)
p_extract.add_argument("--output-dir", required=True, type=Path)
_add_extract_args(p_extract)
p_masks = sub.add_parser("masks", help="Stage 2 only: generate YOLO SfM masks.")
p_masks.add_argument("--input-dir", required=True, type=Path)
p_masks.add_argument("--output-dir", required=True, type=Path)
_add_masks_args(p_masks, prefix="")
p_cubemap = sub.add_parser("cubemap", help="Stage 3 only: project equirect frames+masks to cube faces.")
p_cubemap.add_argument("--frames-dir", required=True, type=Path)
p_cubemap.add_argument("--masks-dir", type=Path, default=None)
p_cubemap.add_argument("--output-frames-dir", required=True, type=Path)
p_cubemap.add_argument("--output-masks-dir", type=Path, default=None)
_add_cubemap_args(p_cubemap, prefix="")
p_rig = sub.add_parser("rig", help="Stage 4 only: generate the COLMAP rig config.")
p_rig.add_argument("--images-dir", type=Path, default=None)
p_rig.add_argument("--masks-dir", type=Path, default=None)
p_rig.add_argument("--output", type=Path, default=Path("rig_config.json"))
p_rig.add_argument("--face-size", type=int, default=None)
p_rig.add_argument("--layout", choices=("auto", "flat", "nested"), default="auto")
p_rig.add_argument("--link-tree", type=Path, default=None)
p_rig.add_argument("--masks-link-tree", type=Path, default=None)
p_rig.add_argument("--self-test", action="store_true")
_add_rig_args(p_rig, prefix="")
return parser
def _cli_logger(gui_log_output: bool) -> Logger:
if not gui_log_output:
return None
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
return lambda message: print(message, flush=True)
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
logger = _cli_logger(args.gui_log_output)
try:
if args.command == "all":
args.project_dir.mkdir(parents=True, exist_ok=True)
run_pipeline(args, logger=logger)
elif args.command == "extract":
run_extract_stage(
video=args.video, output_dir=args.output_dir, chunk_size=args.chunk_size,
scale_width=args.scale_width, workers=args.workers, output_pattern=args.output_pattern,
output_format=args.output_format, jpeg_quality=args.jpeg_quality,
reuse_metadata=args.reuse_metadata, logger=logger,
)
elif args.command == "masks":
run_masks_stage(
input_dir=args.input_dir, output_dir=args.output_dir, classes=args.classes, model_name=args.model,
device=args.device, conf=args.conf, imgsz=args.imgsz, dilate=args.dilate, projection=args.projection,
face_size=args.face_size, skip_faces_raw=args.skip_faces, batch=args.batch, tile_batch=args.tile_batch,
empty_if_none=args.empty_if_none, logger=logger,
)
elif args.command == "cubemap":
if args.masks_dir is not None and args.output_masks_dir is None:
parser.error("--masks-dir given but --output-masks-dir missing")
run_cubemap_stage(
frames_dir=args.frames_dir, output_frames_dir=args.output_frames_dir, masks_dir=args.masks_dir,
output_masks_dir=args.output_masks_dir, face_size=args.face_size, skip_faces_raw=args.skip_faces,
jpeg_quality=args.jpeg_quality, workers=args.workers, resume=args.resume, logger=logger,
)
elif args.command == "rig":
if args.self_test:
return run_self_test()
if args.images_dir is None:
parser.error("--images-dir is required (or use --self-test)")
if args.masks_link_tree is not None and args.masks_dir is None:
parser.error("--masks-link-tree requires --masks-dir")
run_rig_stage(
images_dir=args.images_dir, output=args.output, masks_dir=args.masks_dir, face_size=args.face_size,
layout=args.layout, ref_face=args.ref_face, intrinsics_convention=args.intrinsics_convention,
link_tree=args.link_tree, masks_link_tree=args.masks_link_tree, link_mode=args.link_mode, logger=logger,
)
except PipelineCancelled:
emit("Cancelled.", logger)
return 130
except PipelineError as exc:
emit(f"Error: {exc}", logger)
return 1
return 0
# ==========================================================================
# Self-test (stage 4 geometry/schema only -- no video/GPU required)
# ==========================================================================
def _write_dummy_png(path: Path, width: int, height: int) -> None:
raw = b"".join(b"\x00" + bytes(width) for _ in range(height))
def chunk(tag: bytes, data: bytes) -> bytes:
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def _apply(m: Mat3, v: Vec3) -> Vec3:
return tuple(sum(m[i][k] * v[k] for k in range(3)) for i in range(3)) # type: ignore
def run_self_test() -> int:
import tempfile
n, ok = 64, 0
for face in FACES:
assert_valid_rotation(face_rotation_matrix(face), f"FACE_AXES[{face}]")
ok += 1
q_r = cam_from_rig_rotation("R", "F")
expected_r = (math.sqrt(2) / 2, 0.0, -math.sqrt(2) / 2, 0.0)
assert all(abs(a - b) < 1e-12 for a, b in zip(q_r, expected_r)), q_r
assert abs(rotation_angle_deg(q_r) - 90.0) < 1e-9
ok += 1
r_mat = face_rotation_matrix("R")
assert max(abs(a - b) for a, b in zip(_apply(mat_transpose(r_mat), (0, 0, 1)), (1, 0, 0))) < 1e-12
for face, fwd in (("B", (0, 0, -1)), ("L", (-1, 0, 0)), ("U", (0, -1, 0)), ("D", (0, 1, 0))):
got = _apply(mat_transpose(face_rotation_matrix(face)), (0, 0, 1))
assert max(abs(a - b) for a, b in zip(got, fwd)) < 1e-12, (face, got)
ok += 1
fx, fy, cx, cy = compute_intrinsics(n, "linspace")
assert (fx, fy, cx, cy) == ((n - 1) / 2, (n - 1) / 2, n / 2, n / 2)
ok += 1
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
flat, masks_flat = root / "images", root / "masks"
for i in (1, 2):
for face in FACES:
_write_dummy_png(flat / f"frame_{i:05d}_{face}.png", n, n)
for face in ("F", "R", "B", "L"):
_write_dummy_png(flat / f"frame_{3:05d}_{face}.png", n, n)
for face in FACES:
_write_dummy_png(masks_flat / f"frame_{1:05d}_{face}.png.png", n, n)
for face in ("F", "R"):
_write_dummy_png(masks_flat / f"frame_{3:05d}_{face}.png.png", n, n)
out, link_tree, masks_link_tree = root / "rig.json", root / "nested", root / "masks_nested"
run_rig_stage(images_dir=flat, output=out, masks_dir=masks_flat, link_tree=link_tree,
masks_link_tree=masks_link_tree, link_mode="copy")
config = json.loads(out.read_text())
cams = config[0]["cameras"]
assert len(cams) == 6
refs = [c for c in cams if c.get("ref_sensor")]
assert len(refs) == 1 and refs[0]["image_prefix"] == "F/"
by_prefix = {c["image_prefix"]: c for c in cams}
got_r = by_prefix["R/"]["cam_from_rig_rotation"]
assert all(abs(a - b) < 1e-12 for a, b in zip(got_r, expected_r))
assert (link_tree / "F" / "frame_00001.png").is_file()
assert (masks_link_tree / "F" / "frame_00001.png.png").is_file()
assert not (masks_link_tree / "F" / "frame_00002.png.png").exists()
assert (masks_link_tree / "R" / "frame_00003.png.png").is_file()
ok += 1
partial_dir = root / "partial"
for i in (1, 2):
for face in ("F", "R", "B", "L"):
_write_dummy_png(partial_dir / f"frame_{i:05d}_{face}.png", n, n)
out2 = root / "rig_partial.json"
run_rig_stage(images_dir=partial_dir, output=out2)
cfg2 = json.loads(out2.read_text())
assert len(cfg2[0]["cameras"]) == 4
ok += 1
print(f"\nself-test: {ok} check groups passed.")
return 0
if __name__ == "__main__":
multiprocessing.freeze_support()
raise SystemExit(main())