How to Build a Desktop Document Scanner with Python, PySide6, and Dynamsoft Capture Vision

Digitizing paper documents from a webcam usually means fighting with skewed angles, poor lighting, and manual cropping. This project is a production-style desktop scanner app that detects document boundaries in real time, corrects perspective distortion, and exports the results as PDFs, individual images, or stitched long images. The app is built with Python, PySide6, and the Dynamsoft Capture Vision SDK.

desktop-python-document-scanner

What you’ll build: A PySide6 desktop document scanner that uses the Dynamsoft Capture Vision SDK to detect, normalize, filter, rotate, reorder, and export documents captured from a USB camera or imported image files.

Demo Video: Desktop Document Scanner in Action

Key Takeaways

  • This tutorial demonstrates how to build a real-time desktop document scanner in Python using PySide6 and Dynamsoft Capture Vision.
  • Dynamsoft Capture Vision’s CaptureVisionRouter provides document boundary detection and perspective normalization through preset templates.
  • The app processes camera frames on a background thread pool and downsamples detection input to keep the UI responsive at ~30 fps.
  • Detected quads are stabilized with IoU and area-delta checks before auto-capture, reducing false captures from shaky hands.
  • The resulting pages can be filtered, rotated, reordered, and exported as PDF, separate images, or a vertically stitched long image.

Common Developer Questions

How do I detect document boundaries from a webcam in a Python desktop app?

Use the Dynamsoft Capture Vision CaptureVisionRouter with the DetectDocumentBoundaries_Default preset template. Pass each camera frame as ImageData, then read the returned quadrilateral locations.

How does the app open the default camera?

The app calls cv2.VideoCapture(0) to open the first available camera using OpenCV’s default backend, which works across Windows, Linux, and macOS.

How do I convert between OpenCV numpy images and Dynamsoft Capture Vision ImageData?

Wrap the numpy buffer with the ImageData constructor and specify the pixel format (IPF_RGB_888 for RGB or IPF_GRAYSCALED for grayscale). Convert back by reshaping the returned bytes according to the output pixel format.

What happens if no document is detected during capture?

The capture worker falls back to saving the original frame so the user never loses an image, and the UI shows a toast message explaining that no document was found.

How can I export scanned pages as a PDF or long image?

The app uses reportlab to render each page onto A4 PDF sheets and OpenCV template matching to estimate overlaps before vertically stitching pages into a single long image.

Prerequisites

  • Dynamsoft Capture Vision Bundle >=3.0.0
  • Python 3.9+, PySide6 >=6.5.0, OpenCV >=4.8.0, Pillow, reportlab, numpy
  • A valid Dynamsoft license key. Get a 30-day free trial license.

Step 1: Install Dependencies and Initialize the SDK

Start by declaring the Python packages in requirements.txt and create a DCVScanner wrapper that initializes the license and CaptureVisionRouter.

# requirements.txt
dynamsoft-capture-vision-bundle>=3.0.0
PySide6>=6.5.0
opencv-python>=4.8.0
Pillow>=10.0.0
reportlab>=4.0.0
numpy>=1.24.0
# scanner.py
from dynamsoft_capture_vision_bundle import (
    CaptureVisionRouter,
    LicenseManager,
    EnumErrorCode,
)

DEFAULT_LICENSE_KEY = (
    "LICENSE-KEY"
)

DETECT_TEMPLATE = "DetectDocumentBoundaries_Default"
NORMALIZE_TEMPLATE = "NormalizeDocument_Default"

class DCVScanner:
    def __init__(self, license_key: str = DEFAULT_LICENSE_KEY):
        self.license_key = license_key
        self.cvr = None
        self._initialized = False

    def init(self):
        if self._initialized:
            return EnumErrorCode.EC_OK, "OK"
        ec, msg = LicenseManager.init_license(self.license_key)
        if ec != EnumErrorCode.EC_OK:
            return ec, msg
        self.cvr = CaptureVisionRouter()
        self._initialized = True
        return EnumErrorCode.EC_OK, "OK"

Step 2: Convert Between OpenCV Images and DCV ImageData

The SDK works with ImageData objects, while OpenCV and PySide6 use numpy arrays. Add helpers to move between the two formats without copying more data than necessary.

# scanner.py
import numpy as np
import cv2
from dynamsoft_capture_vision_bundle import (
    EnumImagePixelFormat,
    ImageData,
)

def np_to_image_data(image: np.ndarray) -> ImageData:
    if image.ndim == 2:
        h, w = image.shape
        stride = image.strides[0]
        return ImageData(image.tobytes(), w, h, stride, EnumImagePixelFormat.IPF_GRAYSCALED)
    if image.shape[2] == 3:
        h, w = image.shape[:2]
        stride = image.strides[0]
        return ImageData(image.tobytes(), w, h, stride, EnumImagePixelFormat.IPF_RGB_888)
    if image.shape[2] == 4:
        h, w = image.shape[:2]
        stride = image.strides[0]
        return ImageData(image.tobytes(), w, h, stride, EnumImagePixelFormat.IPF_ARGB_8888)
    rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    h, w = rgb.shape[:2]
    stride = rgb.strides[0]
    return ImageData(rgb.tobytes(), w, h, stride, EnumImagePixelFormat.IPF_RGB_888)

