Python driver for Plustek OpticFilm USB film scanners, built on PyUSB and reverse-engineered from USB captures and the SANE genesys backend.
The library talks directly to the scanner’s Genesys ASIC (GL842, GL843, GL845, or GL128) over USB—no vendor Windows driver is required once the device is bound for libusb access.
Support is one of:
- Hardware tested — live scan + park on physical hardware
- Protocol validated — USB/register traces match a golden setup without hardware; motors stay locked
- Experimental — tables and session code exist; scan/home/park/calibrate stay locked
| Model | USB ID | ASIC | Support |
|---|---|---|---|
| OpticFilm 8200i SE | 07b3:1825 |
GL128 | Hardware tested |
| OpticFilm 8100 (V2) | 07b3:1824 |
GL128 | Hardware tested (no IR) |
| OpticFilm 8300i SE | 07b3:181f |
GL128 | Hardware tested (IR capable) |
| OpticFilm 7600i v1 | 07b3:0c3b |
GL843 | Hardware tested (notes) |
| OpticFilm 7600i v2 | 07b3:0c3b |
GL845 | Hardware tested (IR capable; notes) |
| OpticFilm 8200i | 07b3:130d |
GL845 | Protocol validated (setup traces; scan locked) |
| OpticFilm 8100 | 07b3:130c |
GL845 | Experimental |
| OpticFilm 7500i | 07b3:0c13 |
GL843 | Experimental |
| OpticFilm 7400 (v1 / v2) | 07b3:0c3a |
GL845 / GL843 | Experimental |
| OpticFilm 7300 | 07b3:0c12 |
GL843 | Experimental |
| OpticFilm 7200i / 7200 | 07b3:0c04, 07b3:0807, 07b3:0c07 |
GL843 / GL842 | Experimental |
The GL845 OpticFilm 8100 (07b3:130c) is a different product from the GL128 8100 (V2) (07b3:1824).
Other OpticFilm models enumerate and open: you can read status, turn the lamp on/off (where implemented), and dump registers for bring-up. scan(), calibrate(), home(), and park() stay gated until a model is hardware-tested—calling them raises AsicError rather than risking carriage or lamp damage. Protocol validation does not flip that gate. See docs/scanner-validation.md.
Scanner.open() prefers a scan-ready device (8200i SE, 8100 V2, 8300i SE, 7600i v1, or 7600i v2) when several Plustek film scanners are connected.
- Color and infrared transparency scans at 150–7200 dpi (ASIC programs at ≥600 dpi; lower PPI shares the 600 dpi register set and is downsampled on the host; infrared is available only on supported hardware)
- Infrared as a dust plane on
ScanImage.ir(mode="infrared", orinfrared=Truewith colour; 8200i SE, 8300i SE, 7600i v1, and 7600i v2 among the hardware-tested set) - Multi-exposure (ME) on GL128 hardware-tested models (8200i SE, 8100 V2, and 8300i SE): short + adaptive long colour passes with host SNR/IVW merge into
ScanImage.rgb(multi_exposure=True), or a SilverFast-style fixed long pass (me_long_mode="fixed"); bracket planes viaScanner.last_me_debug. Not available at 7200 dpi - Multi-exposure on the GL845 7600i v2 at every resolution: the long pass raises the line period (up to 65000) and uses the same merge; see the 7600i v2 notes
- Multi-Pass on GL128: repeat an already-validated exposure
n_passestimes (1–9) and stack the aligned repeats for an SNR gain — no new exposure/speed value is ever introduced, only repeats of the short pass (n_passes>1) or, combined withmulti_exposure=True, of both the short and long ME passes (Adaptive Multi-Pass); per-slot stacking stats viaScanner.last_multi_pass_debug. Not available at 7200 dpi - Manual exposure overrides on GL128 (
single_pass_exposure/me_short_exposure/me_long_exposure) for testing/debugging: bypass the adaptive/hardware-max clamps and write an exactREG_EXPOSUREvalue (24-bit register range) - Optional crop via normalized
area(x1, y1, x2, y2in 0–1) - GL128 dark/white shading calibration with on-disk cache (
~/.cache/pyopticfilm/calib_v2.json) - GL128 ASIC shading path (AFE codes + shading blob) aligned with SilverFast capture order
- Left–right orientation corrected in
ImagePipeline.assemble()formirror_xmodels - Pass registration (Multi-Exposure, Multi-Pass, IR→RGB) in pure NumPy; no OpenCV dependency
- 16-bit RGB
numpyoutput; optional TIFF export viatifffile - Progress and cancel hooks for long scans
- Read the scanner's QuickScan / IntelliScan buttons on GL128 models (
scanner.button_state())
Not implemented or out of scope here: iSRD infrared dust removal, SilverFast-style UI, or shipping a desktop app—applications own post-processing.
See CHANGELOG.md for release notes. Latest release: v1.3.8 on GitHub.
- Python ≥ 3.11
numpy,pyusb- libusb 1.0 backend for PyUSB
- Windows:
libusb-packageis installed automatically withpip install pyopticfilmand provides a bundledlibusb-1.0.dll - Linux: system
libusb-1.0(e.g.libusb-1.0-0on Debian/Ubuntu) and permission to access the device (udev rule or run as root—not recommended) - macOS: libusb via Homebrew or
libusb-package
- Windows:
Optional: tifffile for ScanImage.save_tiff().
pip install pyopticfilmFrom source:
git clone https://github.com/jboneng/pyopticfilm.git
cd pyopticfilm
uv sync --all-groupsThe Plustek vendor driver must not own the device when using this library.
Use Zadig to replace the vendor driver with WinUSB (or libusbK) for the scanner’s USB interface. Step-by-step instructions, troubleshooting, and how to revert to the Plustek driver are in docs/windows-setup.md.
Install libusb and add a udev rule granting your user access to Plustek film scanners, for example:
# /etc/udev/rules.d/99-plustek-opticfilm.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="07b3", MODE="0666"
Reload udev rules and replug the scanner.
from pyopticfilm import Scanner
with Scanner.open() as scanner:
print(scanner.model.model, scanner.device_id)
scanner.warmup() # init, home, lamp on
image = scanner.scan(resolution=1800, mode="color")
print(image.rgb.shape, image.rgb.dtype) # H×W×3 uint16
image.save_tiff("frame.tif") # requires tifffileRunnable examples: examples/scan.py (interactive scan to TIFF) and examples/buttons.py (scanner buttons).
For the 7600i v1, explicitly use 1440, 3600 or 7200 dpi for colour; IR supports 3600 or 7200 dpi.
The shared scan() default of 1800 dpi is unsupported on this model. See the
7600i notes for calibration and acquisition limits.
Infrared scan (8200i SE, 8300i SE, or 7600i v2):
with Scanner.open() as scanner:
scanner.warmup()
ir = scanner.scan(resolution=1800, mode="infrared")Multi-exposure (GL128 / hardware-tested models): short colour pass, then a
frame-adaptive long pass. The long clamp is 14k–85k on all three
hardware-tested GL128 models. Fallback 42000. Multi-exposure is not available
at 7200 dpi (see Resolution limits below).
The SNR/IVW-merged deliverable with film-base makeup is in rgb. Bracket
planes and fusion stats are on :attr:~pyopticfilm.scanner.Scanner.last_me_debug
(Scan Lab / audit tooling only — not part of the NegPy-facing ScanImage).
with Scanner.open() as scanner:
scanner.warmup()
image = scanner.scan(
resolution=1800,
mode="color",
multi_exposure=True,
)
print(image.rgb.shape) # SNR/IVW merged deliverable
image.save_tiff("merged.tif")
debug = scanner.last_me_debug
if debug is not None:
from pyopticfilm.image import save_rgb16_tiff
save_rgb16_tiff(debug.rgb_short, "short.tif", dpi=image.dpi)
save_rgb16_tiff(debug.rgb_long, "long.tif", dpi=image.dpi)
print(debug.exposure_short, debug.exposure_long) # e.g. 14000, 42000…85000
print(debug.exposure_proposed, debug.exposure_reason)Fixed multi-exposure (GL128): me_long_mode="fixed" skips the metering and
scans the long pass at the model's fixed exposure on every frame, the way
SilverFast does: 42000 (3× short) on the 8200i SE and 8100 V2, 60000 (4× short)
on the 8300i SE, from model.me_fixed_long_exposure. The value still goes
through the normal 14k–85k long clamp. Alignment, merge, film-base makeup, IR
and n_passes work exactly as in adaptive mode; debug.exposure_reason is
"fixed". A manual me_long_exposure takes precedence over either mode.
Without multi_exposure=True the option has no effect.
image = scanner.scan(
resolution=1800,
mode="color",
multi_exposure=True,
me_long_mode="fixed", # default "adaptive"
)Manual exposure overrides (GL128; debugging/testing only): send an exact
REG_EXPOSURE value that bypasses adaptive selection and the hardware-max
clamp above — the value is written verbatim. Two limits still apply: the
24-bit register range (1–0xFFFFFF), and — at oversample == 1 resolutions
(e.g. 7200 dpi) — the AHB scan motor table's 16-bit width
(1–65535); either is rejected with a clear error rather than clamped or
silently corrupted. All three default to None (unchanged behavior):
image = scanner.scan(
resolution=1800,
mode="color",
multi_exposure=True,
me_short_exposure=14000,
me_long_exposure=120000, # above the adaptive long clamp, on purpose
)Multi-Pass (GL128): repeat the short pass (or, with multi_exposure=True,
both the short and long ME passes) n_passes times and stack the aligned
repeats for an SNR gain, without introducing any new exposure or scan-speed
value. n_passes=1 (default) is unchanged behavior — the same Single-Pass or
Adaptive Multi-Exposure scan as today. Per-slot stacking stats (align shifts,
frames merged, outlier pixels) are on Scanner.last_multi_pass_debug:
# Multi-Pass: 4 repeats of the single exposure, stacked.
image = scanner.scan(resolution=1800, mode="color", n_passes=4)
# Adaptive Multi-Pass: 4 repeats each of the short AND adaptive-long
# ME passes, each slot stacked, then fused exactly as today's 2-bracket ME.
image = scanner.scan(
resolution=1800, mode="color", multi_exposure=True, n_passes=4
)
debug = scanner.last_multi_pass_debug
if debug is not None:
print(debug.short.stack_stats.mean_confidence, debug.short.align_shifts)Common scan-mode combinations. multi_exposure and n_passes are independent axes,
and me_long_mode only matters with multi_exposure=True. A simplified consumer UI
typically only needs these combinations, named as follows:
| Name | multi_exposure |
me_long_mode |
n_passes |
|---|---|---|---|
| Single-Pass | False |
— | 1 |
| Multi-Pass | False |
— | 2–9 |
| Adaptive Multi-Exposure | True |
"adaptive" |
1 |
| Adaptive Multi-Pass | True |
"adaptive" |
2–9 |
| Fixed Multi-Exposure | True |
"fixed" |
1 |
| Fixed Multi-Pass | True |
"fixed" |
2–9 |
Resolution limits. Multi-exposure and Multi-Pass are not available at every
resolution: on the GL128 models they are off at 7200 dpi. Before offering them
for a resolution, check the model; scan() raises ScanError otherwise. Models
without the attributes don't support the mode at all.
model = scanner.model
me_dpis = getattr(model, "multi_exposure_resolutions_dpi", ()) # e.g. (3600, 2400, ...) — no 7200
stack_dpis = getattr(model, "multi_pass_resolutions_dpi", ()) # n_passes > 1
offer_me = dpi in me_dpis
offer_multi_pass = dpi in stack_dpisThe three manual exposure overrides above are lab/debug-only —
Scan Lab (tools/scanlab/) is the reference implementation exposing the full, unrestricted
parameter set; NegPy is the reference implementation of the simplified 4-combination surface.
Colour + IR in one call (8200i SE, 8300i SE, or 7600i v2; IR after the colour / ME passes):
image = scanner.scan(
resolution=1800,
mode="color",
multi_exposure=True,
infrared=True,
)
# image.rgb is the merged deliverable; image.ir is HxW uint16Crop (normalized coordinates on the transparency window):
image = scanner.scan(
resolution=2400,
mode="color",
area=(0.1, 0.1, 0.9, 0.9), # x1, y1, x2, y2
)Scanner buttons (GL128; tested on the 8200i SE and 8300i SE). Each
call reads the USB interrupt endpoint once and returns the buttons pressed since
the last read, so polling every few hundred milliseconds misses nothing. With no
press it returns an empty set after timeout_ms; a real USB failure still raises
UsbError. Other ASICs have no button_state. Poll from one thread, pause polling
while a scan runs, and start scans from your normal scan path rather than from the
polling thread:
from pyopticfilm.asic.buttons import Button
pressed = scanner.button_state(timeout_ms=250) # set() or e.g. {Button.QUICK_SCAN}examples/buttons.py is a complete polling loop.
List devices without opening:
from pyopticfilm.usb.device import find_devices
for info in find_devices():
print(info.device_id, info.product_id, info.asic_hint, info.is_supported)| Entry | Purpose |
|---|---|
Scanner.open(device_id=None) |
Open preferred or specified OpticFilm |
scanner.warmup(home=True, lamp=True) |
Boot ASIC, optional home + lamp |
scanner.scan(...) |
Run a full scan → ScanImage |
scanner.calibrate(...) |
Run shading; updates cache (unsupported on 7600i v1) |
scanner.status() |
Read scanner status flags |
scanner.button_state(timeout_ms=...) |
Poll the QuickScan / IntelliScan buttons (GL128) |
scanner.home() / scanner.park() |
Motor positioning |
scanner.lamp_on() / scanner.lamp_off() |
Lamp control (allowed on experimental models) |
scanner.advanced |
Low-level register read/write (bring-up) |
scanner.calibrator |
Direct access to calibration cache |
ScanImage fields: rgb (uint16 H×W×3), dpi, device_model, optional ir.
For ME scans, rgb is the SNR/IVW-merged deliverable (with film-base makeup).
Bracket planes live on Scanner.last_me_debug, not on ScanImage.
Scan modes: "color", "infrared". "gray" is not implemented.
scan(..., multi_exposure=True) is for the hardware-tested GL128 models (8200i SE,
8100 V2, and 8300i SE) and the GL845 7600i v2. When ME is on, rgb is always SNR/IVW-merged (per-channel
clip confidence, soft highlight roll-off from ~80–95% FS). The noise model is
fitted from each scan's own short/long pair (last_me_debug.fusion_stats
reports it); set both model.me_noise_alpha / me_noise_beta to fix it instead. Pass infrared=True with
mode="color" for a dust/IR plane on the same ScanImage (8200i SE, 8300i SE,
7600i v1, and 7600i v2; the 8100 V2 has no IR). Inspect short/long via scanner.last_me_debug
after the scan.
Audit a saved bracket (and optional SilverFast ME TIFF)::
PYTHONPATH=src python -m tools.audit_me_bracket short.tif long.tif --sf sf_merged.tifEnable debug logging:
from pyopticfilm.logging import enable_debug_logging
enable_debug_logging()For calibration problems, python tools/diag_calibration.py runs one forced calibration and zips a debug log, the build/model details and a USB trace (including the calibration strips) to attach to an issue.
The 7600i v1 calibrates live inside every scan, without a cache. Standalone
scanner.calibrate() is unsupported. apply_calib=False selects captured AFE/shading values
for comparison; widened windows and derived 7200 dpi IR still recompute final shading gains.
The following cache guidance applies to GL128 models.
Each scan calibrates automatically: the first scan of a given resolution and crop in a session measures (about 6 s on the 8200i SE), and later scans of the same window in that session reuse it. Calibrations saved by earlier sessions are not reused, because lamp and sensor drift between sessions and every GL128 unit reports the same USB serial, so a swapped scanner cannot be told apart. Force a new calibration within a session with scanner.calibrate(force=True); scanner.scan(..., apply_calib=False) scans without shading.
The cache key includes resolution, crop geometry, and scan method (transparency vs infrared). GL128 colour shading uses ASIC-internal measurements at home; IR uses a white-only path, read moving like the colour white strip.
Code for additional OpticFilm variants is included so enumeration, model selection, SANE-derived geometry tables, and hardwareless USB traces can be exercised without hardware. These paths are deliberately locked for motor moves and image acquisition:
model.scan_readyisTrueonly for the 8200i SE, 8100 (V2), 8300i SE, 7600i v1, and 7600i v2; all other models stayFalseScanner._ensure_scan_ready()blocks scan, calibrate, home, and park on non-scan-ready models- GL128 motor moves stay disabled unless the model is scan-ready
- Protocol-validated (currently OpticFilm 8200i setup traces) is not hardware support
If you have a non-scan-ready OpticFilm and want to help validate scanning, open an issue with your exact USB IDs (bcdDevice matters for some models) and we can work through capture-based bring-up. How traces are recorded and compared is in docs/scanner-validation.md.
See CONTRIBUTING.md for model-lock policy and how to specialize GL128 siblings (8200i SE / 8100 V2 / 8300i SE) without retargeting frozen oracles.
uv sync --all-groups
uv run ruff check .
uv run pytest -qOptional PyQt6 scan lab (git checkout only — not on PyPI). From the repo root, Run against MOCK is on by default; uncheck it to use a plugged-in scanner:
uv sync --group lab
uv run python -m tools.scanlabFull UI walkthrough: tools/scanlab/README.md.
USBPcap / Wireshark .pcapng recordings used during reverse-engineering (8200i SE
sessions, PPI ladder, bit-depth pairs, etc.) are published separately in
pyopticfilm_captures. Use Scan
Lab Open capture… to decode them offline.
CI runs on Python 3.11–3.13 (lint + tests; no hardware in CI).
Project layout:
src/pyopticfilm/usb/— enumeration, claim, Genesys USB protocol, mock/recording transportssrc/pyopticfilm/asic/— per-ASIC drivers (GL128, GL845, …)src/pyopticfilm/device/— per-model register/geometry tablessrc/pyopticfilm/scan/— geometry, calibration, scan session pipelinetests/scanners/— golden USB traces, SANE log parsertools/scanlab/— PyQt6 bring-up lab (repo only; not in the PyPI package); see tools/scanlab/README.md
GPL-3.0-or-later — see LICENSE.
Register and motor tables for GL845-family models are derived from the SANE genesys backend (see NOTICE and docs/sane-opticfilm.md). The 8200i SE (GL128) protocol was reconstructed from USB traffic captures of the Windows driver and SilverFast; it is not present in SANE.