From c590db302617373dafda7100fa32c762a7ac5150 Mon Sep 17 00:00:00 2001 From: Franck Garnier Date: Sun, 12 Apr 2026 17:21:41 -0400 Subject: [PATCH] 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) --- pipeline/panorama_pipeline.py | 130 +++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 24 deletions(-) diff --git a/pipeline/panorama_pipeline.py b/pipeline/panorama_pipeline.py index ce4fc47..ef78376 100644 --- a/pipeline/panorama_pipeline.py +++ b/pipeline/panorama_pipeline.py @@ -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, - 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. + For large panoramas (>chunk_size images), cpfind runs on overlapping chunks + to avoid O(n^2) explosion in cpfind --multirow and cpclean. + 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 - verdandi: force verdandi """ @@ -296,35 +300,113 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els, blender = "enblend" 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") build_pto(pto_clahe, clahe_images, azs, els, img_w, img_h, fov) - # cpfind on CLAHE images - print(" cpfind...", flush=True) - 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 "{pto_clahe}" "{pto_clahe}"', - cwd=work_dir) - timings["cpfind"] = time.time() - t0 - for line in r.stdout.split("\n"): - if "Found" in line: - print(f" {line.strip()}") + if n_images <= chunk_size: + # Small panorama: run cpfind on all images at once + print(f" cpfind ({n_images} images)...", flush=True) + 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 "{pto_clahe}" "{pto_clahe}"', + cwd=work_dir) + timings["cpfind"] = time.time() - t0 + for line in r.stdout.split("\n"): + if "Found" in line: + print(f" {line.strip()}") - # cpclean - print(" cpclean...", flush=True) - t0 = time.time() - run_hugin("cpclean", f'-o "{pto_clahe}" "{pto_clahe}"', cwd=work_dir) - timings["cpclean"] = time.time() - t0 + # cpclean + print(" cpclean...", flush=True) + t0 = time.time() + run_hugin("cpclean", f'-o "{pto_clahe}" "{pto_clahe}"', cwd=work_dir) + 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: 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: 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: 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) t0 = time.time() run_hugin("autooptimiser", f'-a -l -s -o "{pto_render}" "{pto_render}"', cwd=work_dir)