def image_data_to_np(image_data: ImageData) -> np.ndarray:
    fmt = image_data.get_image_pixel_format()
    w = image_data.get_width()
    h = image_data.get_height()
    stride = image_data.get_stride()
    buf = image_data.get_bytes()
    if fmt == EnumImagePixelFormat.IPF_GRAYSCALED:
        arr = np.frombuffer(buf, dtype=np.uint8).reshape((h, stride))
        gray = arr[:, :w]
        return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
    if fmt == EnumImagePixelFormat.IPF_RGB_888:
        arr = np.frombuffer(buf, dtype=np.uint8).reshape((h, stride))
        arr = arr[:, :w * 3]
        return arr.reshape((h, w, 3))
    # ... additional format handling in the source file

Step 3: Detect and Normalize Document Boundaries

With the router initialized, call the detect template to find the best document quad, then pass that quad into the normalize template to deskew and crop the page.

# scanner.py
class DCVScanner:
    def detect_document(self, image: np.ndarray):
        if not self._initialized:
            self.init()
        img_data = np_to_image_data(image)
        result = self.cvr.capture(img_data, DETECT_TEMPLATE)
        if result.get_error_code() != EnumErrorCode.EC_OK:
            return None
        processed = result.get_processed_document_result()
        if not processed:
            return None
        quads = processed.get_detected_quad_result_items()
        if not quads:
            return None
        best = max(quads, key=lambda q: q.get_confidence_as_document_boundary())
        loc = best.get_location()
        return [QuadPoint(p.x, p.y) for p in loc.points]

    def normalize_document(self, image: np.ndarray, quad_points=None):
        if not self._initialized:
            self.init()
        img_data = np_to_image_data(image)
        template_name = NORMALIZE_TEMPLATE
        if quad_points and len(quad_points) == 4:
            ec, msg, settings = self.cvr.get_simplified_settings(template_name)
            if ec == EnumErrorCode.EC_OK:
                quad = Quadrilateral()
                quad.points = [Point(int(round(p.x)), int(round(p.y))) for p in quad_points]
                settings.roi = quad
                settings.roi_measured_in_percentage = 0
                ec2, msg2 = self.cvr.update_settings(template_name, settings)
                if ec2 != EnumErrorCode.EC_OK:
                    print(f"update_settings warning: {msg2}")
        result = self.cvr.capture(img_data, template_name)
        if result.get_error_code() != EnumErrorCode.EC_OK:
            return None
        processed = result.get_processed_document_result()
        if not processed:
            return None
        items = processed.get_enhanced_image_result_items()
        if not items:
            return None
        return image_data_to_np(items[0].get_image_data())

Step 4: Build the PySide6 UI and Camera Preview

The entry point creates a QApplication in Fusion style and shows the main window. The main window opens the default camera with OpenCV, converts each BGR frame to RGB, and displays it in a custom CameraWidget.

Camera capture view with document boundary overlay

# main.py
import sys
from PySide6.QtWidgets import QApplication
from app import DocumentScannerApp

def main():
    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    window = DocumentScannerApp()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()
# app.py
class DocumentScannerApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Dynamsoft Document Scanner")
        self.setMinimumSize(900, 700)
        self.scanner = DCVScanner()
        self.thread_pool = QThreadPool()
        self.thread_pool.setMaxThreadCount(4)
        # ... UI setup and license screen

    def _init_camera(self):
        self.cap = cv2.VideoCapture(0)
        if self.cap and self.cap.isOpened():
            self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
            self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
        self.camera_timer.start(33)  # ~30 fps preview

    def _on_camera_frame(self):
        if not self.cap or not self.cap.isOpened():
            return
        ret, frame = self.cap.read()
        if not ret or frame is None:
            return
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        self.latest_frame = rgb
        self.camera_widget.set_frame(rgb)

Step 5: Add Auto-Capture, Manual Capture, and File Import

Run detection on a downscaled frame in a background worker to keep the UI smooth. When the detected quad stays stable across several frames, the app auto-captures. A shutter button also triggers manual capture with a short timeout fallback.

