Ghép hai ảnh chụp màn hình khó đến mức nào? Một engine stitching iOS khiến tôi phải làm lại từ đầu — hết lần này đến lần khác

Pipeline 6 bước của engine stitching ScrollShot: từ quay màn hình đến ảnh dài

Ghép hai ảnh chụp màn hình khó đến mức nào? Một engine stitching iOS khiến tôi phải làm lại từ đầu — hết lần này đến lần khác

Tôi đã dành mấy tháng chỉ để làm một việc: giúp tính năng ghép ảnh chụp màn hình dài trên điện thoại «căn chỉnh chính xác».

Nghe đơn giản đúng không? Hai ảnh chụp kề nhau, tìm phần chồng lấp, cắt rồi dán. Nhưng khi thực sự bắt tay vào code, bạn sẽ nhanh chóng nhận ra «chính xác» là một hố sâu kỹ thuật không đáy.

Bài viết này mổ xẻ engine ghép ảnh dài của ScrollShot. Không chào hàng, không hứa suông — chỉ có thuật toán và những chi tiết kỹ thuật khiến tôi phải thiết kế lại hết lần này đến lần khác. Nếu bạn quan tâm đến định vị tổng thể của ScrollShot, hãy bắt đầu với tổng quan sản phẩm: vì sao chọn ScrollShot cho ảnh chụp màn hình dài trên iOS.


Tại sao không dùng ghép panorama của OpenCV?

Theo trực giác, ghép ảnh là bài toán cũ. Lớp Stitcher của OpenCV tạo ảnh panorama chỉ với vài dòng code.

Nhưng ảnh chụp màn hình dài và ảnh panorama là hai thứ hoàn toàn khác nhau. Panorama chụp bằng cách xoay ngang với biến đổi phối cảnh, vùng chồng lấp thường nhỏ. Ảnh chụp màn hình dài là cuộn dọc một chiều — các khung hình liên tiếp chia sẻ 5 %–80 % nội dung giống hệt nhau, và — điều này rất quan trọng — lệch 2 pixel là nhìn thấy ngay vì đối tượng ghép có thể là văn bản.

Ai từng thử OpenCV đều có trải nghiệm tương tự: feature matching hoàn toàn bó tay trên màn hình chat. Một số người dùng đặt hình nền trong app nhắn tin, khiến phần lớn khung hình trông giống nhau. Thay vì cuối khung trước chồng lên đầu khung sau, các điểm SIFT tạo ra vô số cặp «giống nhưng khác».

Vì vậy tôi chọn con đường khác: viết dòng code đầu tiên của engine template matching từ con số không, tối ưu riêng cho kịch bản «ảnh chụp màn hình cuộn dọc».

Pipeline tổng thể như sau:

Pipeline engine stitching ScrollShot

Engine cũng hỗ trợ Apple Vision Framework cho matching (nhanh nhưng thô), nhưng bài viết này tập trung vào template matching tự phát triển — đó là nơi công việc thực sự diễn ra. Để xem thuật toán này chuyển thành trải nghiệm quay một lần, có ảnh dài như thế nào, hãy xem phần giới thiệu sản phẩm.


Thử thách 1: Chọn 30 khung hình hữu ích từ 1.800

Một đoạn quay 30 giây ở 60 fps = 1.800 khung hình. Xử lý hết? Bộ nhớ nổ trước, thời gian nổ ngay sau.

Cách tiếp cận trực quan nhất là lấy mẫu theo khoảng thời gian: 3 khung hình mỗi giây. Nhưng có một lỗi chí mạng — tốc độ cuộn của người dùng không đều. Khi vuốt nhanh, hai khung hình liên tiếp lệch nửa màn hình, ghép ảnh tất nhiên mất nội dung. Khi lướt chậm, các khung hình kề nhau gần như giống hệt — toàn khung hình rác.

ScrollShot dùng lấy mẫu theo khoảng cách: thay vì nhìn thời gian, nhìn «màn hình đã cuộn bao xa». Chỉ khi dịch chuyển tích lũy vượt ngưỡng mới đáng lấy một khung hình.

