feat: chunked cpfind for large panoramas (>20 images)
Splits large image sets into overlapping chunks of 20 images (5 image overlap between chunks). Each chunk runs cpfind + cpclean independently, then CPs are merged with remapped global indices. Avoids O(n^2) explosion: - 80 images monolithic: cpfind ~30min + cpclean ~70min = ~100min - 80 images in 4 chunks of 20: ~4x(2min + 0.5min) = ~10min estimated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -280,11 +280,15 @@ def build_pto(pto_path, img_paths, azs, els, img_w, img_h, fov, ref_idx=None):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def run_pipeline(work_dir, clahe_images, original_images, azs, els,
|
def run_pipeline(work_dir, clahe_images, original_images, azs, els,
|
||||||
img_w, img_h, fov, output_name, blender="auto", camera=None):
|
img_w, img_h, fov, output_name, blender="auto", camera=None,
|
||||||
|
chunk_size=20, chunk_overlap=5):
|
||||||
"""Full pipeline: cpfind(CLAHE) -> swap(originals) -> optimize(geo) -> nona -> blend.
|
"""Full pipeline: cpfind(CLAHE) -> swap(originals) -> optimize(geo) -> nona -> blend.
|
||||||
|
|
||||||
|
For large panoramas (>chunk_size images), cpfind runs on overlapping chunks
|
||||||
|
to avoid O(n^2) explosion in cpfind --multirow and cpclean.
|
||||||
|
|
||||||
Blender selection:
|
Blender selection:
|
||||||
- auto: enblend for NavCam (better vignette/horizon), verdandi for Mastcam-Z (no seam overexposure)
|
- auto: enblend for NavCam, verdandi for Mastcam-Z
|
||||||
- enblend: force enblend --pre-assemble
|
- enblend: force enblend --pre-assemble
|
||||||
- verdandi: force verdandi
|
- verdandi: force verdandi
|
||||||
"""
|
"""
|
||||||
@@ -296,13 +300,15 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els,
|
|||||||
blender = "enblend"
|
blender = "enblend"
|
||||||
|
|
||||||
timings = {}
|
timings = {}
|
||||||
|
n_images = len(clahe_images)
|
||||||
|
|
||||||
# Build CLAHE PTO
|
# Build full CLAHE PTO (needed for final assembly)
|
||||||
pto_clahe = os.path.join(work_dir, f"{output_name}_clahe.pto")
|
pto_clahe = os.path.join(work_dir, f"{output_name}_clahe.pto")
|
||||||
build_pto(pto_clahe, clahe_images, azs, els, img_w, img_h, fov)
|
build_pto(pto_clahe, clahe_images, azs, els, img_w, img_h, fov)
|
||||||
|
|
||||||
# cpfind on CLAHE images
|
if n_images <= chunk_size:
|
||||||
print(" cpfind...", flush=True)
|
# Small panorama: run cpfind on all images at once
|
||||||
|
print(f" cpfind ({n_images} images)...", flush=True)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
r = run_hugin("cpfind",
|
r = run_hugin("cpfind",
|
||||||
f"--multirow --celeste "
|
f"--multirow --celeste "
|
||||||
@@ -322,9 +328,85 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els,
|
|||||||
run_hugin("cpclean", f'-o "{pto_clahe}" "{pto_clahe}"', cwd=work_dir)
|
run_hugin("cpclean", f'-o "{pto_clahe}" "{pto_clahe}"', cwd=work_dir)
|
||||||
timings["cpclean"] = time.time() - t0
|
timings["cpclean"] = time.time() - t0
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Large panorama: chunked cpfind strategy
|
||||||
|
# Split into overlapping chunks, cpfind each, merge CPs
|
||||||
|
step = chunk_size - chunk_overlap
|
||||||
|
chunks = []
|
||||||
|
i = 0
|
||||||
|
while i < n_images:
|
||||||
|
end = min(i + chunk_size, n_images)
|
||||||
|
chunks.append((i, end))
|
||||||
|
if end >= n_images:
|
||||||
|
break
|
||||||
|
i += step
|
||||||
|
|
||||||
|
print(f" Chunked cpfind: {n_images} images -> {len(chunks)} chunks of ~{chunk_size} "
|
||||||
|
f"(overlap={chunk_overlap})", flush=True)
|
||||||
|
|
||||||
|
all_cp_lines = []
|
||||||
|
t0_total = time.time()
|
||||||
|
|
||||||
|
for chunk_idx, (start, end) in enumerate(chunks):
|
||||||
|
chunk_clahe = clahe_images[start:end]
|
||||||
|
chunk_azs = azs[start:end]
|
||||||
|
chunk_els = els[start:end]
|
||||||
|
|
||||||
|
# Build chunk PTO
|
||||||
|
chunk_pto = os.path.join(work_dir, f"{output_name}_chunk{chunk_idx}.pto")
|
||||||
|
build_pto(chunk_pto, chunk_clahe, chunk_azs, chunk_els, img_w, img_h, fov)
|
||||||
|
|
||||||
|
# cpfind on chunk
|
||||||
|
t0 = time.time()
|
||||||
|
r = run_hugin("cpfind",
|
||||||
|
f"--multirow --celeste "
|
||||||
|
f"--sieve1width 20 --sieve1height 20 --sieve1size 1000 "
|
||||||
|
f"--sieve2width 10 --sieve2height 10 --sieve2size 5 "
|
||||||
|
f"--minmatches 3 --ransaciter 2000 --ransacdist 50 "
|
||||||
|
f'-o "{chunk_pto}" "{chunk_pto}"',
|
||||||
|
cwd=work_dir)
|
||||||
|
cp_time = time.time() - t0
|
||||||
|
|
||||||
|
# cpclean on chunk
|
||||||
|
run_hugin("cpclean", f'-o "{chunk_pto}" "{chunk_pto}"', cwd=work_dir)
|
||||||
|
|
||||||
|
# Read CPs and remap indices to global
|
||||||
|
with open(chunk_pto) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("c "):
|
||||||
|
# Remap local image indices to global
|
||||||
|
parts = line.split()
|
||||||
|
new_parts = []
|
||||||
|
for p in parts:
|
||||||
|
if p.startswith("n") and not p.startswith("N"):
|
||||||
|
local_idx = int(p[1:])
|
||||||
|
new_parts.append(f"n{start + local_idx}")
|
||||||
|
elif p.startswith("N"):
|
||||||
|
local_idx = int(p[1:])
|
||||||
|
new_parts.append(f"N{start + local_idx}")
|
||||||
|
else:
|
||||||
|
new_parts.append(p)
|
||||||
|
all_cp_lines.append(" ".join(new_parts) + "\n")
|
||||||
|
|
||||||
|
# Cleanup chunk PTO
|
||||||
|
os.remove(chunk_pto)
|
||||||
|
|
||||||
|
chunk_cps = len([l for l in all_cp_lines]) # running total
|
||||||
|
print(f" Chunk {chunk_idx+1}/{len(chunks)} "
|
||||||
|
f"(imgs {start}-{end-1}): {cp_time:.0f}s, "
|
||||||
|
f"total CPs so far: {len(all_cp_lines)}", flush=True)
|
||||||
|
|
||||||
|
timings["cpfind"] = time.time() - t0_total
|
||||||
|
timings["cpclean"] = 0 # included in per-chunk processing
|
||||||
|
|
||||||
|
# Write CPs back to the full CLAHE PTO
|
||||||
|
with open(pto_clahe, "a") as f:
|
||||||
|
f.writelines(all_cp_lines)
|
||||||
|
|
||||||
|
# Count final CPs
|
||||||
with open(pto_clahe) as f:
|
with open(pto_clahe) as f:
|
||||||
cp_lines = [l for l in f if l.startswith("c ")]
|
cp_lines = [l for l in f if l.startswith("c ")]
|
||||||
print(f" CPs: {len(cp_lines)}")
|
print(f" Total CPs: {len(cp_lines)}")
|
||||||
|
|
||||||
if len(cp_lines) < 5:
|
if len(cp_lines) < 5:
|
||||||
print(" ERROR: Not enough control points")
|
print(" ERROR: Not enough control points")
|
||||||
@@ -336,7 +418,7 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els,
|
|||||||
with open(pto_render, "a") as f:
|
with open(pto_render, "a") as f:
|
||||||
f.writelines(cp_lines)
|
f.writelines(cp_lines)
|
||||||
|
|
||||||
# Geometry-only optimization (NO -m, NO -a to avoid d/e distortion)
|
# Geometry-only optimization (NO -m)
|
||||||
print(" autooptimiser (geo only)...", flush=True)
|
print(" autooptimiser (geo only)...", flush=True)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
run_hugin("autooptimiser", f'-a -l -s -o "{pto_render}" "{pto_render}"', cwd=work_dir)
|
run_hugin("autooptimiser", f'-a -l -s -o "{pto_render}" "{pto_render}"', cwd=work_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user