DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

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

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
Enter fullscreen mode Exit fullscreen mode
# 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
# 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.")
Enter fullscreen mode Exit fullscreen mode

Source Code

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

Top comments (0)