Ngưỡng dịch chuyển bao nhiêu là phù hợp?

Con số này khiến tôi đau đầu suốt nhiều tuần. Đặt quá nhỏ, cuộn chậm sẽ kích hoạt trích xuất điên cuồng toàn bản sao. Đặt quá lớn, cuộn nhanh sẽ mất nội dung.

Công thức cuối cùng:

displacementThreshold = dsHeight × 0,60 × presetScale

Trong đó dsHeight là chiều cao khung hình sau khi giảm mẫu (1080p giảm 3 lần ≈ 360p, tức dsHeight ≈ 640), 0,60 là tỷ lệ cơ bản, presetScale là preset người dùng chọn (tích cực/cân bằng/bảo thủ).

Ở chế độ cân bằng, một khung hình được trích mỗi 384 pixel (640 × 0,60) cuộn — khoảng nửa chiều cao màn hình.

Còn một bộ lọc nhiễu dễ bị bỏ qua: dịch chuyển đơn lẻ phải ≥ 3 pixel mới được tính vào tích lũy. Không có cái này, nhiễu sub-pixel của template matching tích tụ dần, gây «trích xuất ma» ngay cả trên màn hình tĩnh. Tôi mất cả một buổi chiều để tìm ra bug này.

Hai lượt quét trong thực tế

Lượt 1 — phân tích chuyển động độ phân giải thấp. Các khung hình được giảm mẫu 3 lần (1080p → 360p, nhanh hơn một bậc), template matching nhẹ theo dõi dịch chuyển dọc từng khung hình. Một số chi tiết kỹ thuật ẩn ở đây:

  • Bước thích ứng: khi dịch chuyển lớn, bước nhảy lớn (nativeFPS / 6, khoảng mỗi 10 khung). Khi dịch chuyển nhỏ, quay lại quét từng khung (nativeFPS / 30). Khi khoảng cách còn lại dưới 25 % ngưỡng, bắt buộc chuyển sang bước nhỏ nhất — nếu không sẽ bước qua đúng một khung hình
  • Phát hiện đổi cảnh: SAD per pixel giữa các khung liền kề vượt 40,0? Khả năng cao người dùng chuyển app. Trích xuất ngay
  • Cắt ngược: dùng optical flow của Vision phát hiện hướng chuyển động. Nếu dịch chuyển tích lũy xuống dưới -50 px (người dùng cuộn ngược lên), đã đến đầu — loại bỏ toàn bộ khung phía sau. Nhưng ngưỡng này không được quá hẹp: ban đầu tôi đặt -10, kết quả là rung tay nhẹ đã kích hoạt cắt, bản ghi mất một nửa

Lượt 1,5 — tinh chỉnh độ nét. Với mỗi khung hình ứng viên, tìm khung nét nhất trong cửa sổ ±2 khung xung quanh.

Định lượng độ nét thế nào? Tôi dùng năng lượng Laplacian — tích chập ảnh xám với kernel 3×3, rồi tính trung bình bình phương phản hồi:

kernel = [-1, -1, -1,
          -1,  8, -1,
          -1, -1, -1]

sharpness = mean((convolve(gray, kernel) - 128)²)

Trừ 128 vì đầu ra tích chập có bias 128 (yêu cầu của vImage). Bình phương rồi lấy trung bình ra năng lượng tần số cao. Giá trị càng lớn = khung càng nét.

Tại sao dùng năng lượng Laplacian thay vì phương sai? Với ảnh chụp màn hình, hai phương pháp chọn khung gần như giống nhau, nhưng cách đầu tính toán đơn giản hơn — vDSP_vsq + vDSP_meanv của vImage, hai dòng xong.

Giới hạn độ phân giải là 720×1280, đủ dùng. Lý do thực tế: khung hình nét nhất là khoảnh khắc người dùng nhấc ngón tay; khung hình trong lúc vuốt thường có motion blur. Năng lượng Laplacian rất nhạy với điều này — chênh lệch thường 2–3 lần.


