Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
eef05ed
Add Plustek (USB) scanner backend for OpticFilm 8200i SE and enable t…
jboneng Aug 9, 2026
76a1267
Fixed type error
jboneng Aug 9, 2026
be9b5c9
fix(plustek): use Lab full-window geometry so SE scans clear the moto…
jboneng Aug 9, 2026
5cd8938
fix(plustek): align two-pass IR to RGB with whole-pixel phase correla…
jboneng Aug 9, 2026
eecfbfd
Fixed shear issue after DVDSET rewrite, DVDSET rewrite not complete
jboneng Aug 9, 2026
cdc2c0a
finished up the DVDSET rewrite for RGB scan
jboneng Aug 9, 2026
cd8a29b
Work done on the DVDSET calibration for RGB scans
jboneng Aug 9, 2026
cf4df3b
Host-path scans now clamp Full-window border highlights to the film i…
jboneng Aug 9, 2026
9b6059f
Hide Autofocus and Auto-exposure unless the scanner reports those cap…
jboneng Aug 9, 2026
3601218
Fixed test by mocking pyUSB (since they are a optional import group)
jboneng Aug 9, 2026
c86abba
Bit selection in scanner panel is hidden if selected scanner only sup…
jboneng Aug 9, 2026
0c9dd40
Removed plusteklib mention in change log since I have no idea which r…
jboneng Aug 10, 2026
0114e9b
removed plustek scanner mention in changelog under 0.48.1, since I do…
jboneng Aug 10, 2026
2f006a2
Added pre-scan and pre-scan crop capabilities for the plustek (USB) b…
jboneng Aug 10, 2026
31a26fd
Cleanup and minor fix
jboneng Aug 10, 2026
3bc22d4
New rewrite of the HW ASIC DVDSET calibration for RGB scans
jboneng Aug 11, 2026
74a2ce2
Extract Plustek driver to pyopticfilm on PyPI.
jboneng Aug 12, 2026
a40fe1f
Updated uv lock to version 1.1.1 of pyopticfilm
jboneng Aug 12, 2026
b7fca98
changed the display name of the pyOpticfilm library
jboneng Aug 13, 2026
2925a33
Merge upstream/main into feat/plustek_pyUSB_backend
jboneng Aug 13, 2026
37a71d6
Fix Plustek progress callback to match ScannerBackend phase signature
jboneng Aug 13, 2026
0f85e18
Merge branch 'main' into feat/plustek_pyUSB_backend
jboneng Aug 14, 2026
72dcb15
Merge upstream/main into feat/plustek_pyUSB_backend
jboneng Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ jobs:
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1

- name: Install Dependencies
# pieusb is pure Python plus a bundled libusb, so it installs without any
# system package — without it the pieusb backend tests importorskip away.
run: uv sync --group dev --group pieusb
# pieusb / plustek are pure Python (+ bundled libusb where needed); without
# them those backend tests importorskip away.
run: uv sync --group dev --group plustek --group pieusb

- name: Run Linting (Ruff)
run: uv run ruff check .
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ jobs:
- name: Install Dependencies (Windows)
if: matrix.os == 'windows-latest'
# Everything except SANE, which has no Windows build. `--all-groups` rather
# than the default groups alone, or the pieusb backend ships uninstalled and
# the Scan tab has no working backend at all on Windows.
# than the default groups alone, or pieusb / plustek ship uninstalled and
# the Scan tab has no working backend on Windows.
run: uv sync --all-groups --no-group scanner

- name: Set Version
Expand Down
29 changes: 14 additions & 15 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,20 @@ NegPy requires **Python 3.13+**. We use **uv** for environment and dependency ma
### 1. Prerequisites
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.

**Scanner support (optional):** NegPy's direct scanner integration uses SANE (libsane).

- **Linux** (Debian/Ubuntu):
```bash
sudo pacman -S sane # arch
sudo apt install libsane-dev # debian/ubuntu
```

- **macOS**:
```bash
brew install sane-backends
```

- **Windows**:
Scanner support is not yet available on windows.
**Scanner support (optional):**

- **SANE** (Linux/macOS) — Coolscans and other SANE film scanners:
- **Linux** (Debian/Ubuntu):
```bash
sudo pacman -S sane # arch
sudo apt install libsane-dev # debian/ubuntu
```
- **macOS**:
```bash
brew install sane-backends
```

