How CaptureOCR stitches scrolling screenshots without template matching

Why Template Matching Fails for Scrolling

cv2.matchTemplate expects the template to be a sub-image of the target. When a page scrolls, consecutive frames overlap — but neither frame contains the other. You can crop a strip and search, but it gets fragile fast:

  • Different content at top/bottom each frame
  • Sticky headers break the overlap assumption
  • Animation frames have partial content

Product link: CaptureOCR on Microsoft Store — $2.99 one-time, includes best-effort scrolling capture.

Row-Signature Approach

Instead of 2D template matching, compress each frame to a 1D row signature and search for vertical shift.

Step 1: Frame → Row Signature

import cv2 import numpy as np def frame_to_signature(frame: np.ndarray, buckets: int = 32) -> np.ndarray: """ Convert frame to (H, buckets) signature. - Ignore right 8% (scrollbars) - Compress each row to bucket means """ h, w = frame.shape[:2] # Ignore scrollbar region  usable_w = int(w * 0.92) roi = frame[:, :usable_w] # Grayscale  if len(roi.shape) == 3: gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) else: gray = roi # Compress columns into buckets  bucket_width = usable_w // buckets signature = np.zeros((h, buckets), dtype=np.float32) for b in range(buckets): start = b * bucket_width end = (b + 1) * bucket_width if b < buckets - 1 else usable_w signature[:, b] = gray[:, start:end].mean(axis=1) return signature # Shape: (H, 32) 

Step 2: Shift Search with MAE

def find_vertical_shift(prev_sig: np.ndarray, curr_sig: np.ndarray, max_shift: int = None) -> tuple[int, float]: """ Find vertical shift between two frame signatures using MAE. Returns (shift_pixels, best_score) """ h = prev_sig.shape[0] if max_shift is None: max_shift = h // 2 # Don't search more than half frame  best_shift = 0 best_score = float('inf') scores = [] for s in range(max_shift + 1): # Overlap region: prev[s:] vs curr[:h-s]  if h - s < 10: # Too little overlap  break overlap_prev = prev_sig[s:] overlap_curr = curr_sig[:h-s] # Mean Absolute Error  mae = np.mean(np.abs(overlap_prev - overlap_curr)) scores.append(mae) if mae < best_score: best_score = mae best_shift = s # Peak detection: best must be clearly better than median  median_score = np.median(scores) if best_score > median_score * 0.8: # Not a clear peak  return 0, float('inf') # Ambiguous  return best_shift, best_score 

Step 3: Sticky Header Handling

A fixed nav bar stays at top while content moves → overlap never matches.

def find_shift_with_sticky_retry(prev_sig: np.ndarray, curr_sig: np.ndarray) -> tuple[int, float]: """ Retry with increasing top-skip percentages for sticky headers. Skip ratios: 0% → 8% → 16% → 25% """ skip_ratios = [0.0, 0.08, 0.16, 0.25] for skip_ratio in skip_ratios: skip_rows = int(prev_sig.shape[0] * skip_ratio) if skip_rows >= prev_sig.shape[0] // 2: break shifted_prev = prev_sig[skip_rows:] shifted_curr = curr_sig[skip_rows:] shift, score = find_vertical_shift(shifted_prev, shifted_curr) if score != float('inf'): return shift + skip_rows, score # Adjust for skipped rows  return 0, float('inf') # All retries failed 

Step 4: Stitch Frames

def stitch_frames(frames: list[np.ndarray]) -> np.ndarray: """ Stitch a sequence of frames into one tall image. """ if not frames: return np.array([]) if len(frames) == 1: return frames[0] # Start with first frame  canvas = frames[0].copy() prev_sig = frame_to_signature(frames[0]) for i, curr_frame in enumerate(frames[1:], 1): curr_sig = frame_to_signature(curr_frame) # Find shift  shift, score = find_shift_with_sticky_retry(prev_sig, curr_sig) if score == float('inf'): # Fallback: assume single-page scroll step  shift = estimate_wheel_step(canvas.shape[0]) # Append non-overlapping portion  if shift < curr_frame.shape[0]: new_content = curr_frame[shift:] canvas = np.vstack([canvas, new_content]) prev_sig = frame_to_signature(canvas[-prev_sig.shape[0]:]) # Update with tail  # Hard caps  if canvas.shape[0] > 20000: # Max height  break if i > 999: # Max frames  break return canvas 

Why 32 Buckets?

Buckets 9 Real Frame Pairs Correct
12 7/9
24 8/9
32 9/9
64 9/9

With periodic text (line numbers 007, 008...), coarse buckets average away distinguishing columns. The shift search then locks onto multiples of line height, repeating sections. 32 is the cheapest configuration that passes.

Adaptive Wheel Step

One scroll notch = wildly different pixels in different apps (Qt text vs browser vs chat).

class AdaptiveWheelStep: def __init__(self, selection_height: int): self.selection_height = selection_height self.target_overlap_ratio = 0.45 # Target ~45% overlap  self.current_notches = 1 self.last_measured_shift = None def get_notches(self) -> int: return max(1, min(12, self.current_notches)) def update(self, measured_shift: int): """Called after successful stitch with actual shift.""" if self.last_measured_shift is None: self.last_measured_shift = measured_shift # Next step: target 45% of selection height  target = int(self.selection_height * self.target_overlap_ratio) self.current_notches = max(1, int(target / max(1, measured_shift))) else: # Gradual adaptation, cap 2x growth per step  target = int(self.selection_height * self.target_overlap_ratio) ideal = max(1, target / max(1, measured_shift)) self.current_notches = min(self.current_notches * 2, int(ideal)) self.last_measured_shift = measured_shift 

Failure Handling (Code-Based, Not String-Based)

class StitchResult: SUCCESS = "success" NO_CHANGE = "no_change" # Wheel didn't move content  NO_OVERLAP = "no_overlap" # Scrolled too far / mid-animation  AMBIGUOUS = "ambiguous" # Multiple equally good shifts  MAX_FRAMES = "max_frames" # Hit 999 frame cap  MAX_HEIGHT = "max_height" # Hit 20,000px cap  def handle_failure(reason: str, wheel_notches: int) -> tuple[bool, int]: """Returns (should_retry, new_notches)""" if reason == StitchResult.NO_CHANGE: # Wheel probably didn't reach target — resend same notches  return True, wheel_notches elif reason in (StitchResult.NO_OVERLAP, StitchResult.AMBIGUOUS): # Likely mid-animation or scrolled too far — halve and retry  return True, max(1, wheel_notches // 2) return False, 0 

Hard Caps

Cap Value Reason
Max frames 999 Prevent infinite loops
Max canvas height 20,000 px 999 frames × 4K would OOM
Min canvas height 20,000 px Actually a minimum for "long enough" detection
Retry limit 4 per frame After 4 failures, give up with reason

Limitations (Honest)

Scroll capture is best-effort v1.0. It fails on:

  • Virtualized lists (React Virtual, Flutter ListView) — items not in DOM until scrolled into view
  • Lazy-loaded images — content appears after scroll stops
  • Custom scroll animations — non-standard scroll behavior
  • Sticky sidebars — horizontal sticky elements break vertical-only signature

Region capture is the reliable fallback. The app tells you this upfront.

Performance

Metric Value
Signature computation ~0.5ms/frame
Shift search (32 buckets, 1080p) ~1ms/frame
Stitching 50 frames ~50-100ms total
Memory (20k px canvas) ~50MB

Resources