Thuật toán cốt lõi: Engine Template Matching

Đây là phần lớn nhất về khối lượng code (hơn 3.000 dòng trong một file), và cũng là phần bị bug hành hạ lâu nhất.

Matching ngược: Tại sao tìm khung cũ từ khung mới?

Phiên bản đầu tôi dùng «matching xuôi» — lấy template từ đáy khung cũ, tìm trong khung mới. Thất bại thảm hại trên màn hình Zalo: đáy khung cũ có một tin «OK», khung mới xuất hiện ba tin «OK». Matching bám vào cái trên cùng, offset lệch 200 pixel.

Sau đó tôi đảo hướng: Reverse Matching. Lấy template từ đỉnh khung mới (img2), tìm ở đáy khung cũ (img1).

Tại sao ngược tốt hơn? Đỉnh khung mới là «nội dung mới cuộn vào» — nội dung này chắc chắn tồn tại trong khung cũ, và tồn tại đúng một lần (ở phần giữa-dưới khung cũ). Ngược lại, «nội dung cũ sắp biến mất» ở đáy khung cũ có thể chỉ lộ ra một chút trong khung mới, hoặc đã bị đẩy ra khỏi màn hình.

Sơ đồ matching ngược

Tìm kiếm tự động bỏ qua vùng thanh trạng thái (~250 px trên) và vùng tab bar dưới (~350 px). Nhưng đây lại có bẫy — một số app có thanh điều hướng dưới với chiều cao khác nhau, một số trang có bàn phím, có nút nổi.

Nên tôi thêm phát hiện footer động. Cách làm: quét từ đáy hai khung lên trên từng dòng, lấy 80 % trung tâm mỗi dòng (cắt 10 % hai bên để tránh nút nổi), lấy mẫu mỗi 16 pixel, tính MAD (sai lệch tuyệt đối trung bình) của cùng dòng trong hai khung. MAD ≤ 3,2 nghĩa là dòng gần giống nhau — có thể là thành phần UI tĩnh. Dừng sau 4+ dòng liên tiếp không tĩnh (maxGapTolerance = 4). Dưới 24 dòng tĩnh tổng cộng → không có thanh cố định, trả về 0.

Ngưỡng 3,2 là kinh nghiệm. Vùng tĩnh nền trắng có MAD 0–1,5. Nhiễu nén JPEG dao động 2–4. Dòng có thay đổi nội dung thực sự ít nhất 8 trở lên. 3,2 nằm đúng khe «chấp nhận nhiễu nén nhưng từ chối thay đổi nội dung».

6 template bỏ phiếu, chống lại «trông giống nhau»

Một khối template đơn lẻ rất dễ bị match sai. Hãy tưởng tượng trang danh sách sản phẩm thương mại điện tử — mỗi card sản phẩm có bố cục gần như giống hệt, một template có thể match sản phẩm trước hoặc sau.

Engine trích 6 khối template đồng thời (mỗi khối cao 100 px, xếp cách nhau từ đỉnh khung mới), tìm vị trí match tốt nhất trong khung cũ một cách độc lập, thu được 6 giá trị offset. Sau đó phân cụm 6 offset này (dung sai: 5 px), lấy trung vị của cụm lớn nhất.

Nếu 5/6 template chỉ về offset ≈ 320, 1 chỉ về 800, thì 800 gần như chắc chắn là match sai — bỏ.

Còn tối ưu hóa dừng sớm: nếu 3 template đầu đã đồng ý (dung sai: 4 px) và offset không gần 0 (loại trừ «người dùng chưa cuộn gì»), 3 template sau không cần tính.

Một chi tiết tôi trăn trở: dung sai đặt bao nhiêu? Ban đầu đặt 2 px, nhưng do sai số lượng tử hóa của NCC (sẽ nói sau), offset của các template khác nhau trên cùng cặp khung thường lệch 2–3 px, dừng sớm gần như không bao giờ kích hoạt. Nới lên 4 px, khoảng 60 % cặp khung kích hoạt dừng sớm trong test thực tế, không quan sát thấy giảm độ chính xác.

