
目录LightGlue2、专门做【视频 Mosaic 视频地面平铺拼接】开源项目最贴合你的场景video-mosaicPythonMIT 协议pip 直接装findHomography透视和 estimateAffinePartial2D有重影特征匹配 运动估计 全局优化 多波段融合klt跟踪算法LightGluehttps://github.com/cvg/LightGlue根因分析C 版效果好是因为cv::Stitcher内置了分块增益曝光补偿的完整流水线全局线性补偿太粗糙→ 原来整张图只算一组均值/方差系数处理不了渐晕、相机 AE 引起的空间上渐变的亮度差。新版改成分块滑窗增益在重叠区按 101×101 窗口逐块估计每通道增益gain mean(img0)/mean(img1)再做高斯平滑得到随空间连续变化的增益图——等价于 OpenCV 的 GainBlocks 思路。gpu:GitHub - AIDajiangtang/Superpoint-LightGlue-Image-Stiching: OpenCV图像拼接Pipeline集成 SuperPoint 、LightGlue 特征点检测和匹配深度学习模型 · GitHubGitHub - kajo-kurisu/D_VINS: Merge superpoint、lightglue、MixVPR into VINS-FUSION for loop closure with TensorRT · GitHub2、专门做【视频 Mosaic 视频地面平铺拼接】开源项目最贴合你的场景video-mosaicPythonMIT 协议pip 直接装githubGitHub - GonzaloFuentes28/video-mosaic: CLI tool to extract frames from a video and arrange them into a single mosaic image. · GitHubpip install video-mosaic功能直接读取视频自动提取关键帧、帧间配准、生成地面 mosaic 平铺图就是你想要的视频→一张俯视图底层ORB 特征 RANSAC 仿射变换 多频段融合内置漂移抑制适合地面扫描、农田、水下扫测这类相机平面移动场景缺点参数可调空间有限底层封装不方便改 KLT / 自定义 BA。findHomography透视和estimateAffinePartial2D最后有模糊import cv2 import numpy as np # ---------- 参数 ---------- VIDEO_PATH ground.mp4 MAX_FEATURES 3000 # ORB 最大特征点数 RATIO_TEST 0.75 # Lowes ratio test 阈值 MIN_MATCH 15 # 最少好匹配数 RANSAC_THRESH 3.0 # RANSAC 重投影误差阈值 USE_HOMOGRAPHY True # True: 透视变换; False: 相似变换(无错切) CANVAS_W, CANVAS_H 4000, 4000 # 画布尺寸按场景调整 def main(): cap cv2.VideoCapture(VIDEO_PATH) ret, frame0 cap.read() if not ret: raise RuntimeError(无法读取视频) orb cv2.ORB_create(MAX_FEATURES) bf cv2.BFMatcher(cv2.NORM_HAMMING, crossCheckFalse) # 参考帧上一关键帧的特征 gray_prev cv2.cvtColor(frame0, cv2.COLOR_BGR2GRAY) kp_prev, des_prev orb.detectAndCompute(gray_prev, None) # T_prev: 从参考帧像素坐标 - 世界坐标第一帧坐标系 T_prev np.eye(3, dtypenp.float64) # 画布累积 sum 和 weight最后做加权平均 canvas_sum np.zeros((CANVAS_H, CANVAS_W, 3), dtypenp.float32) canvas_w np.zeros((CANVAS_H, CANVAS_W), dtypenp.float32) # 世界坐标 - 画布坐标的平移把第一帧放在画布中心 T_offset np.array([[1, 0, CANVAS_W / 2], [0, 1, CANVAS_H / 2], [0, 0, 1]], dtypenp.float64) def add_to_canvas(frame, T_world): 把 frame 用 T_world 投到画布并用距离变换做软权重融合 T_canvas T_offset T_world warped cv2.warpPerspective( frame, T_canvas, (CANVAS_W, CANVAS_H), flagscv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT ) mask cv2.warpPerspective( np.ones(frame.shape[:2], np.uint8) * 255, T_canvas, (CANVAS_W, CANVAS_H), flagscv2.INTER_NEAREST ) # 距离变换离边界越远权重越高边缘柔和过渡 dist cv2.distanceTransform(mask, cv2.DIST_L2, 5) if dist.max() 1e-6: w dist / dist.max() else: w mask.astype(np.float32) / 255.0 canvas_sum[:] warped.astype(np.float32) * w[..., None] canvas_w[:] w # 第一帧入画布 add_to_canvas(frame0, T_prev.copy()) frame_idx 0 while True: ret, frame cap.read() if not ret: break frame_idx 1 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) kp, des orb.detectAndCompute(gray, None) if des is None or des_prev is None or len(kp) MIN_MATCH: continue # ---------- 匹配 ---------- matches bf.knnMatch(des_prev, des, k2) good [] for m, n in matches: if m.distance RATIO_TEST * n.distance: good.append(m) if len(good) MIN_MATCH: continue pts_prev np.float32([kp_prev[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) pts_cur np.float32([kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) # ---------- 估计变换当前帧 - 参考帧 ---------- if USE_HOMOGRAPHY: H, mask cv2.findHomography(pts_cur, pts_prev, cv2.RANSAC, RANSAC_THRESH) else: M, mask cv2.estimateAffinePartial2D( pts_cur, pts_prev, methodcv2.RANSAC, ransacReprojThresholdRANSAC_THRESH ) H np.vstack([M, [0, 0, 1]]) if M is not None else None if H is None or mask is None or mask.sum() MIN_MATCH: continue # ---------- 累积到世界坐标 ---------- # p_prev H p_cur # T_prev p_prev T_cur p_cur T_cur T_prev H T_cur T_prev H add_to_canvas(frame, T_cur) # 更新参考帧这里用简单策略每帧都作为新参考也可以换关键帧策略 kp_prev, des_prev kp, des T_prev T_cur # 预览 if frame_idx % 30 0: preview canvas_sum / np.maximum(canvas_w[..., None], 1e-6) preview np.clip(preview, 0, 255).astype(np.uint8) cv2.imshow(stitching, cv2.resize(preview, None, fx0.25, fy0.25)) if cv2.waitKey(1) 0xFF 27: break cap.release() # 输出最终拼接图 result canvas_sum / np.maximum(canvas_w[..., None], 1e-6) result np.clip(result, 0, 255).astype(np.uint8) # 自动裁掉黑边 gray_res cv2.cvtColor(result, cv2.COLOR_BGR2GRAY) ys, xs np.where(gray_res 0) if len(xs) 0: result result[ys.min():ys.max()1, xs.min():xs.max()1] cv2.imwrite(stitched.jpg, result) cv2.destroyAllWindows() print(f完成处理 {frame_idx} 帧输出 stitched.jpg) if __name__ __main__: main()有重影import cv2 import numpy as np # ---------- 参数 ---------- VIDEO_PATH rC:\Users\Administrator\Videos\pinjie1.mp4 MAX_FEATURES 3000 RATIO_TEST 0.75 MIN_MATCH 15 RANSAC_THRESH 3.0 USE_HOMOGRAPHY False # 白纸黑字平移场景False 更稳相似变换 CANVAS_W, CANVAS_H 4000, 4000 ANCHOR_EVERY 30 # 每 N 帧对首帧重锚定清零漂移 INVERT_DOC False # 白底黑字 False黑底白字改 True if __name__ __main__: cap cv2.VideoCapture(VIDEO_PATH) ret, frame0 cap.read() if not ret: raise RuntimeError(无法读取视频) orb cv2.ORB_create(MAX_FEATURES) bf cv2.BFMatcher(cv2.NORM_HAMMING, crossCheckFalse) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) def preprocess(bgr): 灰度 CLAHE白纸黑字下能明显提升 ORB 稳定性 gray cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) return clahe.apply(gray) # 首帧特征用于重锚定 gray_prev preprocess(frame0) kp_prev, des_prev orb.detectAndCompute(gray_prev, None) kp_first, des_first kp_prev, des_prev T_prev np.eye(3, dtypenp.float64) # ---------- 关键改动 1最小值融合 ---------- # 白底黑字文字是暗的取 min 相当于对所有笔画做 OR # 黑底白字把 INVERT_DOCTrue取 max fill_value 0 if INVERT_DOC else 255 canvas_img np.full((CANVAS_H, CANVAS_W, 3), fill_value, dtypenp.uint8) canvas_valid np.zeros((CANVAS_H, CANVAS_W), dtypebool) # 记录覆盖范围用于裁剪 T_offset np.array([[1, 0, CANVAS_W / 2], [0, 1, CANVAS_H / 2], [0, 0, 1]], dtypenp.float64) def add_to_canvas(frame, T_world): T_canvas T_offset T_world border (fill_value, fill_value, fill_value) warped cv2.warpPerspective( frame, T_canvas, (CANVAS_W, CANVAS_H), flagscv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT, borderValueborder ) mask cv2.warpPerspective( np.ones(frame.shape[:2], np.uint8) * 255, T_canvas, (CANVAS_W, CANVAS_H), flagscv2.INTER_NEAREST, borderModecv2.BORDER_CONSTANT, borderValue0 ).astype(bool) if INVERT_DOC: canvas_img[mask] np.maximum(canvas_img[mask], warped[mask]) else: canvas_img[mask] np.minimum(canvas_img[mask], warped[mask]) canvas_valid[mask] True add_to_canvas(frame0, T_prev.copy()) frame_idx 0 while True: ret, frame cap.read() if not ret: break frame_idx 1 gray preprocess(frame) kp, des orb.detectAndCompute(gray, None) if des is None or des_prev is None or len(kp) MIN_MATCH: continue # ---------- 帧间匹配 ---------- matches bf.knnMatch(des_prev, des, k2) good [m for m, n in matches if m.distance RATIO_TEST * n.distance] if len(good) MIN_MATCH: continue pts_prev np.float32([kp_prev[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) pts_cur np.float32([kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) if USE_HOMOGRAPHY: H, mask cv2.findHomography(pts_cur, pts_prev, cv2.RANSAC, RANSAC_THRESH) else: M, mask cv2.estimateAffinePartial2D( pts_cur, pts_prev, methodcv2.RANSAC, ransacReprojThresholdRANSAC_THRESH ) H np.vstack([M, [0, 0, 1]]) if M is not None else None if H is None or mask is None or mask.sum() MIN_MATCH: continue T_cur T_prev H # ---------- 关键改动 2定期对首帧重锚定 ---------- # 累计漂移会随时间线性增长重锚定直接把 T_cur 覆盖成“相对首帧”的变换 if frame_idx % ANCHOR_EVERY 0 and des_first is not None: m0 bf.knnMatch(des_first, des, k2) g0 [m for m, n in m0 if m.distance RATIO_TEST * n.distance] if len(g0) MIN_MATCH: p0 np.float32([kp_first[m.queryIdx].pt for m in g0]).reshape(-1, 1, 2) pc np.float32([kp[m.trainIdx].pt for m in g0]).reshape(-1, 1, 2) if USE_HOMOGRAPHY: H0, mask0 cv2.findHomography(pc, p0, cv2.RANSAC, RANSAC_THRESH) else: M0, mask0 cv2.estimateAffinePartial2D( pc, p0, methodcv2.RANSAC, ransacReprojThresholdRANSAC_THRESH ) H0 np.vstack([M0, [0, 0, 1]]) if M0 is not None else None if H0 is not None and mask0 is not None and mask0.sum() MIN_MATCH: T_cur H0 print(f [anchor] frame {frame_idx}: reset, inliers{int(mask0.sum())}) add_to_canvas(frame, T_cur) kp_prev, des_prev kp, des T_prev T_cur if frame_idx % 30 0: cv2.imshow(stitching, cv2.resize(canvas_img, None, fx0.25, fy0.25)) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows() result canvas_img.copy() # ---------- 关键改动 3用有效区域掩码裁剪不靠灰度阈值 ---------- ys, xs np.where(canvas_valid) if len(xs) 0: y0 max(0, ys.min() - 5) y1 min(result.shape[0], ys.max() 6) x0 max(0, xs.min() - 5) x1 min(result.shape[1], xs.max() 6) result result[y0:y1, x0:x1] cv2.imwrite(stitched.jpg, result) print(f完成处理 {frame_idx} 帧输出 stitched.jpg)特征匹配 运动估计 全局优化 多波段融合# 1. 打开视频抽帧 cap cv2.VideoCapture(ground.mp4) # 2. ORB初始化 orb cv2.ORB_create(2000) # 3. 保存关键帧列表每个关键帧存图像、特征点、描述子、全局变换矩阵 keyframes [] # 4. 循环每一帧 while cap.isOpened(): ret, frame cap.read() if not ret: break kp2, des2 orb.detectAndCompute(frame, None) # 和最近关键帧匹配 matches matcher.match(des_prev, des2) # 筛选好匹配点 pts1, pts2 get_match_points(matches) # 【重点】用仿射限定无旋转只估计缩放平移 M, mask cv2.estimateAffinePartial2D(pts2, pts1) # 判断匹配质量满足条件则加入关键帧 if good_match(M, mask): keyframes.append( (frame, M) ) # 5. 全局BA优化所有关键帧的M矩阵 # 6. 投影所有关键帧到大画布 多波段融合klt跟踪算法import numpy as np import cv2 # ---------- 参数配置 ---------- feature_params dict( maxCorners100, # 最多保留的角点数 qualityLevel0.3, # 角点质量阈值 minDistance7, # 角点间最小距离 blockSize7 # 计算角点时的邻域大小 ) lk_params dict( winSize(15, 15), # 光流搜索窗口大小 maxLevel2, # 金字塔层数 criteria(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03) ) # 轨迹绘制颜色随机生成 track_len 30 # 每条轨迹保留的历史长度 detect_interval 5 # 每隔多少帧重新检测一次角点 # ---------- 打开视频源 ---------- # 用 0 表示摄像头也可以换成视频文件路径如 video.mp4 cap cv2.VideoCapture(0) if not cap.isOpened(): raise RuntimeError(无法打开视频源) # 读取第一帧 ret, old_frame cap.read() if not ret: raise RuntimeError(无法读取第一帧) old_gray cv2.cvtColor(old_frame, cv2.COLOR_BGR2GRAY) # 首帧检测角点 p0 cv2.goodFeaturesToTrack(old_gray, maskNone, **feature_params) # 用于绘制轨迹的画布 mask np.zeros_like(old_frame) # 存储轨迹历史每个点对应一条轨迹列表 tracks [] for i in range(len(p0)): tracks.append([p0[i][0].copy()]) frame_idx 0 while True: ret, frame cap.read() if not ret: break frame_gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # ---------- 计算光流 ---------- # p1: 当前帧中对应点的位置 # st: 状态1 表示跟踪成功0 表示失败 # err: 误差 p1, st, err cv2.calcOpticalFlowPyrLK( old_gray, frame_gray, p0, None, **lk_params ) # ---------- 过滤跟踪失败的点 ---------- if p1 is not None: good_new p1[st 1] good_old p0[st 1] else: good_new np.empty((0, 2), dtypenp.float32) good_old np.empty((0, 2), dtypenp.float32) # ---------- 更新轨迹并绘制 ---------- # 因为过滤后点的数量和顺序变了这里简单重建 tracks # 实际工程中可以用唯一 ID 关联这里为了演示简化处理 new_tracks [] for i, (new, old) in enumerate(zip(good_new, good_old)): a, b new.ravel() c, d old.ravel() # 在当前帧上画点 frame cv2.circle(frame, (int(a), int(b)), 3, (0, 255, 0), -1) # 更新轨迹历史 if i len(tracks): tracks[i].append(new.copy()) if len(tracks[i]) track_len: tracks[i].pop(0) # 绘制轨迹线 for j in range(1, len(tracks[i])): cv2.line( mask, tuple(tracks[i][j - 1].astype(int)), tuple(tracks[i][j].astype(int)), (0, 0, 255), 2 ) new_tracks.append(tracks[i]) else: new_tracks.append([new.copy()]) tracks new_tracks # ---------- 叠加轨迹画布 ---------- img cv2.add(frame, mask) cv2.imshow(KLT Tracker, img) # ---------- 定期重新检测角点 ---------- frame_idx 1 if frame_idx % detect_interval 0 or len(good_new) 10: # 重新检测角点但避开已有角点附近区域 mask_feat np.zeros_like(frame_gray) mask_feat[:] 255 for pt in good_new: x, y pt.ravel().astype(int) cv2.circle(mask_feat, (x, y), 5, 0, -1) p0 cv2.goodFeaturesToTrack( frame_gray, maskmask_feat, **feature_params ) if p0 is not None: # 重置轨迹简化处理实际可用 ID 关联 tracks [[p0[i][0].copy()] for i in range(len(p0))] else: p0 good_new.reshape(-1, 1, 2) else: p0 good_new.reshape(-1, 1, 2) old_gray frame_gray.copy() # 按 ESC 退出 if cv2.waitKey(30) 0xFF 27: break cap.release() cv2.destroyAllWindows()