How to Build a Live Document Scanner Desktop App in Java with Dynamsoft Capture Vision SDK

Document scanning software needs to handle skewed, perspective-distorted pages captured from any angle — a problem that trips up many image-processing pipelines. This tutorial walks through a Java Swing desktop application that combines the LiteCam camera library with the Dynamsoft Capture Vision SDK (DDN) v3.4.1000 to detect document borders in real time, let the user fine-tune the detected quad, and produce a clean, deskewed output image.

What you’ll build: A cross-platform Java 17 desktop app that streams a live camera feed, automatically detects document boundaries using Dynamsoft DDN, lets you interactively drag corner handles to refine the quad, and exports a perspective-corrected PNG/JPEG — all without any native OpenCV dependency.

Key Takeaways

  • Demonstrates how to integrate Dynamsoft CaptureVisionRouter into a Java Swing desktop app for automatic document boundary detection.
  • Uses the DetectDocumentBoundaries_Default and NormalizeDocument_Default built-in templates from Dynamsoft Capture Vision SDK v3.4.1000 to eliminate the need for manual threshold tuning.
  • Runs at a ~30 FPS detection loop on a background ExecutorService thread, keeping the Swing EDT responsive at all times.
  • Applies in real-world workflows such as digitizing contracts, receipts, whiteboards, and ID documents directly from a webcam.

Common Developer Questions

  • How do I detect and normalize a document boundary from a live webcam using Dynamsoft in Java?
  • Why does my Swing UI freeze when running heavy image processing in the background?
  • How do I convert a BufferedImage to a Dynamsoft ImageData object for use with CaptureVisionRouter?

Demo Video: Java Document Scanner in Action

Prerequisites

  • Java 17+ (LTS recommended)
  • Maven 3.8+
  • LiteCam SDKlitecam.jar + its native libraries placed in the libs/ directory
  • Dynamsoft Capture Vision SDK 3.4.1000 — downloaded automatically by Maven from download2.dynamsoft.com/maven/jar
  • Get a 30-day free trial license

Step 1: Configure Maven Dependencies

The pom.xml pulls the Dynamsoft Capture Vision SDK from Dynamsoft’s Maven repository and the LiteCam SDK from the local Maven cache (installed from libs/litecam.jar during the build):

<repositories>
    <repository>
        <id>central</id>
        <url>https://repo1.maven.org/maven2</url>
    </repository>
    <repository>
        <id>dcv</id>
        <url>https://download2.dynamsoft.com/maven/jar</url>
    </repository>
</repositories>

<dependencies>
    <!-- LiteCam SDK (local JAR installed to local repository) -->
    <dependency>
        <groupId>com.example</groupId>
        <artifactId>litecam</artifactId>
        <version>1.0.0</version>
    </dependency>

    <!-- Dynamsoft Capture Vision SDK (includes CVR, DCP, DLR, DDN, DBR, etc.) -->
    <dependency>
        <groupId>com.dynamsoft</groupId>
        <artifactId>dcv</artifactId>
        <version>${dynamsoft.dcv.version}</version>
    </dependency>
</dependencies>

Build the fat JAR with:

Windows

# .\build.ps1

# Build script for LiteCam Barcode Scanner Maven Example
# PowerShell version

$ErrorActionPreference = "Stop"

# Define Maven executable path
$MavenPath = "C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.11\bin\mvn.cmd"

Write-Host "Building LiteCam Document Scanner Maven Example..." -ForegroundColor Green
Write-Host "=================================================" -ForegroundColor Green

# Check if Maven is available at the expected path
if (-not (Test-Path $MavenPath)) {
    # Try to find Maven in PATH
    if (-not (Get-Command mvn -ErrorAction SilentlyContinue)) {
        Write-Host "Error: Maven is not installed or not found" -ForegroundColor Red
        Write-Host "Expected Maven location: $MavenPath" -ForegroundColor Red
        exit 1
    } else {
        $MavenPath = "mvn"
    }
}