NCC kim tự tháp: tìm từ thô đến tinh

Mỗi lần match dùng NCC (Normalized Cross-Correlation) đo độ tương đồng. Công thức NCC:

NCC(T,I)=(TiTˉ)(IiIˉ)(TiTˉ)2(IiIˉ)2\text{NCC}(T, I) = \frac{\sum(T_i - \bar{T})(I_i - \bar{I})}{\sqrt{\sum(T_i - \bar{T})^2 \cdot \sum(I_i - \bar{I})^2}}

Ưu điểm của NCC là không nhạy với thay đổi độ sáng (quay ban ngày và cùng trang ở chế độ tối vẫn match được). Khoảng [-1, 1], 1 = khớp hoàn hảo. Hiểu trực quan: NCC không so giá trị tuyệt đối pixel mà so «mô hình sáng-tối có giống nhau không».

Nhưng trượt pixel từng pixel trên toàn bộ khung cũ quá chậm. Engine dùng tìm kiếm kim tự tháp ba tầng:

TầngBướcCửa sổỨng viênMục đích
Level 1 (Pre)12 pxToàn dải~125Định vị thô
Level 2 (Coarse)4 px±32 px~16Chính xác theo dòng
Level 3 (Fine)1 px±4 px9Tinh chỉnh pixel

Một con số đáng chia sẻ: bước 12 của Level 1 chỉ tìm 1/12 vị trí ứng viên, tăng tốc khoảng 12 lần cho mỗi template. Tổng tính toán ba tầng khoảng 1/8 tìm kiếm vét toàn dải.

Khi ứng viên vượt 64 (thường xảy ra ở Level 1), tự động bật tính toán song song GCD (DispatchQueue.concurrentPerform), phân phối tìm kiếm trên nhiều nhân CPU. Ở đây tôi gặp bẫy: ban đầu dùng NSLock bảo vệ biến bestScore chung, nhưng tranh chấp khóa trên 6 nhân ngược lại làm chậm. Chuyển sang local best từng thread rồi merge cuối cùng, tranh chấp khóa giảm từ O(n) xuống O(1).

Cửa sổ tìm kiếm: để lịch sử dẫn đường

Tìm toàn bộ vùng khả dụng của khung cũ mỗi lần vừa chậm vừa dễ match sai (đầu và cuối trang có thể có bố cục giống nhau).

Engine dùng hai loại «tiên nghiệm» để thu hẹp cửa sổ:

  • Tiên nghiệm thời gian: nếu offset cặp trước là 300 px, cặp này có lẽ tương tự. Thu hẹp cửa sổ thành 300 ± 120 px
  • Tiên nghiệm Vision: với cặp đầu chưa có lịch sử, dùng API đăng ký ảnh của Apple Vision cho ước lượng sơ bộ. Thu hẹp thành ước lượng ± 180 px

Nếu cửa sổ hẹp không tìm được ít nhất 3 match nhất quán (tiên nghiệm sai), tự động lùi về tìm kiếm toàn dải.

Điều kiện «ít nhất 3 nhất quán» cũng là bài học. Ban đầu dùng «ít nhất 1», nhưng khi tiên nghiệm Vision sai nhiều (thỉnh thoảng lệch 500 px), match sai trong cửa sổ hẹp tình cờ được coi là hợp lệ. Đổi thành 3, match sai phải lừa cùng lúc 3 template độc lập — xác suất gần như bằng không.

Khi matching thường thất bại: fallback ba tầng

Môi trường thực tế khắc nghiệt hơn phòng thí nghiệm nhiều. Nút nổi, overlay bán trong suốt, video đang phát, con trỏ nhấp nháy — tất cả kéo điểm NCC xuống.

Engine triển khai chiến lược fallback ba tầng. Dùng dữ liệu thực để minh họa tần suất:

Tầng 1: Match mạnh (~80 % cặp khung). Đa số template có NCC ≥ 0,6 và cùng chỉ về một offset. Trường hợp phổ biến nhất.

