Files
sky-cam/stitch-cameras.py
T
Claude 8908c2f01a Add camera-stitch preview and feature-matching script
- images/stitch-preview/north-sunrise-hstack.jpg:
  naive side-by-side preview of the two skycams at 14:29 on 2026-04-17
- images/stitch-preview/feature-matches-debug.jpg:
  close-up of overlap region (right 40% of north × left 40% of sunrise)
  with ORB Lowe-ratio matches drawn — 39 candidates, RANSAC survives only
  4 inliers because the 90° perspective offset invalidates a similarity
  transform.  Overlap IS present (trees, houses, power poles visible in
  both) but full homography needs more/better tie points.
- stitch-cameras.py: reusable script that generates both previews plus a
  similarity-transform .txt for reuse in ffmpeg filter chains once we
  have enough matches (or manual tie-points).

https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
2026-04-18 03:59:36 +00:00

115 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
"""
import sys, cv2, numpy as np
north_path, sunrise_path, out_prefix = sys.argv[1], sys.argv[2], sys.argv[3]
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}")
# 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)
# 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
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
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)}")
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)}")
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)
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])
# 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 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)
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}")
# 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})")
warped_n = cv2.warpAffine(north, M_shifted, (W, H))
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")