diff --git a/images/stitch-preview/north-sunrise-aligned-preview.jpg b/images/stitch-preview/north-sunrise-aligned-preview.jpg new file mode 100644 index 0000000..f412ef2 Binary files /dev/null and b/images/stitch-preview/north-sunrise-aligned-preview.jpg differ diff --git a/stitch-cameras.py b/stitch-cameras.py index d3a513a..55f6701 100644 --- a/stitch-cameras.py +++ b/stitch-cameras.py @@ -1,114 +1,79 @@ #!/usr/bin/env python3 -"""Stitch north + sunrise — v2. - -Lessons from v1: full homography needs lots of good inliers. We had 5, which -over-fit and produced streaking. Fixes: - 1. Mask out the top half (sky/clouds) — clouds moved in the 36 s between - the two shots, so they poison matching. - 2. Use a similarity transform (rotation + uniform scale + translation, - 4 DOF) via estimateAffinePartial2D — stable with few inliers. - 3. Narrow the candidate band to the expected overlap sliver. +""" +Final stitch: north left, sunrise right. +Translation from phase-correlation (tx=-3324, ty=-85). +Feathered gradient blend across the overlap zone. """ import sys, cv2, numpy as np -north_path, sunrise_path, out_prefix = sys.argv[1], sys.argv[2], sys.argv[3] +north_p, sunrise_p, out_p = sys.argv[1], sys.argv[2], sys.argv[3] +tx_arg = int(sys.argv[4]) if len(sys.argv) > 4 else -3324 +ty_arg = int(sys.argv[5]) if len(sys.argv) > 5 else -85 -north = cv2.imread(north_path) -sunrise = cv2.imread(sunrise_path) -H_n, W_n = north.shape[:2] -H_s, W_s = sunrise.shape[:2] -print(f"north {W_n}x{H_n}") -print(f"sunrise {W_s}x{H_s}") +north = cv2.imread(north_p) +sunrise = cv2.imread(sunrise_p) +H, W = north.shape[:2] -# 1) Naive side-by-side baseline so the user always has something to look at. -naive = np.hstack([north, sunrise]) -cv2.imwrite(f"{out_prefix}-naive.jpg", naive) +tx, ty = tx_arg, ty_arg # north → sunrise translation +print(f"Using tx={tx} ty={ty}") -# 2) Take only the ground half (skip sky where clouds change) and the -# band where the two FOVs should overlap: -# right 40% of north × left 40% of sunrise -sky_cutoff = 0.45 # keep rows from y = 0.45*H downwards -ts_cutoff = 0.96 # cut the bottom ~4% to drop the burnt-in timestamp -y0_n, y1_n = int(H_n * sky_cutoff), int(H_n * ts_cutoff) -y0_s, y1_s = int(H_s * sky_cutoff), int(H_s * ts_cutoff) -band_frac = 0.40 +# ── Canvas ──────────────────────────────────────────────────────────────────── +# Sunrise is at the canvas origin (0,0). +# North is at (tx, ty) relative to sunrise — tx is negative so north is LEFT. +canvas_x0 = min(0, tx) # leftmost pixel +canvas_y0 = min(0, ty) +canvas_x1 = max(W, W + tx) +canvas_y1 = max(H, H + ty) +cW = int(canvas_x1 - canvas_x0) +cH = int(canvas_y1 - canvas_y0) -n_band = north [y0_n:y1_n, int(W_n * (1 - band_frac)):] -s_band = sunrise[y0_s:y1_s, : int(W_s * band_frac)] -n_ox, n_oy = int(W_n * (1 - band_frac)), y0_n # band → full-image offset -s_ox, s_oy = 0, y0_s +# Canvas offsets for each image +s_ox, s_oy = int(-canvas_x0), int(-canvas_y0) # sunrise top-left in canvas +n_ox, n_oy = s_ox + tx, s_oy + ty # north top-left in canvas -orb = cv2.ORB_create(nfeatures=8000, scaleFactor=1.2, nlevels=10, edgeThreshold=15) -kp_n, des_n = orb.detectAndCompute(cv2.cvtColor(n_band, cv2.COLOR_BGR2GRAY), None) -kp_s, des_s = orb.detectAndCompute(cv2.cvtColor(s_band, cv2.COLOR_BGR2GRAY), None) -print(f"ORB keypoints: north-band={len(kp_n)} sunrise-band={len(kp_s)}") +canvas = np.zeros((cH, cW, 3), dtype=np.uint8) -bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) -raw = bf.knnMatch(des_n, des_s, k=2) -good = [m for m, n in raw if m.distance < 0.8 * n.distance] -print(f"good Lowe-ratio matches: {len(good)}") +# Paint north first (background) +nx0, ny0 = int(n_ox), int(n_oy) +canvas[ny0:ny0+H, nx0:nx0+W] = north -if len(good) < 6: - print("Not enough matches to align — writing naive hstack as final.") - cv2.imwrite(f"{out_prefix}-aligned.jpg", naive) - sys.exit(0) +# Paint sunrise on top (base) +sx0, sy0 = int(s_ox), int(s_oy) +canvas[sy0:sy0+H, sx0:sx0+W] = sunrise -src = np.float32([[kp_n[m.queryIdx].pt[0] + n_ox, - kp_n[m.queryIdx].pt[1] + n_oy] for m in good]) -dst = np.float32([[kp_s[m.trainIdx].pt[0] + s_ox, - kp_s[m.trainIdx].pt[1] + s_oy] for m in good]) +# ── Feathered blend in overlap zone ────────────────────────────────────────── +# Overlap: columns where both images exist in the canvas +ov_x0 = max(sx0, nx0) +ov_x1 = min(sx0 + W, nx0 + W) +ov_y0 = max(sy0, ny0) +ov_y1 = min(sy0 + H, ny0 + H) -# Similarity transform: rotation + uniform scale + translation. -M, inl = cv2.estimateAffinePartial2D(src, dst, method=cv2.RANSAC, - ransacReprojThreshold=5.0, maxIters=5000, - confidence=0.99) -n_inl = int(inl.sum()) if inl is not None else 0 -print(f"similarity inliers: {n_inl}/{len(good)}") +if ov_x1 > ov_x0 and ov_y1 > ov_y0: + ov_w = ov_x1 - ov_x0 + ov_h = ov_y1 - ov_y0 + print(f"Overlap zone: {ov_w}x{ov_h}px at canvas x=[{ov_x0},{ov_x1}]") -if M is None or n_inl < 4: - print("Similarity fit too weak — writing naive hstack as final.") - cv2.imwrite(f"{out_prefix}-aligned.jpg", naive) - sys.exit(0) + # Horizontal gradient: sunrise fades OUT on the left (where north takes over) + # north fades OUT on the right (where sunrise takes over) + alpha = np.linspace(0.0, 1.0, ov_w, dtype=np.float32) # 0=north, 1=sunrise + alpha = alpha[np.newaxis, :, np.newaxis] # shape (1, ov_w, 1) -a, b, tx = M[0]; c, d, ty = M[1] -scale = np.sqrt(a * a + c * c) -rot_deg = np.degrees(np.arctan2(c, a)) -print(f"est. transform: scale={scale:.4f} rot={rot_deg:+.2f}° tx={tx:+.1f} ty={ty:+.1f}") + n_patch = north [ov_y0-ny0:ov_y1-ny0, ov_x0-nx0:ov_x1-nx0].astype(np.float32) + s_patch = sunrise[ov_y0-sy0:ov_y1-sy0, ov_x0-sx0:ov_x1-sx0].astype(np.float32) + blended = (s_patch * alpha + n_patch * (1.0 - alpha)).astype(np.uint8) + canvas[ov_y0:ov_y1, ov_x0:ov_x1] = blended -# Warp north into sunrise's coordinate frame, then compose the canvas. -M3 = np.vstack([M, [0, 0, 1]]) -n_corners = np.float32([[0, 0], [W_n, 0], [W_n, H_n], [0, H_n]]).reshape(-1, 1, 2) -n_warped = cv2.perspectiveTransform(n_corners, M3) -s_corners = np.float32([[0, 0], [W_s, 0], [W_s, H_s], [0, H_s]]).reshape(-1, 1, 2) -all_c = np.concatenate([n_warped, s_corners]) -[xmin, ymin] = np.int32(all_c.min(axis=0).ravel() - 0.5) -[xmax, ymax] = np.int32(all_c.max(axis=0).ravel() + 0.5) -W, H = xmax - xmin, ymax - ymin -shift = np.array([[1, 0, -xmin], [0, 1, -ymin]], dtype=np.float64) -M_shifted = shift @ M3 # 2x3 after slicing -M_shifted = M_shifted[:2] -print(f"canvas {W}x{H} shift=({-xmin},{-ymin})") +# ── Crop off the black margin at top/bottom from the ty offset ─────────────── +# After the shift there may be a thin black bar top or bottom — crop it. +top_crop = max(sy0, ny0) # first row where BOTH images exist +bottom_crop = min(sy0+H, ny0+H) # last row where either image exists +canvas = canvas[top_crop:bottom_crop, :] -warped_n = cv2.warpAffine(north, M_shifted, (W, H)) +cv2.imwrite(out_p, canvas, [cv2.IMWRITE_JPEG_QUALITY, 92]) +print(f"Output: {out_p} size={canvas.shape[1]}x{canvas.shape[0]}") -canvas = warped_n.copy() -y0, x0 = -ymin, -xmin -s_slot = canvas[y0:y0 + H_s, x0:x0 + W_s] -n_mask = (warped_n[y0:y0 + H_s, x0:x0 + W_s].sum(axis=2) > 0).astype(np.float32)[..., None] -# Where north has content → 60/40 blend favouring sunrise (base). Else just sunrise. -blended = (sunrise.astype(np.float32) * (0.4 + 0.6 * (1 - n_mask)) + - s_slot.astype(np.float32) * (0.6 * n_mask)).astype(np.uint8) -# Simpler: just drop sunrise on top wherever it exists. -canvas[y0:y0 + H_s, x0:x0 + W_s] = sunrise - -cv2.imwrite(f"{out_prefix}-aligned.jpg", canvas) -np.savetxt(f"{out_prefix}-similarity.txt", M, fmt="%.8f") -print(f"wrote {out_prefix}-aligned.jpg and {out_prefix}-similarity.txt") - -# Also draw a debug image showing matched inliers. -dbg = cv2.drawMatches( - n_band, kp_n, s_band, kp_s, - [good[i] for i in range(len(good)) if inl[i]], - None, flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS) -cv2.imwrite(f"{out_prefix}-matches.jpg", dbg) -print(f"wrote {out_prefix}-matches.jpg") +# ── Save a 50% scaled preview for quick viewing ─────────────────────────────── +preview = cv2.resize(canvas, (canvas.shape[1]//2, canvas.shape[0]//2)) +cv2.imwrite(out_p.replace(".jpg", "-preview.jpg"), preview, + [cv2.IMWRITE_JPEG_QUALITY, 85]) +print(f"Preview saved.")