Tầng 2: Match mềm (~12 % cặp khung). Template tốt nhất chỉ có NCC 0,45–0,6 — có thể UI nổi che một phần. Dùng SAD toàn vùng chồng lấp làm xác minh phụ. SAD per pixel ≤ 26,0 → chấp nhận; nếu không, từ chối.

Tầng 3: NCC Profile 1D (~5 % cặp khung). «Làm phẳng» ảnh 2D thành đường cong trung bình mỗi dòng — một mảng 1D. Tương quan chéo hai đường cong 1D. Nhanh hơn ~100 lần, nhưng rủi ro dương tính giả cao.

Tất cả thất bại (~3 % cặp khung)? Khung này có thể là đổi cảnh hoặc không chồng lấp. Gắn vào cuối mà không ghép.

Xác minh SAD tầng 2 khá tinh tế. Ngưỡng cơ bản là 25,0 mỗi pixel, nhưng khi tỷ lệ chồng lấp vượt 80 %, ngưỡng được siết thích ứng:

if overlapRatio > 0,8:
    scale = max(minScale, 1,0 - (overlapRatio - 0,8) × 4,0)
    threshold = 25,0 × scale

Tại sao siết? Vùng chồng lấp càng lớn, SAD càng có nhiều mẫu thống kê, nhiễu ngẫu nhiên bị trung bình hóa — match sai cũng có SAD thấp. Không siết, dương tính giả tăng trong cảnh chồng lấp cao.

Dù đi đường nào, cuối cùng luôn có tinh chỉnh offset: tính SAD pixel từng pixel trong ±6 px quanh offset ứng viên, lấy giá trị nhỏ nhất.

Tại sao offset NCC vẫn cần tinh chỉnh? Tôi mất ba ngày để hiểu. NCC rất «phẳng» gần giá trị tối ưu — offset lệch 2 px, NCC chỉ thay đổi 0,003. Nhưng SAD cực kỳ nhạy với dịch chuyển ±1 px — 1 px chênh lệch có thể thay đổi SAD 3–5 đơn vị. Hai cái bổ sung nhau: NCC lo định vị thô, SAD lo hiệu chuẩn pixel. Khi ghép văn bản, 2 px sai là khác biệt giữa «một dòng chữ bị cắt đôi» và «căn chỉnh hoàn hảo».

Đường may: đừng cắt chữ làm đôi

Tìm được offset. Hai khung có vùng chồng lấp. Cắt ở đâu?

Trực quan là cắt ở giữa. Nhưng nếu giữa đúng lúc có dòng chữ lớn, nửa trên và nửa dưới không thẳng hàng — mắt nhận ra ngay.

Cách của engine: tính chênh lệch pixel của mỗi dòng trong vùng chồng lấp, rồi dùng cửa sổ trượt tìm vị trí có tổng chênh lệch nhỏ nhất. Chiều cao cửa sổ thích ứng — min(overlapH, max(100, adaptiveCutHeight)). Vị trí này là nơi nội dung hai khung gần nhất — đường may ở đây trông tự nhiên nhất.


Dữ liệu hiệu năng

Test với đoạn quay chat Zalo 30 giây điển hình (iPhone 15 Pro, 1080×2400, 60 fps). Các chỉ số chính:

Chỉ sốGiá trị
Tổng khung hình1.800
Thời gian phân tích Lượt 13,2 s
Khung hình chính trích được26
Tinh chỉnh Lượt 1,50,8 s
Template matching tổng4,1 s (25 cặp)
Matching trung bình mỗi cặp164 ms
Tỷ lệ dừng sớm58 % (15/25)
Tỷ lệ match mạnh84 % (21/25)
Match mềm kích hoạt3 cặp
Fallback Profile 1D1 cặp
Tất cả thất bại (gắn thêm)0 cặp
Kích thước ảnh cuối1.170 × 18.600
Thời gian end-to-end~8,5 s

