From acd22d41dc83aa1a15a170da395c1f8a44de0482 Mon Sep 17 00:00:00 2001 From: Franck Garnier Date: Sun, 12 Apr 2026 19:28:38 -0400 Subject: [PATCH] feat: incremental assembly for large panoramas Replace parallel chunk strategy with sequential incremental assembly: 1. Start with first 20 images, full cpfind + optimize 2. Add 5 new images at a time with 5-image bridge to existing set 3. cpfind only on bridge+new group (small, fast) 4. Remap CPs to global indices and accumulate This ensures spatial coherence: each new batch is constrained by the already-stable panorama, preventing orientation flips that occurred with the parallel chunk approach. Images are sorted by azimuth, so consecutive indices = spatial neighbors (guaranteed contiguity). Co-Authored-By: Claude Opus 4.6 (1M context) --- pipeline/panorama_pipeline.py | 117 ++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 39 deletions(-) diff --git a/pipeline/panorama_pipeline.py b/pipeline/panorama_pipeline.py index ef78376..c388a2c 100644 --- a/pipeline/panorama_pipeline.py +++ b/pipeline/panorama_pipeline.py @@ -329,77 +329,116 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els, 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 + # Large panorama: incremental assembly strategy + # Images are sorted by azimuth — consecutive indices = spatial neighbors. + # We build the panorama progressively: + # 1. Start with first initial_size images, full cpfind + optimize + # 2. Add batch_size new images at a time + # 3. cpfind only between new images and the tail of existing set + # 4. Optimize incrementally (existing images constrain new ones) + initial_size = chunk_size # first group size + batch_size = chunk_overlap # images to add per iteration + bridge_size = 5 # how many existing images to overlap with new ones - print(f" Chunked cpfind: {n_images} images -> {len(chunks)} chunks of ~{chunk_size} " - f"(overlap={chunk_overlap})", flush=True) + print(f" Incremental assembly: {n_images} images, " + f"initial={initial_size}, batch={batch_size}, bridge={bridge_size}", + 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] + # Phase 1: initial group — full cpfind + init_end = min(initial_size, n_images) + init_pto = os.path.join(work_dir, f"{output_name}_init.pto") + build_pto(init_pto, clahe_images[:init_end], + azs[:init_end], els[:init_end], img_w, img_h, fov) - # 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) + print(f" Phase 1: cpfind on images 0-{init_end-1} ({init_end} 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 "{init_pto}" "{init_pto}"', + cwd=work_dir) + run_hugin("cpclean", f'-o "{init_pto}" "{init_pto}"', cwd=work_dir) - # cpfind on chunk + # Read initial CPs (local indices = global indices for first group) + with open(init_pto) as f: + for line in f: + if line.startswith("c "): + all_cp_lines.append(line) + os.remove(init_pto) + + init_time = time.time() - t0 + print(f" Initial: {init_time:.0f}s, CPs: {len(all_cp_lines)}", flush=True) + + # Phase 2: add batches incrementally + cursor = init_end + batch_num = 0 + while cursor < n_images: + batch_num += 1 + batch_end = min(cursor + batch_size, n_images) + new_count = batch_end - cursor + + # Bridge: last bridge_size images from existing set + new images + bridge_start = max(0, cursor - bridge_size) + group_start = bridge_start + group_end = batch_end + group_size = group_end - group_start + + # Build PTO for this bridge+new group + batch_pto = os.path.join(work_dir, f"{output_name}_batch{batch_num}.pto") + build_pto(batch_pto, + clahe_images[group_start:group_end], + azs[group_start:group_end], + els[group_start:group_end], + img_w, img_h, fov) + + # cpfind on bridge+new group 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}"', + f'-o "{batch_pto}" "{batch_pto}"', cwd=work_dir) - cp_time = time.time() - t0 + run_hugin("cpclean", f'-o "{batch_pto}" "{batch_pto}"', cwd=work_dir) + batch_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: + # Read CPs and remap local indices to global + batch_cps = 0 + with open(batch_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}") + new_parts.append(f"n{group_start + local_idx}") elif p.startswith("N"): local_idx = int(p[1:]) - new_parts.append(f"N{start + local_idx}") + new_parts.append(f"N{group_start + local_idx}") else: new_parts.append(p) all_cp_lines.append(" ".join(new_parts) + "\n") + batch_cps += 1 - # Cleanup chunk PTO - os.remove(chunk_pto) + os.remove(batch_pto) + print(f" Batch {batch_num}: imgs {cursor}-{batch_end-1} " + f"(bridge from {bridge_start}): {batch_time:.0f}s, " + f"+{batch_cps} CPs, total: {len(all_cp_lines)}", flush=True) - 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) + cursor = batch_end timings["cpfind"] = time.time() - t0_total - timings["cpclean"] = 0 # included in per-chunk processing + timings["cpclean"] = 0 # included in per-batch processing - # Write CPs back to the full CLAHE PTO + # Write all CPs to the full CLAHE PTO with open(pto_clahe, "a") as f: f.writelines(all_cp_lines)