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.
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
# 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.
# 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.
# 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.")



Top comments (0)