Tăng tốc kim tự tháp rất rõ rệt: bước 12 của Level 1 bỏ qua 92 % ứng viên, Level 2 chỉ tính ~16 điểm trong cửa sổ ±32 px, Level 3 chỉ tinh chỉnh ±4 px. Tổng tính toán hiệu quả ba tầng khoảng 1/8 tìm kiếm vét toàn dải.


Những chi tiết kỹ thuật không thể bỏ qua

Ảnh dài 20.000 pixel không thể load hết một lần

Ảnh dài ghép từ 30 khung có thể đạt 1.170 × 24.000 pixel. RGBA đủ độ phân giải trong bộ nhớ chiếm 112 MB (1.170 × 24.000 × 4 byte) — quá nhiều cho iPhone.

StreamingStitchingPlanner thiết kế theo kiểu streaming: khi matching, bộ nhớ chỉ chứa «khung hiện tại + khung trước». Kết quả ghép được ghi thành tập hợp Piece (chỉ số ảnh nguồn + vùng cắt + vị trí đích). Render cuối cùng giải mã và vẽ theo từng khối.

Thuật toán loại bỏ thanh cuộn

Ảnh dài ghép xong gần như luôn còn thanh cuộn ở bên phải. Engine dùng phương pháp chiếu gradient tự động loại bỏ:

Trích dải rộng 3 % từ mép phải → tính gradient ngang tích lũy → hai mép thanh cuộn tạo hai đỉnh gradient (cách nhau 3–20 px) → xác nhận vị trí thực từng dòng → ghi đè bằng pixel liền kề bên trái. Thanh cuộn ngang ở dưới cùng xử lý tương tự.

UI dự phòng khi thuật toán thất bại

Thuật toán giỏi đến mấy cũng có lúc vấp. ScrollShot cung cấp tinh chỉnh tương tác trên mỗi đường may — người dùng kéo lên xuống để điều chỉnh vị trí ghép, xem trước thời gian thực. FineTuneGeometryEngine bên dưới đảm bảo ràng buộc hình học luôn hợp lệ. Xem trước render ở độ phân giải thấp cho nhanh; xuất tự động chuyển về độ phân giải đầy đủ. Khả năng kiểm soát này cũng là một trong những tương tác cốt lõi của chế độ Manual Stitch.


Lời cuối

Bài học lớn nhất từ engine này: thuật toán ghép ảnh không khó. Khó là làm nó hoạt động trên mọi trang «vô lý» trong thực tế.

Nền chat Zalo trông đều giống nhau, danh sách sản phẩm Shopee lặp vô tận, trang Cài đặt iOS có mảng trắng rộng, thumbnail YouTube chuyển động… mỗi thứ là một trường hợp biên mà không sách nào dạy, không paper nào viết.

Một bug tôi nhớ rất rõ: khi quay trang Cài đặt iOS, offset NCC luôn lệch khoảng 40 px. Debug mãi mới phát hiện đầu trang có mảng trắng lớn. Khi template rơi vào vùng trắng, NCC cho gần 1,0 ở mọi vị trí trắng — không phân biệt được. Cách sửa: phát hiện phương sai template quá thấp thì tự động bỏ qua — template trắng không mang thông tin, chi bằng không dùng.

Một lần khác còn vô lý hơn: quay trang chủ YouTube, thumbnail video có con mèo đang chuyển động. Vùng thumbnail hoàn toàn khác giữa hai khung, kéo NCC xuống 0,3. Thuật toán đơn lẻ không cứu được. Cuối cùng cơ chế bỏ phiếu 6 template đã cứu — 5 template còn lại lấy từ vùng tĩnh, kết quả bỏ phiếu vẫn đúng.

Giải quyết những vấn đề này không nhờ công thức toán thanh lịch hơn. Mà nhờ từng thủ thuật «tạm vá thế này đã» hết cái này đến cái khác. Nếu bạn cũng đang làm điều tương tự, hy vọng bài viết này giúp bạn tránh được vài hố.


Đọc thêm

ảnh chụp màn hình dài iOSthuật toán ghép ảnhtemplate matchingNCCengine ScrollShotghép quay màn hình
Quay lại blog