# Check if Java is installed
if (-not (Get-Command java -ErrorAction SilentlyContinue)) {
    Write-Host "Error: Java is not installed or not in PATH" -ForegroundColor Red
    exit 1
}

# Verify litecam.jar exists
if (-not (Test-Path "libs\litecam.jar")) {
    Write-Host "Error: litecam.jar not found in libs\ directory" -ForegroundColor Red
    Write-Host "Please copy litecam.jar to libs\ directory first" -ForegroundColor Red
    exit 1
}

Write-Host "`nJava version:" -ForegroundColor Yellow
java -version

Write-Host "`nMaven version:" -ForegroundColor Yellow
& $MavenPath -version

Write-Host "`nCleaning previous build..." -ForegroundColor Yellow
& $MavenPath clean

Write-Host "`nCompiling project..." -ForegroundColor Yellow
& $MavenPath compile

Write-Host "`nRunning tests..." -ForegroundColor Yellow
& $MavenPath test

Write-Host "`nCreating fat JAR with dependencies..." -ForegroundColor Yellow
& $MavenPath package

Write-Host "`nBuild completed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "To run the application:"
Write-Host "  Option 1: & `"$MavenPath`" exec:java -Dexec.mainClass=`"com.example.litecam.MRZScanner`""
Write-Host "  Option 2: java -jar target\litecam-mrz-scanner-1.0.0.jar"
Write-Host ""

$jarPath = "target\litecam-mrz-scanner-1.0.0.jar"
if (Test-Path $jarPath) {
    $jarSize = (Get-Item $jarPath).Length
    $jarSizeMB = [math]::Round($jarSize / 1MB, 2)
    Write-Host "JAR file created: $jarPath"
    Write-Host "JAR size: $jarSizeMB MB"
} else {
    Write-Host "Warning: JAR file not found at expected location" -ForegroundColor Yellow
}

Linux/macOS

# ./build.sh

#!/bin/bash
# Build script for LiteCam Barcode Scanner Maven Example

set -e

echo "Building LiteCam Document Scanner Maven Example..."
echo "================================================="

# Check if Maven is installed
if ! command -v mvn &> /dev/null; then
    echo "Error: Maven is not installed or not in PATH"
    exit 1
fi

# Check if Java is installed
if ! command -v java &> /dev/null; then
    echo "Error: Java is not installed or not in PATH"
    exit 1
fi

# Verify litecam.jar exists
if [ ! -f "libs/litecam.jar" ]; then
    echo "Error: litecam.jar not found in libs/ directory"
    echo "Please copy litecam.jar to libs/ directory first"
    exit 1
fi

echo "Java version:"
java -version

echo ""
echo "Maven version:"
mvn -version

echo ""
echo "Cleaning previous build..."
mvn clean

echo ""
echo "Compiling project..."
mvn compile

echo ""
echo "Running tests..."
mvn test

echo ""
echo "Creating fat JAR with dependencies..."
mvn package

echo ""
echo "Build completed successfully!"
echo ""
echo "To run the application:"
echo "  Option 1: mvn exec:java -Dexec.mainClass=\"com.example.litecam.BarcodeScanner\""
echo "  Option 2: java -jar target/litecam-barcode-scanner-1.0-SNAPSHOT-shaded.jar"
echo ""
echo "JAR file created: target/litecam-barcode-scanner-1.0-SNAPSHOT-shaded.jar"
echo "JAR size: $(du -h target/litecam-barcode-scanner-1.0-SNAPSHOT-shaded.jar 2>/dev/null | cut -f1 || echo 'Unknown')"


Step 2: Initialize the Dynamsoft License and CaptureVisionRouter

License initialization must happen before any SDK call. The main method activates the license with LicenseManager.initLicense, then creates the application window on the Swing EDT:

