核心拼接思想:
-
第一张图片: [内容A][重叠区域][内容B]
-
第二张图片: [内容C][重叠区域][内容D]
-
图片正序或者倒序不影响结果
-
拼接结果: [内容A][重叠区域][内容D]
优化:
- 与第三张图片特征匹配时,内容A不参与匹配
- 倒序检测
- 顶部导航栏与底部安全区优化
技术方案:
- APP添加Target - Broadcast Upload Extension
Broadcast Upload Extension 其实是用来做录屏才会用到的。
因为大家都是通过视频流来获取图片的。
把录屏帧中的CMSampleBuffer转成UIImage存储起来。
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
let currentTime = CACurrentMediaTime()
guard imageCounter < 111 else {
isCapturing = false
stopRecordingDueToNoFrameChange()
return
}
if lastCaptureTime > 0, currentTime - lastCaptureTime >= 2 { // 超过2s没有处理了
isCapturing = false
stopRecordingDueToNoFrameChange()
return
}
// 只处理视频样本
guard sampleBufferType == .video else { return }
// 添加日志,确认processSampleBuffer被调用
if !SampleHandler.loggedFirstFrame {
print("SampleHandler: 收到第一帧视频")
print("广播状态: (userDefaults.string(forKey: "broadcastStatus") ?? "未知")")
// 再次确保广播状态设置为active
userDefaults.set("active", forKey: "broadcastStatus")
userDefaults.synchronize()
print("已再次设置broadcastStatus为active")
SampleHandler.loggedFirstFrame = true
}
// 检查时间是否应该捕获当前帧
guard shouldCaptureCurrentFrame() else { return }
// 从样本缓冲区中提取像素缓冲区
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
// 对比变化大的视频帧才继续往下处理
guard shouldCaptureFrame(current: pixelBuffer) else { return }
// 将CMSampleBuffer转换为UIImage
guard let image = createImageFromSampleBuffer(sampleBuffer) else { return }
// 更新最后一帧的时间
lastCaptureTime = currentTime
// 更新最后成功的一帧
lastFrameBuffer = pixelBuffer
// 保存图像到共享容器
saveImageToSharedContainer(image)
}
private func saveImageToSharedContainer(_ image: UIImage) {
// 生成唯一文件名
imageCounter += 1
userDefaults.set(imageCounter, forKey: UserDefaultsKeys.imageCounter)
let timestamp = Int(Date().timeIntervalSince1970)
let fileName = "capture_(timestamp)_(imageCounter).jpg"
// 更新捕获的图像列表
var capturedImages = userDefaults.stringArray(forKey: "capturedImages") ?? []
capturedImages.append(fileName)
userDefaults.set(capturedImages, forKey: "capturedImages")
userDefaults.synchronize()
print("SampleHandler: 已添加图像到捕获列表,当前共有 (capturedImages.count) 张图像")
print("SampleHandler: 捕获的图像列表: (capturedImages)")
// 保存到共享容器
guard let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
print("错误: 无法访问共享容器")
return
}
let imagesDirectory = containerURL.appendingPathComponent("Images")
// 确保目录存在
if !fileManager.fileExists(atPath: imagesDirectory.path) {
try? fileManager.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
}
let fileURL = imagesDirectory.appendingPathComponent(fileName)
print("保存截图到: (fileURL.path)")
// 保存图像
if let imageData = image.jpegData(compressionQuality: 0.8) {
do {
try imageData.write(to: fileURL)
// 这里不需要重复添加,因为已经在前面添加过了
print("SampleHandler: 图像已成功保存到文件: (fileURL.path)")
print("截图已保存: (fileName)")
print("当前捕获的图像数量: (capturedImages.count)")
} catch {
print("保存截图失败: (error.localizedDescription)")
}
} else {
print("错误: 无法创建JPEG数据")
}
}
- 主APP 接受Target传递的 UIImage
这其实是视频的中的图片帧,可以转为UIImage
// 获取所有捕获的图像
func getAllCapturedImages() {
guard let sharedContainerURL = sharedContainerURL else { return }
let imagesDirectory = sharedContainerURL.appendingPathComponent("Images", isDirectory: true)
var images: [UIImage] = []
do {
// 检查目录是否存在
if FileManager.default.fileExists(atPath: imagesDirectory.path) {
// 获取目录中的所有文件
let fileURLs = try FileManager.default.contentsOfDirectory(at: imagesDirectory, includingPropertiesForKeys: nil)
print("找到(fileURLs.count)个图像文件")
// 加载每个图像
for fileURL in fileURLs {
if let imageData = try? Data(contentsOf: fileURL),
let image = UIImage(data: imageData) {
images.append(image)
print("成功加载图像: (fileURL.lastPathComponent)")
} else {
print("无法加载图像: (fileURL.lastPathComponent)")
}
}
} else {
print("Images目录不存在")
try FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
print("已创建Images目录")
}
} catch {
print("获取捕获图像时出错: (error.localizedDescription)")
}
print("成功加载(images.count)个图像")
broadcastImagesResult.accept(images)
cleanupCapturedImages()
}
- 拼接
将输入的 UIImage 转换为 OpenCV 的 cv::Mat 格式
OpenCV 内部处理拼接;核心部分
+ (UIImage *)stitch:(UIImage *)imageA with:(UIImage *)imageB {
if (!imageA || !imageB) {
return nil;
}
cv::Mat matA_color = UIImageToMat(imageA, false);
cv::Mat matB_color = UIImageToMat(imageB, false);
cv::Mat matA_gray = UIImageToMat(imageA, true);
cv::Mat matB_gray = UIImageToMat(imageB, true);
if (matA_gray.empty() || matB_gray.empty()) {
return nil;
}
......
项目技术方案总结
桥接层 (Objective-C++ & Bridging Header):
- SmartScreenShot-Bridging-Header.h : 这是 Swift 与 Objective-C/C++ 代码交互的桥梁。
- OpenCVWrapper.h : 定义了 OpenCVWrapper 类的公共接口。它接收一个 UIImage 数组,返回拼接后的 UIImage 。
核心算法层 (Objective-C++ & OpenCV):
-
OpenCVWrapper.mm : 这是整个项目的技术核心和难点所在。它使用 Objective-C++ ( .mm 文件) 编写,因此可以无缝混编 Objective-C 和 C++ 代码,从而调用强大的 OpenCV 库。
-
核心拼接逻辑 ( stitchAll ):
- 该方法采用 迭代式两两拼接 的策略。它将图片数组中的第一张作为基础,然后依次将后续图片拼接上去。
- 为了实现您提出的“ 已匹配区域不再参与新匹配 ”的核心要求,我们引入了一个关键的优化: 动态调整特征匹配区域 (ROI - Region of Interest) 。
-
智能区域匹配算法:
-
图像预处理: 将输入的 UIImage 转换为 OpenCV 的 cv::Mat 格式。
-
特征点检测与描述: 使用 ORB (Oriented FAST and Rotated BRIEF) 算法在两张待拼接的图片中寻找特征点。ORB 是一种高效且免费的特征检测算法,非常适合移动端应用。
-
动态 ROI (Region of Interest) 限定: 这是算法的精髓。对于第一张图片(即已拼接好的结果),我们只在其 底部新生成的内容区域 (由 activeRegionHeight 参数指定)进行特征点检测。对于第二张新图片,则在其 顶部区域 进行检测。这精确地实现了“避免重复匹配”的需求,极大地提高了匹配的准确性和效率。
-
特征点匹配: 使用 Brute-Force Matcher (暴力匹配) 配合 汉明距离 (Hamming Distance) 来寻找两组特征点之间的最佳匹配对。
-
误匹配对筛选 (RANSAC): 匹配的特征点对中难免存在噪声和错误。我们使用 RANSAC (Random Sample Consensus) 算法来提纯匹配结果。它通过迭代找到一个能解释最多匹配点的几何变换模型(在这里是垂直位移),从而剔除掉那些不符合该模型的“局外点”。
-
计算精确位移: 从经过 RANSAC 筛选后的优质匹配点对中,计算出它们在 Y 轴上的位移(offset)的中位数。使用中位数可以进一步排除极端异常值的影响,得到一个非常稳健的垂直偏移量。
-
图像裁剪与合并:
- 根据计算出的精确位移,确定两张图片的重叠区域。
- 为了实现平滑过渡,我们采用 30/70 的非对称裁剪策略 。从重叠区域的 30% 位置作为“接缝”,分别裁剪第一张图的上方内容和第二张图的下方内容。这保留了更多第二张图的顶部信息,有效解决了之前版本中第二张图顶部被过度裁剪的问题。
- 最后,将裁剪后的两部分图像垂直拼接( cv::vconcat )成一张新的 cv::Mat 。
-
-
结果返回: 将最终拼接好的 cv::Mat 转换回 UIImage ,并更新 activeRegionHeight (新生成内容的高度),为下一次迭代做准备。
技术难点与解决方案
- 难点一:精确计算图片间的重叠与位移
- 挑战: 手机截图的背景可能很复杂(如渐变色、动态内容),简单的像素比对非常不可靠。且截图操作可能引入微小的滚动差异,导致重叠区域大小不一。
- 解决方案: 采用基于 特征点匹配 的计算机视觉方法。这一经典组合拳,能够稳健地在不同光照、微小形变和噪声下找到可靠的对应关系,从而计算出精确的像素级位移。
- 难点二:避免重复匹配与累计误差
- 挑战: 在迭代拼接多张图片时,如果每次都对整张已拼接图片和新图片进行全局特征匹配,那么之前已经匹配过的区域(如 A 和 B 的重叠区)会再次参与和 C 的匹配,引入大量噪声和潜在的错误匹配,导致拼接“漂移”或失败。
- 解决方案: 引入 activeRegionHeight 和动态 ROI 策略 。通过只在“新内容”区域进行特征匹配,我们确保了每次匹配都聚焦于寻找当前两张图片真正的连接处。这不仅大大提高了匹配的准确率,也显著提升了算法效率,是整个多图拼接方案能够成功的关键。
- 难点三:接缝处的自然过渡与内容保留
- 挑战: 简单地在重叠区域中间进行裁剪(50/50 分割)常常会导致视觉上的断裂感,或者在某些情况下(如 RANSAC 稍有偏差),会裁掉一部分重要内容(如此前遇到的第二张图顶部被裁掉的问题)。
- 解决方案: 采用 30/70 的非对称裁剪策略 。通过在重叠区域靠上的位置(30% 处)设置接缝,我们为第二张(下方)的图片保留了更多的顶部内容(70% 的重叠区域),这是一种更保守和安全的策略,有效避免了重要信息的丢失,使得拼接结果更完整。
- 难点四:性能与内存管理
- 挑战: 图像处理是计算密集型和内存密集型操作。在移动设备上处理多张高分辨率截图,很容易导致应用卡顿或因内存溢出而崩溃。
- 解决方案:
- 后台执行: 将所有 OpenCV 计算都放在后台线程执行。
- 高效算法: 选择 ORB 而非 SIFT/SURF 等更复杂的算法,是在性能和效果之间做的权衡。
- 内存管理: 在 C++ 代码中,使用 cv::Mat 的智能指针机制可以很好地管理内存。在 Objective-C++ 层,需要注意 UIImage 和 cv::Mat 转换过程中的内存拷贝,确保中间产物被及时释放。虽然在当前版本中我们没有做极致的优化,但这是大型项目中需要持续关注的问题。