- **Plustek USB** (Windows/macOS/Linux) — optional [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver for OpticFilm 8200i SE (`uv sync --group plustek` or `pip install negpy[plustek]`). Windows installs `libusb-package` via pyopticfilm (bundled in release builds). On Windows, bind WinUSB with Zadig for USB id `07b3:1825` before scanning (vendor/SilverFast drivers conflict). See [docs/PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md).


### 2. Python Environment
Expand Down
11 changes: 11 additions & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ ICE, for interoperability and research. Digital ICE is a trademark of Eastman
Kodak Company; Nikon, Coolscan and Nikon Scan are trademarks of Nikon
Corporation. Neither openICE nor NegPy is affiliated with or endorsed by them;
the names identify only the formats and hardware being interoperated with.

The Plustek USB driver lives in the separate [pyopticfilm](https://github.com/jboneng/pyopticfilm) package. NegPy integrates it via `negpy/infrastructure/scanners/plustek_backend.py`. That driver includes
material derived from or informed by the SANE Project genesys backend
(Scanner Access Now Easy), GNU GPL:

https://gitlab.com/sane-project/backends

Relevant upstream areas include (non-exhaustive): backend/genesys USB protocol,
register tables, motor/sensor/frontend tables, and image pipeline helpers.
Plustek, OpticFilm, and related names are trademarks of their respective owners;
NegPy is not affiliated with or endorsed by Plustek Inc.
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,7 @@ brew install libgphoto2
1. Run the installer (ignore the warnings)
2. Start the app and click through the warnings.

Scanner and camera scanning are **not available on Windows**. Both rely on Unix-first free-software libraries - SANE for scanners, libgphoto2 for cameras, that just don't build there. It's not really their fault: the open source world spent decades writing generic, vendor-neutral drivers for hundreds of devices, while Windows stuck with closed per-vendor blobs and never grew an equivalent. So the free, open stack NegPy leans on has nowhere to stand on Windows.

Good news: you can install Linux on pretty much any Windows machine. 🐧
**Scanner support (Plustek OpticFilm 8200i SE)** uses the optional `pyopticfilm` driver (`uv sync --group plustek` or `pip install negpy[plustek]`). Bind the scanner to WinUSB with [Zadig](https://zadig.akeo.ie/) (replace the vendor/SilverFast driver for `07b3:1825`). Windows release builds bundle pyopticfilm, PyUSB, and libusb. See [docs/PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md). Only the 8200i SE is scan-ready today. Camera scanning is still unavailable on Windows (libgphoto2 has no Windows build).

---

Expand Down
16 changes: 15 additions & 1 deletion build.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,28 @@ def get_macos_target_arch():
# Camera scanning: see collect_gphoto2_plugins() — the plugin trees need their
# directory layout preserved, which --collect-all does not do.
*([] if is_windows else ["--collect-all=gphoto2"]),
# pieusb scanner support (all platforms; it is the only backend on Windows).
# pieusb scanner support (all platforms).
# libusb_package's own PyInstaller hook drops the bundled libusb at the bundle
# root, but get_library_path() resolves it with importlib_resources against the
# *package* directory — so without this it finds nothing and pyusb falls back to
# a system libusb that Windows does not have. --collect-all puts it where the
# lookup actually looks.
"--hidden-import=pieusb",
"--collect-all=libusb_package",
# Plustek / pyopticfilm: ship PyUSB + bundled libusb on Windows only.
# Linux/macOS use host libusb via PyUSB (same stack SANE needs).
*(
[
"--hidden-import=usb",
"--hidden-import=usb.core",
"--hidden-import=usb.backend.libusb1",
"--hidden-import=pyopticfilm",
"--collect-all=usb",
"--collect-all=pyopticfilm",
]
if is_windows
else []
),
# Exclude unused modules
# Metadata
"--copy-metadata=imageio",
Expand Down
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
- New: **Local Grade per dodge/burn mask** — each mask can print at its own contrast, in ISO-R points off the frame's Grade. Burn a sky in through a harder filter.
- Change: **Dodge/burn now in stops** — the mask slider is "Burn", ±2 stops, default 0; positive burns, negative dodges. Old saves migrate.
- Fix: **Slider tooltips work on the label too** — hovering a slider's title showed only "double-click to reset" instead of what the control does. Both halves now show the full text.

git a
## 0.48.0

- Change: **Calibration section** — the sensor unmix, the crosstalk matrix and Hue Trim now sit together, out of Process, since all three correct the capture rather than the negative-to-positive conversion. Crosstalk is reframed as a property of a whole scanning setup rather than of a film stock; the bundled matrices are marked "(approx)" and grouped by provenance, and "Default" is now "Generic C41".
Expand Down
71 changes: 71 additions & 0 deletions docs/PLUSTEK_WINDOWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Windows USB setup (Plustek OpticFilm)

NegPy’s Plustek USB backend uses the external [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (libusb via PyUSB). The stock Plustek Windows driver must not own the device.

## Requirements

- Windows 10/11
- OpticFilm **8200i SE** (`07B3:1825`, GL128) — the only model validated for scan
- WinUSB (or libusbK) bound via [Zadig](https://zadig.akeo.ie/)
- NegPy with the `plustek` optional dependency (`uv sync --group plustek` or `pip install negpy[plustek]`); Windows also pulls `libusb-package` via pyopticfilm

## 1. Confirm the device

With the scanner powered and plugged in:

1. Open **Device Manager**
2. Look under imaging / USB devices for Plustek
3. Properties → Details → Hardware Ids should include `VID_07B3&PID_1825`

## 2. Bind WinUSB (Zadig)

1. Download [Zadig](https://zadig.akeo.ie/)
2. **Options → List All Devices**
3. Select the Plustek Film Scanner (`07B3:1825`)
4. Replace the driver with **WinUSB** (libusbK also works)
5. Keep the stock Plustek driver installer available if you need to restore VueScan/vendor software later

While WinUSB is bound, Plustek’s stock Windows scanning apps will not see the device.

## 3. Run NegPy and scan

From a source checkout:

```powershell
cd path\to\NegPy
uv sync --group plustek
make run
```

Or use a Windows release build (pyopticfilm, PyUSB, and libusb are bundled). In the Scan tab, Backend should be **pyOpticfilm (Plustek)**. Refresh the device list; the SE should appear when WinUSB is bound.

## 4. Restoring the vendor driver

1. Unplug the scanner
2. Device Manager → uninstall the WinUSB device (check “delete driver software” if offered)
3. Reinstall the Plustek / VueScan driver package
4. Replug the scanner

## Troubleshooting

| Symptom | Likely cause |
|---------|----------------|
| Empty device list | Wrong PID, unplugged, or still on vendor driver |
| `DriverBindingError` / access denied | WinUSB not bound; another app has the handle |
| `UsbError` / link failures | Cable/hub; try a direct motherboard port |
| UI hints at missing USB / PyUSB | Install the plustek group: `uv sync --group plustek` |
| First scan at a DPI takes a few seconds | Normal — AFE + one dark + one white shading strip (same choreography as SilverFast), then cached per resolution |
| ASIC shading ready (preferred) | Log shows a colour white mean ~50–57k and `median_gain` ~1.1–1.7x, then `ASIC shading ready`, image `shading=True` / DVDSET on. Delete `plustek_calib` and rescan after driver changes so calib is remeasured |
| Colour white mean ~50–57k | Normal. At unity gain DVDSET returns `raw − dark`, so a bright strip reads near full scale. The 11–13k figures in the captures are the *gains* SilverFast computed from it (`0x2000` = 1.0), not white levels |
| Median gain outside 1.02–3.0x | Below the band the strip is already at target (nothing to flatten); above it the light path is too dim — check the lamp, the AFE gains, and that the head sits on clear home chrome |
| Carriage not at home after shading / scan abort | White strip arms `AGOHOME` during the measure; clearing SCAN parks. If park timed out, use SilverFast or power-cycle, then retry |
| Darker corners than SilverFast on Full window | Full window includes ~0.8 mm holder chrome; shading is per-column only, so residual Y falloff at those edges is expected vs a tighter SilverFast frame |
| Positive very dark until you crop (white edges on negative) | Holder chrome is brighter than film base; NegPy auto bounds latch onto it. Both paths clamp border highlights to the film inset (`border highlight clamp…`) — on the ASIC path this is essential, because DVDSET maps that same chrome to full scale by construction. Also raise Process → **Analysis Buffer**, or crop before auto |
| Rainbow vertical “barcode” stripes | The uploaded table was indexed differently from the image. Either the measurement went in the gain slot (gain must be `0xFFFF × 0x2000 / white`, which is proportional to `1/white`), or the blob was packed without its block padding — the AHB table is 512-byte blocks of 126 `(dark, gain)` pairs plus two `gain = 0` pad pairs, so contiguous records slide 8 bytes per block. Delete `plustek_calib` and rescan |
| Diamond / sheared scene (objects lean) | (1) Image X shrunk while USB still paced the full line — the shading table must cover every acquired column (Full window @1800 stays ~2592 px). (2) Odd crop USB width at 1800 (e.g. 2455) — output width must stay even (`optical_span_alignment` + even pixel count). Delete `plustek_calib` (cache v9+) and rescan — log should show even `pixels=` |
| Negative very dark / positive washed bright (vs SilverFast) | Both paths reference *home* chrome, which is brighter than the light at the scan position — at 1800 dpi the film base lands near 42% of full scale, ~1.2 stops down, and NegPy meters a thin negative. `expose_film_base` lifts it with one scalar gain keyed to the brightest channel (`… exposure makeup gain=…`); the gain must stay scalar or it neutralizes the orange mask that inversion needs |
| Strong orange/pink cast on the positive | Check `AFE search done gains=…` for a channel at or near `AFE_GAIN_MAX` (511). At the rail that channel's dark term clips to 0 (`dark0=(0, …)`), so it loses its blacks and tints the whole frame. The search now substitutes SF's session-04 code for any pegged channel; a persistent peg means the AFE gain target is unreachable — the target is an *AFE-strip* level, not a shading white, so do not raise it to match SF's ~50k probe mean |
| Strong green cast on the positive | The border clamp ceiling must be **per channel**. A single joint percentile flattens the margin to neutral grey at a level the dimmest channel never reaches in the film (session 004: joint 27432 vs green's own 19306), so auto Dmin meters green off chrome and lifts it 1.4x. Check `border highlight clamp peak_p99.7=(r,g,b)` — each figure should sit just above that channel's own film peak |
| `white clipped at the rail` | The white strip is pinned near `0xFFFF`, so it carries no shape to flatten — lower the AFE gains. DVDSET stays off and host stretch runs |
| Scan fails: white mean < 20000 | The post-unity strip is too dim to reach target within the 4x gain clamp. Often a stale AHB strip (dark/AFE wait until the buffer has data **at home**; motor-busy `0xa5` is not ready there). Also check the lamp is actually off for dark and the head is on clear home chrome. Cache v6+ ignores collapsed calib |
| Scan fails: colour ASIC shading / clear home field | Carriage not on the clear home sensor; park/power-cycle, then retry (film may stay loaded) |
8 changes: 5 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -772,10 +772,12 @@ When you set capture gear, it's written to standard EXIF and the digitizing rig

## 13. Scan tab

Capture film directly into NegPy (Linux and macOS; unavailable on Windows). Two collapsible sections:
Capture film directly into NegPy. Two collapsible sections:

* **Scanner (SANE)**: drive a supported flatbed/film scanner over SANE. Common controls: backend/device selection, DPI, bit depth, IR channel, autofocus, hardware auto-exposure, frame range (roll feeders), scan window, output format, folder and filename template. When the connected scanner exposes a SANE `scan-exposure-time` option (e.g. some genesys devices), an **Exposure** slider appears below Auto-exposure — set it to override the scanner's default exposure time; the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.
* **Camera Scanning**: DSLR/mirrorless copy-stand capture. Auto-connects the camera over USB (PC-Remote mode). With a NegPy **Scanlight** connected it captures narrowband R/G/B triplets from saved film-stock presets; without one it does a single white-light exposure. A **Live View** window helps you frame and focus; captured frames land in the hot folder and flow straight into RGB-Scan mode.
* **Scanner**: drive a film scanner. Choose a **Backend** — **SANE** (Linux/macOS; Coolscans and other SANE devices) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE; Windows, macOS and Linux). Common controls: device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **Depth** appears only when the device offers more than one bit depth (hidden for the OpticFilm 8200i SE, which is 16-bit only). **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them (typical Coolscans; not the OpticFilm 8200i SE). **Prescan** appears for devices that support a low-DPI full-window preview (OpticFilm 8200i SE): run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option (e.g. some genesys devices), an **Exposure** slider appears — set it to override the scanner's default exposure time; the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.

**pyOpticfilm (Plustek)** notes: only the **OpticFilm 8200i SE** (`07b3:1825`) is scan-ready. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (hardware ROI, not a software crop). IR is a second USB pass (colour then infrared); NegPy registers that IR plane to the colour frame with a whole-pixel shift so dust removal stays aligned after the carriage re-homes. Colour scans apply ASIC shading measured at home before the film feed (same order as SilverFast; the strip may stay loaded). The table is cached per DPI so later scans only re-upload it. The default Full window includes a little holder chrome top and bottom; host-path scans clamp those near-white margins to the film highlight so auto exposure is not skewed (raise **Analysis Buffer** or crop if a frame still looks off). Autofocus and hardware Auto-exposure controls stay hidden — the SE does not report those capabilities. On Windows, bind the device to **WinUSB** with Zadig before use (the stock vendor/SilverFast driver conflicts). The driver is the optional **pyopticfilm** package — install with `uv sync --group plustek` or `pip install negpy[plustek]` (Windows release builds bundle it). See [PLUSTEK_WINDOWS.md](PLUSTEK_WINDOWS.md).
* **Camera Scanning**: DSLR/mirrorless copy-stand capture (macOS/Linux). Auto-connects the camera over USB (PC-Remote mode). With a NegPy **Scanlight** connected it captures narrowband R/G/B triplets from saved film-stock presets; without one it does a single white-light exposure. A **Live View** window helps you frame and focus; captured frames land in the hot folder and flow straight into RGB-Scan mode.

Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md).

Expand Down
Loading
Loading