public static void main(String[] args) {
    int    errorCode = 0;
    String errorMsg  = "";

    // Initialize Dynamsoft license.
    // Request a free trial at https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform
    try {
        LicenseError licenseError = LicenseManager.initLicense(
                "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==");
        if (licenseError.getErrorCode() != EnumErrorCode.EC_OK
                && licenseError.getErrorCode() != EnumErrorCode.EC_LICENSE_WARNING) {
            errorCode = licenseError.getErrorCode();
            errorMsg  = licenseError.getErrorString();
        }
    } catch (LicenseException e) {
        errorCode = e.getErrorCode();
        errorMsg  = e.getErrorString();
    }

    if (errorCode != EnumErrorCode.EC_OK) {
        System.err.println("License warning — ErrorCode: " + errorCode + ", ErrorString: " + errorMsg);
    }

    SwingUtilities.invokeLater(() -> {
        JFrame frame = new JFrame("Document Scanner");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        try {
            boolean fileMode = args.length > 0 && args[0].equalsIgnoreCase("--file");
            DocumentScanner scanner = fileMode ? new DocumentScanner() : new DocumentScanner(0);
            frame.setContentPane(scanner);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
            Runtime.getRuntime().addShutdownHook(new Thread(scanner::cleanup));
        } catch (Exception e) {

The CaptureVisionRouter — the central SDK object used for all capture operations — is initialized once in initRouter():

private void initRouter() {
    try {
        cvRouter = new CaptureVisionRouter();
        logger.info("CaptureVisionRouter initialized");
    } catch (Exception e) {
        logger.error("Failed to initialize CaptureVisionRouter", e);
        throw new RuntimeException("CVR init failed", e);
    }
}

Step 3: Stream Camera Frames and Detect Document Boundaries

The detection loop runs on a dedicated daemon thread at ~30 FPS. Each frame is grabbed from LiteCam, deep-copied to avoid races with the UI thread, and passed directly to detectDocumentBoundary:

private void startDetectionWorker() {
    if (currentMode != Mode.CAMERA) return;

    detectWorker.submit(() -> {
        while (isRunning.get() && currentMode == Mode.CAMERA) {
            try {
                if (!detectPaused.get() && cam != null && cam.isOpen() && cam.grabFrame(frameBuffer)) {
                    byte[] dst = ((DataBufferByte) cameraFrame.getRaster().getDataBuffer()).getData();
                    frameBuffer.rewind();
                    frameBuffer.get(dst, 0, Math.min(dst.length, frameBuffer.remaining()));
                    frameBuffer.rewind();

                    BufferedImage snapshot = deepCopy(cameraFrame);
                    DetectedQuadResultItem[] quads = detectDocumentBoundary(snapshot);

                    synchronized (resultLock) {
                        latestSourceImage  = snapshot;
                        latestDetectedQuad = (quads != null && quads.length > 0)
                                ? quads[0].getLocation() : null;
                    }
                }
                Thread.sleep(33); // ~30 FPS cap
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception ex) {
                logger.debug("Detection worker error: {}", ex.getMessage());
            }
        }
    });
}

The detectDocumentBoundary method converts the frame to a Dynamsoft ImageData object and calls CaptureVisionRouter.capture with the DetectDocumentBoundaries_Default built-in template:

private static final String DETECT_TEMPLATE = "DetectDocumentBoundaries_Default";

private DetectedQuadResultItem[] detectDocumentBoundary(BufferedImage source) {
    if (source == null || cvRouter == null) return null;
    try {
        ImageData imageData = toImageData(source);
        CapturedResult result = cvRouter.capture(imageData, DETECT_TEMPLATE);
        if (result == null) return null;
        int err = result.getErrorCode();
        if (err != EnumErrorCode.EC_OK && err != EnumErrorCode.EC_UNSUPPORTED_JSON_KEY_WARNING) {
            logger.debug("DetectDocumentBoundary error {}: {}", err, result.getErrorString());
            return null;
        }
        ProcessedDocumentResult docResult = result.getProcessedDocumentResult();
        if (docResult == null) return null;
        return docResult.getDetectedQuadResultItems();
    } catch (Exception e) {
        logger.debug("detectDocumentBoundary error: {}", e.getMessage());
        return null;
    }
}

The toImageData helper converts a BufferedImage to the BGR byte array format the SDK expects:

private static ImageData toImageData(BufferedImage src) {
    BufferedImage bgr = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
    Graphics2D g = bgr.createGraphics();
    g.drawImage(src, 0, 0, null);
    g.dispose();
    byte[] bytes = ((DataBufferByte) bgr.getRaster().getDataBuffer()).getData();
    return new ImageData(bytes, src.getWidth(), src.getHeight(), src.getWidth() * 3,
            EnumImagePixelFormat.IPF_BGR_888, 0, null);
}

Step 4: Render the Detected Quad Overlay

The CameraPanel repaints every 33 ms via a javax.swing.Timer. The drawQuadOverlay method scales the detected Quadrilateral coordinates to the panel’s display size and draws a semi-transparent fill, edge lines, and corner dots:

private void drawQuadOverlay(Graphics2D g2d, int ox, int oy, double scale) {
    if (overlayFrozen) return;
    Quadrilateral quad;
    boolean isCustom;
    synchronized (resultLock) {
        isCustom = customQuad != null;
        quad = isCustom ? customQuad : latestDetectedQuad;
    }
    if (quad == null || quad.points == null || quad.points.length < 4) return;

    int[] xs = new int[4], ys = new int[4];
    for (int i = 0; i < 4; i++) {
        xs[i] = ox + (int)(quad.points[i].getX() * scale);
        ys[i] = oy + (int)(quad.points[i].getY() * scale);
    }

    Color fillColor = isCustom ? new Color(255, 165, 0, 35) : new Color(0, 200, 255, 35);
    Color edgeColor = isCustom ? new Color(255, 165, 0, 220) : new Color(0, 200, 255, 220);
    Color dotColor  = isCustom ? Color.ORANGE : Color.CYAN;

    g2d.setColor(fillColor);
    g2d.fillPolygon(xs, ys, 4);

    g2d.setStroke(new BasicStroke(2.5f));
    g2d.setColor(edgeColor);
    for (int i = 0; i < 4; i++) {
        g2d.drawLine(xs[i], ys[i], xs[(i + 1) % 4], ys[(i + 1) % 4]);
    }

    g2d.setColor(dotColor);
    for (int i = 0; i < 4; i++) {
        g2d.fillOval(xs[i] - 5, ys[i] - 5, 10, 10);
    }
}

Detected quads are drawn in cyan; user-edited (custom) quads are drawn in orange, giving instant visual feedback on which boundary will be used for normalization.


Step 5: Edit Corners and Normalize the Document

Clicking Edit Quad/Corners & Normalize Document opens a modal QuadEditDialog. The dialog pauses the detection worker so the shared CaptureVisionRouter is idle during the edit:

private void onEditQuad(ActionEvent e) {
    BufferedImage source;
    Quadrilateral quad;
    synchronized (resultLock) {
        source = latestSourceImage;
        quad   = customQuad != null ? customQuad : latestDetectedQuad;
    }

    if (currentMode == Mode.CAMERA) detectPaused.set(true);

    final BufferedImage snapshot = deepCopy(source);
    QuadEditDialog dialog = new QuadEditDialog(
            SwingUtilities.getWindowAncestor(this), snapshot, quad);
    dialog.setVisible(true);

    Quadrilateral edited = dialog.getResult();
    if (edited != null) {
        synchronized (resultLock) { customQuad = edited; }
        final Quadrilateral finalQuad = edited;
        SwingWorker<BufferedImage, Void> sw = new SwingWorker<>() {
            @Override
            protected BufferedImage doInBackground() {
                return perspectiveWarp(snapshot, finalQuad);
            }
            @Override
            protected void done() {
                // update normalizedImage and re-enable UI on EDT ...
            }
        };
        sw.execute();
    } else {
        if (currentMode == Mode.CAMERA) detectPaused.set(false);
    }
}

Normalization is performed by a pure-Java inverse-homography perspective warp. The homography matrix is solved via Gaussian elimination, then each output pixel is reverse-mapped to the source image with bilinear interpolation:

private static BufferedImage perspectiveWarp(BufferedImage src, Quadrilateral quad) {
    Point[] pts = quad.points;
    double topW  = distance(pts[0], pts[1]);
    double botW  = distance(pts[3], pts[2]);
    double leftH = distance(pts[0], pts[3]);
    double riteH = distance(pts[1], pts[2]);
    int outW = Math.max(4, (int) Math.max(topW, botW));
    int outH = Math.max(4, (int) Math.max(leftH, riteH));

    float[] srcPts = {
        (float) pts[0].getX(), (float) pts[0].getY(),
        (float) pts[1].getX(), (float) pts[1].getY(),
        (float) pts[2].getX(), (float) pts[2].getY(),
        (float) pts[3].getX(), (float) pts[3].getY()
    };
    float[] dstPts = { 0, 0, outW - 1, 0, outW - 1, outH - 1, 0, outH - 1 };

    double[] H    = computeHomography(srcPts, dstPts);
    double[] Hinv = invertHomography(H);

    BufferedImage out = new BufferedImage(outW, outH, BufferedImage.TYPE_3BYTE_BGR);
    for (int oy = 0; oy < outH; oy++) {
        for (int ox = 0; ox < outW; ox++) {
            double denom = Hinv[6] * ox + Hinv[7] * oy + Hinv[8];
            double sx    = (Hinv[0] * ox + Hinv[1] * oy + Hinv[2]) / denom;
            double sy    = (Hinv[3] * ox + Hinv[4] * oy + Hinv[5]) / denom;
            // bilinear sample from src ...
        }
    }
    return out;
}

Run the Application

After building the fat JAR (see Step 1), launch the scanner from the target/ directory:

Windows

java -jar target\document-scanner-1.0-jar-with-dependencies.jar

Linux/macOS

java -jar target/document-scanner-1.0-jar-with-dependencies.jar

Pass --file to open an image from disk instead of using the webcam:

java -jar target/document-scanner-1.0-jar-with-dependencies.jar --file

Java document scanner desktop app — detected quad overlay and normalized output


Common Issues & Edge Cases

  • License initialization must precede CaptureVisionRouter construction: Calling new CaptureVisionRouter() before LicenseManager.initLicense results in an unlicensed watermark on output images. Always call initLicense in main before creating Swing components.
  • Camera index out of range: If no camera is found at the requested index, initCamera throws an IllegalArgumentException. Use the --file flag (java -jar ... --file) to start in file-only mode on machines without a webcam.
  • Detection paused flag is critical for thread safety: The detectPaused AtomicBoolean prevents the background worker from calling cvRouter.capture concurrently while the quad-edit SwingWorker also uses it for normalization. Removing this guard leads to intermittent CapturedResult errors under load.

Conclusion

This sample demonstrates how the Dynamsoft Capture Vision SDK removes all the hard parts of document scanning — boundary detection, confidence scoring, and result types — from a Java desktop application, leaving only straightforward Swing and image-conversion code to write. The same CaptureVisionRouter API works identically for static images and live video, making it easy to add file-mode support alongside the camera stream. For next steps, explore the Dynamsoft Capture Vision documentation to add barcode reading or MRZ recognition to the same pipeline.


Source Code

https://github.com/yushulx/java-barcode-mrz-document-scanner/tree/main/examples/document-scanner