Phase-correlation on the treeline/horizon band (rows 70-90%) found: tx = -3324 px (north is 3324 px to the left of sunrise) ty = -85 px (north camera is 85 px higher — slight tilt difference) overlap = 516 px wide, feather-blended with a horizontal gradient Result in images/stitch-preview/north-sunrise-aligned-preview.jpg: 7164x2075 px panorama with continuous horizon and treeline. Cloud "V" at seam is a 36-second inter-shot artefact only; live video frames captured simultaneously will have no such discontinuity. stitch-cameras.py: accepts tx/ty overrides as argv[4]/argv[5] so the offsets can be tuned without code changes. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
80 lines
3.4 KiB
Python
80 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
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_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_p)
|
|
sunrise = cv2.imread(sunrise_p)
|
|
H, W = north.shape[:2]
|
|
|
|
tx, ty = tx_arg, ty_arg # north → sunrise translation
|
|
print(f"Using tx={tx} ty={ty}")
|
|
|
|
# ── 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)
|
|
|
|
# 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
|
|
|
|
canvas = np.zeros((cH, cW, 3), dtype=np.uint8)
|
|
|
|
# Paint north first (background)
|
|
nx0, ny0 = int(n_ox), int(n_oy)
|
|
canvas[ny0:ny0+H, nx0:nx0+W] = north
|
|
|
|
# Paint sunrise on top (base)
|
|
sx0, sy0 = int(s_ox), int(s_oy)
|
|
canvas[sy0:sy0+H, sx0:sx0+W] = sunrise
|
|
|
|
# ── 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)
|
|
|
|
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}]")
|
|
|
|
# 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)
|
|
|
|
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
|
|
|
|
# ── 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, :]
|
|
|
|
cv2.imwrite(out_p, canvas, [cv2.IMWRITE_JPEG_QUALITY, 92])
|
|
print(f"Output: {out_p} size={canvas.shape[1]}x{canvas.shape[0]}")
|
|
|
|
# ── 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.")
|