iOS 长截图的实现方案

93 阅读8分钟

核心拼接思想:

  1. 第一张图片: [内容A][重叠区域][内容B]

  2. 第二张图片: [内容C][重叠区域][内容D]

  3. 图片正序或者倒序不影响结果

  4. 拼接结果: [内容A][重叠区域][内容D]

优化:

  1. 与第三张图片特征匹配时,内容A不参与匹配
  2. 倒序检测
  3. 顶部导航栏与底部安全区优化

技术方案:

  1. 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数据")
        }
    }
    
  1. 主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()
    }
    
  1. 拼接

将输入的 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 (新生成内容的高度),为下一次迭代做准备。

技术难点与解决方案

  1. 难点一:精确计算图片间的重叠与位移
  • 挑战: 手机截图的背景可能很复杂(如渐变色、动态内容),简单的像素比对非常不可靠。且截图操作可能引入微小的滚动差异,导致重叠区域大小不一。
  • 解决方案: 采用基于 特征点匹配 的计算机视觉方法。这一经典组合拳,能够稳健地在不同光照、微小形变和噪声下找到可靠的对应关系,从而计算出精确的像素级位移。
  1. 难点二:避免重复匹配与累计误差
  • 挑战: 在迭代拼接多张图片时,如果每次都对整张已拼接图片和新图片进行全局特征匹配,那么之前已经匹配过的区域(如 A 和 B 的重叠区)会再次参与和 C 的匹配,引入大量噪声和潜在的错误匹配,导致拼接“漂移”或失败。
  • 解决方案: 引入 activeRegionHeight 和动态 ROI 策略 。通过只在“新内容”区域进行特征匹配,我们确保了每次匹配都聚焦于寻找当前两张图片真正的连接处。这不仅大大提高了匹配的准确率,也显著提升了算法效率,是整个多图拼接方案能够成功的关键。
  1. 难点三:接缝处的自然过渡与内容保留
  • 挑战: 简单地在重叠区域中间进行裁剪(50/50 分割)常常会导致视觉上的断裂感,或者在某些情况下(如 RANSAC 稍有偏差),会裁掉一部分重要内容(如此前遇到的第二张图顶部被裁掉的问题)。
  • 解决方案: 采用 30/70 的非对称裁剪策略 。通过在重叠区域靠上的位置(30% 处)设置接缝,我们为第二张(下方)的图片保留了更多的顶部内容(70% 的重叠区域),这是一种更保守和安全的策略,有效避免了重要信息的丢失,使得拼接结果更完整。
  1. 难点四:性能与内存管理
  • 挑战: 图像处理是计算密集型和内存密集型操作。在移动设备上处理多张高分辨率截图,很容易导致应用卡顿或因内存溢出而崩溃。
  • 解决方案:
  • 后台执行: 将所有 OpenCV 计算都放在后台线程执行。
  • 高效算法: 选择 ORB 而非 SIFT/SURF 等更复杂的算法,是在性能和效果之间做的权衡。
  • 内存管理: 在 C++ 代码中,使用 cv::Mat 的智能指针机制可以很好地管理内存。在 Objective-C++ 层,需要注意 UIImage 和 cv::Mat 转换过程中的内存拷贝,确保中间产物被及时释放。虽然在当前版本中我们没有做极致的优化,但这是大型项目中需要持续关注的问题。