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
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 762 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,114 @@
|
||||
#!/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")
|
||||
Reference in New Issue
Block a user