Files
nerfstudio/lantern_scripts/align_two_folders.py
T
MrKarimiDandClaude Opus 4.8 287f56176b Add HDR metric/alignment helper scripts and combine READMEs
- align_two_folders.py: model-free contour+homography alignment of two
  image folders, with diagnostics (checkerboards, thresholds, matched
  contours, SIFT match panels).
- align_fast_to_well.py: expose the pre-RANSAC SIFT correspondences so
  the new visualizations can consume them.
- ldr_res_jpg.py: LDR (jpg) variant of ldr_res.py metric computation.
- run_metrics_all.sh: run calculate_metrics_fast across all scenes.
- collect_metrics_sheet.py: gather per-scene result JSONs into a CSV.
- README.md: merge README-pandora.md and README-hdrnerfacto.md into one
  guide (PanDORA + HDR-Nerfacto paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:28:36 -04:00

333 lines
15 KiB
Python

"""Align images from one folder to a reference folder using the same
contour-matching + homography computation as align_fast_to_well.py, but
WITHOUT needing a model to render the reference images.
It loads two folders of already-existing images:
--well_dir : reference images to align *to* (previously the rendered ones)
--fast_dir : images to be warped/aligned
and, for each matched pair, computes the homography and saves the warped image.
"""
import cv2
import numpy as np
import os
import sys
import argparse
# Reuse the alignment logic from the original script.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from align_fast_to_well import AlignImages, save_transformation
IMAGE_EXTS = ('.png', '.jpg', '.jpeg')
def list_images(directory, prefix=""):
files = [f for f in os.listdir(directory)
if f.lower().endswith(IMAGE_EXTS) and f.startswith(prefix)]
return sorted(files)
def pair_images(well_files, fast_files, match_by):
"""Return a list of (well_filename, fast_filename) pairs."""
if match_by == 'name':
well_by_stem = {os.path.splitext(f)[0]: f for f in well_files}
pairs = []
for fast_f in fast_files:
stem = os.path.splitext(fast_f)[0]
if stem in well_by_stem:
pairs.append((well_by_stem[stem], fast_f))
else:
print(f"No matching well image for: {fast_f}")
return pairs
else: # 'order'
if len(well_files) != len(fast_files):
print(f"Warning: folder counts differ "
f"(well={len(well_files)}, fast={len(fast_files)}); "
f"pairing the first {min(len(well_files), len(fast_files))} by sorted order.")
return list(zip(well_files, fast_files))
def save_checkerboard(a, b, path, tiles=8):
"""Alternating tiles of two RGB images to expose misaligned edges."""
h, w = a.shape[:2]
th, tw = h // tiles, w // tiles
out = a.copy()
for i in range(tiles):
for j in range(tiles):
if (i + j) % 2 == 1:
y0, y1 = i * th, (h if i == tiles - 1 else (i + 1) * th)
x0, x1 = j * tw, (w if j == tiles - 1 else (j + 1) * tw)
out[y0:y1, x0:x1] = b[y0:y1, x0:x1]
cv2.imwrite(path, cv2.cvtColor(out, cv2.COLOR_RGB2BGR))
def save_matched_contours(align_images, well_path, fast_path):
"""Save the matched contours on the well and fast images as two separate
images. Each matched pair uses the same color and index in both, so they
can be cross-referenced."""
well = align_images.well_image.image.copy()
fast = align_images.fast_image.image.copy()
pairs = list(zip(align_images.matched_well_contours, align_images.fast_contours))
def draw(canvas, contour, idx, color):
cv2.drawContours(canvas, [contour.contour], -1, color, 2)
cx, cy = int(contour.centroid[0]), int(contour.centroid[1])
cv2.putText(canvas, str(idx), (cx + 5, cy - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
for idx, (well_c, fast_c) in enumerate(pairs):
if well_c is None or fast_c is None:
continue
# Deterministic distinct color per matched pair (same in both images).
color_map = cv2.applyColorMap(
np.array([[int(255 * idx / max(len(pairs), 1))]], dtype=np.uint8),
cv2.COLORMAP_HSV)[0, 0]
color = (int(color_map[0]), int(color_map[1]), int(color_map[2]))
draw(well, well_c, idx, color)
draw(fast, fast_c, idx, color)
cv2.imwrite(well_path, cv2.cvtColor(well, cv2.COLOR_RGB2BGR))
cv2.imwrite(fast_path, cv2.cvtColor(fast, cv2.COLOR_RGB2BGR))
def _rotate_ccw_point(x, y, width):
"""Map a point (x, y) under a 90 deg counter-clockwise image rotation,
where `width` is the ORIGINAL image width."""
return y, (width - 1 - x)
def _rotate_ccw_contour(contour, width):
"""Apply the 90 deg CCW point mapping to an (N,1,2) contour array."""
pts = contour.reshape(-1, 2)
rotated = np.empty_like(pts)
rotated[:, 0] = pts[:, 1]
rotated[:, 1] = width - 1 - pts[:, 0]
return rotated.reshape(-1, 1, 2)
def save_matched_contours_panel(align_images, path):
"""Side-by-side panel with each image rotated 90 deg counter-clockwise,
each matched contour pair drawn in the same color and connected by a line
across the panel."""
well = cv2.rotate(align_images.well_image.image.copy(), cv2.ROTATE_90_COUNTERCLOCKWISE)
fast = cv2.rotate(align_images.fast_image.image.copy(), cv2.ROTATE_90_COUNTERCLOCKWISE)
# Original widths (needed for the coordinate rotation).
w_w0 = align_images.well_image.image.shape[1]
w_f0 = align_images.fast_image.image.shape[1]
# Rotated image dimensions.
h_w, w_w = well.shape[:2]
h_f, w_f = fast.shape[:2]
h = max(h_w, h_f)
canvas = np.zeros((h, w_w + w_f, 3), dtype=np.uint8)
canvas[:h_w, :w_w] = well
canvas[:h_f, w_w:w_w + w_f] = fast
pairs = list(zip(align_images.matched_well_contours, align_images.fast_contours))
for idx, (well_c, fast_c) in enumerate(pairs):
if well_c is None or fast_c is None:
continue
# Deterministic distinct color per matched pair.
color_map = cv2.applyColorMap(
np.array([[int(255 * idx / max(len(pairs), 1))]], dtype=np.uint8),
cv2.COLORMAP_HSV)[0, 0]
color = (int(color_map[0]), int(color_map[1]), int(color_map[2]))
well_contour_rot = _rotate_ccw_contour(well_c.contour, w_w0)
fast_contour_rot = _rotate_ccw_contour(fast_c.contour, w_f0)
cv2.drawContours(canvas, [well_contour_rot], -1, color, 2)
cv2.drawContours(canvas, [fast_contour_rot + np.array([[w_w, 0]])], -1, color, 2)
wx, wy = _rotate_ccw_point(int(well_c.centroid[0]), int(well_c.centroid[1]), w_w0)
fx, fy = _rotate_ccw_point(int(fast_c.centroid[0]), int(fast_c.centroid[1]), w_f0)
fx += w_w
cv2.line(canvas, (wx, wy), (fx, fy), color, 3)
cv2.imwrite(path, cv2.cvtColor(canvas, cv2.COLOR_RGB2BGR))
def save_sift_matches_panel(align_images, path, layout="vertical", max_matches=40):
"""Panel drawing SIFT/BFMatcher point correspondences (post-BFMatcher,
pre-RANSAC) as connecting lines.
layout='vertical' -> fast exposure on top, well exposure on bottom.
layout='horizontal' -> fast exposure on left, well exposure on right.
max_matches limits how many correspondences are drawn (evenly subsampled)
so the visualization stays readable."""
well = align_images.well_image.image.copy()
fast = align_images.fast_image.image.copy()
h_w, w_w = well.shape[:2]
h_f, w_f = fast.shape[:2]
if layout == "horizontal":
canvas = np.zeros((max(h_w, h_f), w_f + w_w, 3), dtype=np.uint8)
canvas[:h_f, :w_f] = fast # fast exposure on left
canvas[:h_w, w_f:w_f + w_w] = well # well exposure on right
fast_offset = (0, 0)
well_offset = (w_f, 0)
else: # vertical
canvas = np.zeros((h_f + h_w, max(w_w, w_f), 3), dtype=np.uint8)
canvas[:h_f, :w_f] = fast # fast exposure on top
canvas[h_f:h_f + h_w, :w_w] = well # well exposure on bottom
fast_offset = (0, 0)
well_offset = (0, h_f)
well_pts = align_images.well_points_combined
fast_pts = align_images.fast_points_combined
n = min(len(well_pts), len(fast_pts))
# Evenly subsample down to max_matches for readability.
if max_matches and n > max_matches:
indices = np.linspace(0, n - 1, max_matches).astype(int)
else:
indices = range(n)
for k, idx in enumerate(indices):
wp = well_pts[idx]
fp = fast_pts[idx]
# Distinct color per drawn correspondence.
color_map = cv2.applyColorMap(
np.array([[int(255 * k / max(len(indices), 1))]], dtype=np.uint8),
cv2.COLORMAP_HSV)[0, 0]
color = (int(color_map[0]), int(color_map[1]), int(color_map[2]))
fx, fy = int(fp[0]) + fast_offset[0], int(fp[1]) + fast_offset[1]
wx, wy = int(wp[0]) + well_offset[0], int(wp[1]) + well_offset[1]
cv2.circle(canvas, (fx, fy), 5, color, -1)
cv2.circle(canvas, (wx, wy), 5, color, -1)
cv2.line(canvas, (fx, fy), (wx, wy), color, 3)
cv2.imwrite(path, cv2.cvtColor(canvas, cv2.COLOR_RGB2BGR))
def save_thresholded(align_images, output_dir, base):
"""Save the binary threshold maps that contour detection operates on."""
cv2.imwrite(os.path.join(output_dir, base + "_fast_threshold.png"),
align_images.fast_image.thresholded)
for img, thr in zip(align_images.well_images, align_images.well_thresholds):
cv2.imwrite(os.path.join(output_dir, base + f"_well_threshold_{thr}.png"),
img.thresholded)
if __name__ == '__main__':
argparser = argparse.ArgumentParser()
argparser.add_argument("--well_dir", type=str, required=True,
help="Folder of reference images to align to.")
argparser.add_argument("--fast_dir", type=str, required=True,
help="Folder of images to be aligned (warped).")
argparser.add_argument("--output_dir", type=str, required=True,
help="Folder to write the aligned images into.")
argparser.add_argument("--match_by", type=str, default="order",
choices=["order", "name"],
help="Pair images by sorted order (default) or by filename stem.")
argparser.add_argument("--prefix", type=str, default="right",
help="Only align fast_dir images whose filename starts with this prefix "
"(use '' to process all images).")
argparser.add_argument("--stride", type=int, default=1,
help="Process only every Nth image pair (e.g. 5 = every 5th).")
argparser.add_argument("--only", type=str, default=None,
help="Process only this single fast_dir filename (its correct "
"well partner is still resolved via the full pairing).")
argparser.add_argument("--save_merged", action='store_true', default=False,
help="Also save before/after overlay visualizations.")
argparser.add_argument("--visualize_contours", action='store_true', default=False,
help="Save detected/matched contour visualizations for debugging.")
argparser.add_argument("--save_diagnostics", action='store_true', default=False,
help="Save diagnostics: before/after checkerboard composites, "
"thresholded binary maps, and contour visualizations.")
argparser.add_argument("--sift_layout", type=str, default="vertical",
choices=["vertical", "horizontal"],
help="Layout for *_sift_matches.png: vertical (fast top / well bottom) "
"or horizontal (fast left / well right).")
argparser.add_argument("--sift_max_matches", type=int, default=40,
help="Max number of SIFT correspondences drawn in *_sift_matches.png "
"(evenly subsampled for readability; 0 = draw all).")
args = argparser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
json_path = os.path.join(args.output_dir, 'alignment_matrices.json')
merged_dir = os.path.join(args.output_dir, 'merged')
contour_visualizations_path = os.path.join(args.output_dir, 'contour_visualizations')
diagnostics_dir = os.path.join(args.output_dir, 'diagnostics')
if args.save_merged:
os.makedirs(merged_dir, exist_ok=True)
if args.save_diagnostics:
os.makedirs(diagnostics_dir, exist_ok=True)
well_files = list_images(args.well_dir)
fast_files = list_images(args.fast_dir, prefix=args.prefix)
pairs = pair_images(well_files, fast_files, args.match_by)
if args.only is not None:
pairs = [(w, f) for (w, f) in pairs if f == args.only]
if not pairs:
print(f"'{args.only}' not found among the paired fast images; nothing to do.")
sys.exit(0)
if args.stride > 1:
pairs = pairs[::args.stride]
print(f"Aligning {len(pairs)} image pairs...")
for well_name, fast_name in pairs:
well_path = os.path.join(args.well_dir, well_name)
fast_path = os.path.join(args.fast_dir, fast_name)
well_image = cv2.cvtColor(cv2.imread(well_path), cv2.COLOR_BGR2RGB)
fast_image = cv2.cvtColor(cv2.imread(fast_path), cv2.COLOR_BGR2RGB)
align_images = None
try:
align_images = AlignImages(well_image, fast_image)
warped_image, matrix = align_images.alignImages()
print(f"Aligned: {fast_name} -> {well_name}")
except Exception as e:
warped_image = fast_image
matrix = np.zeros((3, 3))
print(f"Failed to align: {fast_name} ({e})")
base = os.path.splitext(fast_name)[0]
if args.visualize_contours and align_images is not None:
try:
align_images.save_contour_visualizations(contour_visualizations_path, base + ".png")
except Exception:
print(f"Failed to visualize contours: {fast_name}")
out_path = os.path.join(args.output_dir, fast_name)
cv2.imwrite(out_path, cv2.cvtColor(warped_image, cv2.COLOR_RGB2BGR))
save_transformation(json_path, fast_name, matrix)
if args.save_diagnostics:
try:
save_checkerboard(well_image, fast_image,
os.path.join(diagnostics_dir, base + "_checkerboard_before.png"))
save_checkerboard(well_image, warped_image,
os.path.join(diagnostics_dir, base + "_checkerboard_after.png"))
if align_images is not None:
save_thresholded(align_images, diagnostics_dir, base)
align_images.save_contour_visualizations(diagnostics_dir, base + ".png")
save_matched_contours(
align_images,
os.path.join(diagnostics_dir, base + "_matched_well.png"),
os.path.join(diagnostics_dir, base + "_matched_fast.png"))
save_matched_contours_panel(
align_images,
os.path.join(diagnostics_dir, base + "_matched_panel.png"))
save_sift_matches_panel(
align_images,
os.path.join(diagnostics_dir, base + "_sift_matches.png"),
layout=args.sift_layout,
max_matches=args.sift_max_matches)
except Exception as e:
print(f"Failed to save diagnostics: {fast_name} ({e})")
if args.save_merged:
alpha = 50.0 / 100.0
merged_before = cv2.addWeighted(well_image, 1.0 - alpha, fast_image, alpha, 0)
merged_before_path = os.path.join(merged_dir, fast_name).rsplit('.', 1)[0] + "_before.png"
cv2.imwrite(merged_before_path, cv2.cvtColor(merged_before, cv2.COLOR_RGB2BGR))
merged_after = cv2.addWeighted(well_image, 1.0 - alpha, warped_image, alpha, 0)
merged_after_path = os.path.join(merged_dir, fast_name).rsplit('.', 1)[0] + "_after.png"
cv2.imwrite(merged_after_path, cv2.cvtColor(merged_after, cv2.COLOR_RGB2BGR))
print("All done :D")