# app.py
class DocumentScannerApp(QMainWindow):
    def _on_detection_tick(self):
        if not self.is_scanning or self.is_processing_frame or self.is_capture_in_progress:
            return
        if self.latest_frame is None:
            return
        self.is_processing_frame = True
        h, w = self.latest_frame.shape[:2]
        scale = min(1.0, 640 / w)
        if scale < 1.0:
            small = cv2.resize(self.latest_frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
        else:
            small = self.latest_frame.copy()
        worker = DetectWorker(self.scanner, small)
        worker.signals.result.connect(lambda quad: self._on_detection_result(quad, scale))
        worker.signals.error.connect(lambda msg: self._on_detection_result(None, scale))
        self.thread_pool.start(worker)

    def _on_detection_result(self, quad, scale):
        self.is_processing_frame = False
        if quad:
            full_quad = [QuadPoint(p.x / scale, p.y / scale) for p in quad] if scale < 1.0 else quad
            self.latest_detected_quad = full_quad
            self.camera_widget.set_overlay(full_quad)
            if self.manual_capture_pending:
                self.manual_capture_pending = False
                self._reset_stabilizer()
                self._perform_capture(False, full_quad)
                return
            if self.last_quad is None:
                self.last_quad = full_quad
                self.stable_counter = 1
            elif is_quad_stable(full_quad, self.last_quad, self.quad_stabilizer["iou_threshold"], self.quad_stabilizer["area_delta_threshold"]):
                self.stable_counter += 1
                self.last_quad = full_quad
            else:
                self.stable_counter = 0
                self.last_quad = full_quad
            if self.quad_stabilizer["enabled"] and self.stable_counter >= self.quad_stabilizer["stable_frame_count"]:
                self._reset_stabilizer()
                self._perform_capture(True, full_quad)
        else:
            self.camera_widget.set_overlay(None)
            self._reset_stabilizer()

Step 6: Filter, Rotate, Reorder, and Export Pages

After capture, each page is stored as a Page object. The app applies color, grayscale, or binary filters through DCV’s ImageProcessor, rotates pages 90 degrees at a time, lets users drag to reorder, and exports to PDF, individual PNGs, or a stitched long image.

Document editor with quad adjustment and filters

# scanner.py
from dynamsoft_capture_vision_bundle import ImageProcessor

def apply_filter(image: np.ndarray, mode: str) -> np.ndarray:
    if mode == "color":
        return image.copy()
    processor = ImageProcessor()
    img_data = np_to_image_data(image)
    if mode == "grayscale":
        result_data = processor.convert_to_gray(img_data)
        return image_data_to_np(result_data)
    if mode == "binary":
        result_data = processor.convert_to_binary_global(img_data, threshold=140, invert=True)
        return image_data_to_np(result_data)
    return image.copy()

def rotate_image_90(image: np.ndarray) -> np.ndarray:
    return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
# app.py
class DocumentScannerApp(QMainWindow):
    def _on_rotate(self):
        if not self.pages:
            return
        page = self.pages[self.current_page_index]
        page.base_image = rotate_image_90(page.base_image)
        self._render_result()
        self._update_thumbnail_bar()

    def _on_sort(self):
        if len(self.pages) < 2:
            self._show_toast("Need at least 2 pages to reorder.")
            return
        dialog = SortDialog(self.pages, self)
        if dialog.exec() == QDialog.Accepted:
            order = dialog.get_order()
            self.pages = [self.pages[i] for i in order]
            self.current_page_index = 0
            self._render_result()
            self._update_thumbnail_bar()

    def _on_export_pdf(self):
        if not self.pages:
            return
        path, _ = QFileDialog.getSaveFileName(self, "Save PDF", "documents.pdf", "PDF Files (*.pdf)")
        if not path:
            return
        pdf = pdf_canvas.Canvas(path, pagesize=A4)
        page_width, page_height = A4
        for i, page in enumerate(self.pages):
            if i > 0:
                pdf.showPage()
            img = apply_filter(page.base_image, page.filter_mode)
            h, w = img.shape[:2]
            ratio = min(page_width / w, page_height / h)
            draw_w = w * ratio
            draw_h = h * ratio
            x = (page_width - draw_w) / 2
            y = (page_height - draw_h) / 2
            temp_path = f"_temp_pdf_{i}.jpg"
            save_image(img, temp_path)
            pdf.drawImage(temp_path, x, y, width=draw_w, height=draw_h)
        pdf.save()
        for i in range(len(self.pages)):
            temp = f"_temp_pdf_{i}.jpg"
            if os.path.exists(temp):
                os.remove(temp)
        self._show_toast("PDF exported.")

Common Issues & Edge Cases

  • Binary filter inversion: DCV may return inverted 8-bit binary data where white pixels are 0 and black pixels are 255. The image_data_to_np helper detects IPF_BINARY_8_INVERTED and flips the values back to black-text-on-white.
  • No document detected: If detect_document returns None during capture, the app saves the original frame instead of failing, so the user never loses a shot.
  • Long-image stitching failures: When the overlap estimator cannot find a confident match, it treats the overlap as zero and concatenates images without blending, preserving all content.

Conclusion

This project gives you a full-featured desktop document scanner built with Python, PySide6, and Dynamsoft Capture Vision. From real-time boundary detection to PDF export, the code shows how to combine DCV’s preset templates with a responsive Qt UI. Try adjusting the stabilization thresholds in the settings dialog or adding your own export formats next.

Source Code

https://github.com/yushulx/python-document-scanner-sdk/tree/main/examples/official/multi-document-capture