pieusb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pieusb-0.1.0/.gitignore +218 -0
- pieusb-0.1.0/.python-version +1 -0
- pieusb-0.1.0/DESIGN.md +352 -0
- pieusb-0.1.0/LICENSE +674 -0
- pieusb-0.1.0/PKG-INFO +57 -0
- pieusb-0.1.0/README.md +41 -0
- pieusb-0.1.0/TODO.md +270 -0
- pieusb-0.1.0/pyproject.toml +28 -0
- pieusb-0.1.0/scan_rgb.npy +0 -0
- pieusb-0.1.0/src/pieusb/__init__.py +17 -0
- pieusb-0.1.0/src/pieusb/_device.py +37 -0
- pieusb-0.1.0/src/pieusb/exceptions.py +91 -0
- pieusb-0.1.0/src/pieusb/inquiry.py +158 -0
- pieusb-0.1.0/src/pieusb/option.py +515 -0
- pieusb-0.1.0/src/pieusb/postprocess.py +131 -0
- pieusb-0.1.0/src/pieusb/scanner.py +489 -0
- pieusb-0.1.0/src/pieusb/transport.py +263 -0
- pieusb-0.1.0/src/pieusb/types.py +114 -0
- pieusb-0.1.0/src/pieusb/usb_utils.py +35 -0
- pieusb-0.1.0/tests/exercise.py +451 -0
- pieusb-0.1.0/uv.lock +96 -0
pieusb-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
pieusb-0.1.0/DESIGN.md
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# pieusb — API & module design
|
|
2
|
+
|
|
3
|
+
Design notes for `pieusb`, a Python library for USB-connected Reflecta (a.k.a. PacificImage)
|
|
4
|
+
film/dia scanners.
|
|
5
|
+
|
|
6
|
+
Earlier revisions of this document aimed at a [python-sane](https://github.com/python-pillow/Sane)
|
|
7
|
+
lookalike. That goal has been dropped. python-sane's surface carries a lot of shape that exists
|
|
8
|
+
for SANE's sake rather than the user's — `init()`/`exit()` lifecycle calls, device name strings
|
|
9
|
+
that have to be parsed back into devices, a `start()`/`snap()` split that this hardware does not
|
|
10
|
+
actually stream across, `get_parameters()` returning a five-tuple, PIL conversions that cannot
|
|
11
|
+
represent a 4-plane RGBI scan. **The API is instead reduced to the smallest thing that does the
|
|
12
|
+
job**: enumerate, configure, scan.
|
|
13
|
+
|
|
14
|
+
The internal *layering* still follows the SANE C backend (`backend/pieusb*.c`), which remains the
|
|
15
|
+
authoritative reference for everything on the wire. Only the public surface changes.
|
|
16
|
+
|
|
17
|
+
## Target usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import pieusb
|
|
21
|
+
|
|
22
|
+
for info in pieusb.available_devices():
|
|
23
|
+
print(info.vendor, info.model)
|
|
24
|
+
|
|
25
|
+
scanner = pieusb.Scanner(pieusb.available_devices()[0])
|
|
26
|
+
scanner.mode = "rgbi"
|
|
27
|
+
scanner.resolution = 5000
|
|
28
|
+
scanner.color_depth = 16
|
|
29
|
+
scanner.tl_x, scanner.tl_y = 0, 0
|
|
30
|
+
scanner.br_x, scanner.br_y = 14200, 9600
|
|
31
|
+
|
|
32
|
+
def on_progress(scanned_lines, total_lines, chunk, plane):
|
|
33
|
+
print(f"{scanned_lines}/{total_lines}")
|
|
34
|
+
|
|
35
|
+
def on_complete(result):
|
|
36
|
+
if result.error:
|
|
37
|
+
raise result.error
|
|
38
|
+
numpy.save("scan.npy", result.image)
|
|
39
|
+
|
|
40
|
+
scanner.scan(progress=on_progress, scan_complete=on_complete) # returns immediately
|
|
41
|
+
scanner.wait() # optional
|
|
42
|
+
scanner.close()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A scan takes minutes at high resolution, so `scan()` is **non-blocking**: it validates the
|
|
46
|
+
options, starts a worker thread and returns. Callers who want the blocking behaviour call
|
|
47
|
+
`wait()`.
|
|
48
|
+
|
|
49
|
+
## Public API
|
|
50
|
+
|
|
51
|
+
The entire exported surface:
|
|
52
|
+
|
|
53
|
+
| Name | Role |
|
|
54
|
+
|------|------|
|
|
55
|
+
| `available_devices() -> list[DeviceInfo]` | Enumerate connected scanners and INQUIRY each one |
|
|
56
|
+
| `Scanner(info: DeviceInfo)` | Open a device; options as attributes; `scan()`/`cancel()`/`wait()`/`close()` |
|
|
57
|
+
| `DeviceInfo`, `InquiryResponse` | What enumeration hands back |
|
|
58
|
+
| `ScanResult`, `ScanPhase` | What a finished scan reports |
|
|
59
|
+
| `PieusbError` and subclasses | Errors — see [Exceptions](#exceptions) |
|
|
60
|
+
|
|
61
|
+
Deliberately **not** exposed: `init()`, `exit()`, `open()`, device-name strings, `start()`/`snap()`,
|
|
62
|
+
`get_parameters()`, `multi_scan()`, `fileno()`, PIL conversion.
|
|
63
|
+
|
|
64
|
+
### `available_devices()`
|
|
65
|
+
|
|
66
|
+
Named after pyserial's `comports()` idiom: one call that both finds devices and describes them.
|
|
67
|
+
It returns `DeviceInfo` objects, each holding the live `usb.core.Device` alongside the parsed
|
|
68
|
+
`InquiryResponse`, so a caller can pick one by model, capability, or index and hand it straight
|
|
69
|
+
to `Scanner`. There is no name string and therefore nothing to parse back into a device.
|
|
70
|
+
|
|
71
|
+
This replaces `get_devices()`.
|
|
72
|
+
|
|
73
|
+
### `Scanner`
|
|
74
|
+
|
|
75
|
+
Constructed directly from a `DeviceInfo` — no `open()` indirection, since `DeviceInfo` already
|
|
76
|
+
carries everything needed. The constructor opens the USB interface and builds the option table
|
|
77
|
+
from the device's own INQUIRY response.
|
|
78
|
+
|
|
79
|
+
- **Options are attributes.** `scanner.resolution = 5000` goes through `__setattr__`, which
|
|
80
|
+
type-checks and validates against the constraints derived from INQUIRY, raising `TypeError` /
|
|
81
|
+
`ValueError` immediately on a bad value. Reading an option returns its current value.
|
|
82
|
+
`scanner.options` exposes the underlying table for introspection (name, type, unit, default).
|
|
83
|
+
Setting an option while a scan is running raises `ScanInProgress`.
|
|
84
|
+
- **Nothing reaches the device until `scan()`.** Options are host-side state. This mirrors the C
|
|
85
|
+
backend exactly: `sane_control_option()` only writes into `scanner->val[]`, and `sane_start()`
|
|
86
|
+
(`pieusb.c:865`) pushes the whole configuration to the device in one sequence.
|
|
87
|
+
- **`scanner.phase -> ScanPhase`** is readable at any time, so a caller can tell warm-up from
|
|
88
|
+
calibration from the pixel read without waiting for a callback.
|
|
89
|
+
- **`scanner.scanning -> bool`**.
|
|
90
|
+
|
|
91
|
+
#### Methods
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
def ready(self) -> bool
|
|
95
|
+
def wait_ready(self, timeout_s: float = 180) -> None
|
|
96
|
+
def scan(self, progress=None, scan_complete=None) -> None
|
|
97
|
+
def cancel(self) -> None
|
|
98
|
+
def wait(self, timeout: float | None = None) -> bool
|
|
99
|
+
def close(self) -> None
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- **`ready()`** asks the device whether it can start a scan right now, via TEST UNIT READY.
|
|
103
|
+
Returns a plain `True`/`False` — every NOT_READY sense maps to `False`, including warm-up. Only
|
|
104
|
+
transport-level failures raise. It is a convenience predicate; `scan()` does its own check and
|
|
105
|
+
does not require you to call this first.
|
|
106
|
+
- **`wait_ready()`** polls `ready()` until the device is ready or the timeout expires, raising
|
|
107
|
+
`WarmingUp` if it was still warming up when time ran out and `DeviceNotReady` otherwise.
|
|
108
|
+
- **`scan()`** checks readiness, validates the whole option table, then starts a worker thread
|
|
109
|
+
and returns immediately. It raises rather than starting a scan that cannot succeed:
|
|
110
|
+
- `WarmingUp` — the lamp is still warming up. Retryable; the caller decides whether to
|
|
111
|
+
`wait_ready()` and try again, or report it and move on.
|
|
112
|
+
- `DeviceNotReady` — the device is reachable but cannot scan (including the case where the
|
|
113
|
+
device itself reports a scan already active, `Pieusb_Scanner_State.scanning`).
|
|
114
|
+
- `ScanInProgress` — *this* `Scanner` already has a worker running. Host-side state, distinct
|
|
115
|
+
from the device-side conditions above.
|
|
116
|
+
- `ValueError` / `TypeError` — invalid option combination.
|
|
117
|
+
|
|
118
|
+
All of these are raised on the *calling* thread before the worker starts, so a scan that cannot
|
|
119
|
+
begin fails at the call site rather than arriving in a callback.
|
|
120
|
+
- **`cancel()`** requests a stop and returns immediately. It does **not** touch the device: it
|
|
121
|
+
sets a flag the worker checks between chunk reads, and the worker issues STOP SCAN and then
|
|
122
|
+
fires `scan_complete` with `result.cancelled` set. Cancellation therefore takes effect at the
|
|
123
|
+
next chunk boundary (≤255 lines), not instantly. Calling `cancel()` when no scan is running is
|
|
124
|
+
a no-op.
|
|
125
|
+
- **`wait()`** blocks until the scan finishes, is cancelled or fails; returns `True` if it
|
|
126
|
+
finished within the timeout.
|
|
127
|
+
- **`close()`** cancels any running scan, waits for the worker, then releases the interface.
|
|
128
|
+
|
|
129
|
+
`__exit__` does exactly what `close()` does. This matters: a `with` block that exits while a scan
|
|
130
|
+
is in flight must not release the USB interface out from under the worker thread.
|
|
131
|
+
|
|
132
|
+
`ready()` and `wait_ready()` talk to the device, so they raise `ScanInProgress` while a worker is
|
|
133
|
+
running — see the threading contract below.
|
|
134
|
+
|
|
135
|
+
## Exceptions
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
PieusbError
|
|
139
|
+
├── TransportError USB/SCSI transaction failed below the command layer
|
|
140
|
+
├── Timeout a command, or a wait for the device, did not complete in time
|
|
141
|
+
├── CheckCondition raw SCSI CHECK CONDITION with decoded sense data
|
|
142
|
+
├── DeviceNotReady device is reachable but cannot start a scan
|
|
143
|
+
│ └── WarmingUp ... specifically because the lamp is warming up (retryable)
|
|
144
|
+
├── ParamError option values that are individually valid but conflict
|
|
145
|
+
└── ScanInProgress host-side: this Scanner already has a worker running
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`ParamError` is what whole-table validation raises — the cross-option checks that per-option
|
|
149
|
+
validators structurally cannot express, such as `tl_x < br_x`. It is distinct from the
|
|
150
|
+
`TypeError`/`ValueError` a single bad assignment raises, because it can only be detected once the
|
|
151
|
+
whole table is known.
|
|
152
|
+
|
|
153
|
+
`CheckCondition` is what the *transport* layer raises: it reports what the device said and
|
|
154
|
+
nothing more, exposing `not_ready` / `warming_up` / `must_calibrate` predicates over the sense
|
|
155
|
+
triple. Translating a sense into `WarmingUp` or `DeviceNotReady` is the `Scanner`'s job, since
|
|
156
|
+
that is an interpretation rather than a property of the transaction — and because
|
|
157
|
+
`DeviceNotReady` also covers non-sense sources such as `Pieusb_Scanner_State.scanning`. The C
|
|
158
|
+
backend splits it the same way: `sanei_pieusb_command()` returns a status, `sane_start()` decides
|
|
159
|
+
what it means.
|
|
160
|
+
|
|
161
|
+
`WarmingUp` subclasses `DeviceNotReady` so that `except DeviceNotReady` catches "cannot scan"
|
|
162
|
+
generally, while a caller who wants to retry can catch `WarmingUp` specifically. That hierarchy
|
|
163
|
+
mirrors the wire: both are SCSI sense key `NOT_READY` (0x02), and warm-up is the single case
|
|
164
|
+
distinguished by code 0x04 / qualifier 0x01 — `sanei_pieusb_decode_sense` maps exactly that
|
|
165
|
+
triple to `PIEUSB_STATUS_WARMING_UP` and every other `NOT_READY` to a generic failure
|
|
166
|
+
(`pieusb_usb.c:387-397`).
|
|
167
|
+
|
|
168
|
+
`ScanInProgress` is deliberately *not* under `DeviceNotReady`: it describes this library's own
|
|
169
|
+
state, not the scanner's, and the two want different handling — one is "wait and retry", the
|
|
170
|
+
other is "you have a bug". It is also why the name avoids "busy", which on this hardware already
|
|
171
|
+
means the SCSI `STATUS_BUSY` code.
|
|
172
|
+
|
|
173
|
+
### Warm-up is handled in two different places, on purpose
|
|
174
|
+
|
|
175
|
+
- **Before the worker starts**, `scan()` refuses and raises `WarmingUp`. The caller is still on
|
|
176
|
+
the stack and can decide: wait, retry, tell the user, give up.
|
|
177
|
+
- **After the worker starts**, at START SCAN, the worker retries with a bounded backoff rather
|
|
178
|
+
than failing. There is no caller left to decide, and the C backend does the same
|
|
179
|
+
(`pieusb.c:1088-1093`, retrying while the status is `WARMING_UP`). If the retries are
|
|
180
|
+
exhausted, it surfaces as `result.error = WarmingUp` through `scan_complete`.
|
|
181
|
+
|
|
182
|
+
Both references also check up front: `sane_start()` reads the device state and returns
|
|
183
|
+
immediately if `state.warmingUp` (`pieusb.c:908-921`), and the PoC calls `wait_ready()` before it
|
|
184
|
+
does anything else.
|
|
185
|
+
|
|
186
|
+
### Threading contract
|
|
187
|
+
|
|
188
|
+
`scan()` is non-blocking, which makes threading part of the public contract rather than an
|
|
189
|
+
implementation detail:
|
|
190
|
+
|
|
191
|
+
- **Both callbacks run on the worker thread**, not the caller's. A GUI caller must marshal to its
|
|
192
|
+
own event loop; a callback that blocks stalls the read loop and, on a slow enough callback, can
|
|
193
|
+
stall it long enough for the device to time out.
|
|
194
|
+
- **All device access stays on the worker thread.** pyusb/`UASDevice` is not thread-safe, which
|
|
195
|
+
is why `cancel()` is flag-based rather than issuing STOP SCAN itself.
|
|
196
|
+
- **Exceptions never propagate to the caller** once the worker has started. Anything raised
|
|
197
|
+
inside the scan is caught, STOP SCAN is attempted, and it is delivered as `result.error`. A
|
|
198
|
+
caller that only registers `progress` and never `scan_complete` will therefore *silently* miss
|
|
199
|
+
failures — `wait()` returning is not evidence of success. Check `result.error`.
|
|
200
|
+
- One worker at a time per `Scanner`; a second `scan()` raises `ScanInProgress`.
|
|
201
|
+
|
|
202
|
+
### `progress` callback
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
def progress(scanned_lines: int, total_lines: int, chunk: numpy.ndarray, plane: int) -> None
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `scanned_lines` / `total_lines` count raw scanner lines across the whole read, so
|
|
209
|
+
`total_lines == planes * height`. They reach equality exactly once, at the end.
|
|
210
|
+
- `chunk` is the lines read since the previous call, shape **`(n_lines, pixels_per_line)`**,
|
|
211
|
+
already shading-corrected, dtype matching `color_depth`.
|
|
212
|
+
- `plane` is which channel those lines belong to: `0=R, 1=G, 2=B, 3=I`.
|
|
213
|
+
|
|
214
|
+
**Why `chunk` is single-plane rather than an image strip.** The scanner does not return
|
|
215
|
+
interleaved rows. It returns the planes as sequential blocks — all R lines, then all G, and so on
|
|
216
|
+
— each line prefixed by a two-byte channel tag (`poc:794-818`). So the lines arriving at any
|
|
217
|
+
moment belong to *one* channel, and a chunk of them cannot be assembled into an
|
|
218
|
+
`(n_lines, width, planes)` strip; the other channels for those rows do not exist yet and will not
|
|
219
|
+
until much later in the scan. Delivering `(n_lines, width)` plus a `plane` index is the only
|
|
220
|
+
shape that is both honest about what arrived and useful for a live preview.
|
|
221
|
+
|
|
222
|
+
The 255-line read cap does not align to plane boundaries, so a single device read can straddle a
|
|
223
|
+
channel transition. The library splits such a read by tag and calls `progress` once per plane
|
|
224
|
+
run, so a chunk is always single-plane. This means the callback may fire twice for one device
|
|
225
|
+
read, and `n_lines` varies.
|
|
226
|
+
|
|
227
|
+
### `scan_complete` callback
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
def scan_complete(result: ScanResult) -> None
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Fires exactly once per `scan()`, on every outcome — success, cancellation and failure — so it is
|
|
234
|
+
the single place teardown belongs.
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class ScanResult:
|
|
239
|
+
image: numpy.ndarray | None # None if cancelled or failed
|
|
240
|
+
width: int # pixels per line, as reported by GET PARAMETERS
|
|
241
|
+
height: int # lines per plane
|
|
242
|
+
planes: int # 4 for rgbi, 3 for rgb, 1 for gray
|
|
243
|
+
mode: str
|
|
244
|
+
color_depth: int # 8 or 16
|
|
245
|
+
resolution: int # dpi actually used
|
|
246
|
+
shading_corrected: bool
|
|
247
|
+
cancelled: bool
|
|
248
|
+
error: Exception | None
|
|
249
|
+
duration_s: float
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`width`/`height` come from the device's own GET PARAMETERS mid-scan, not from the requested
|
|
253
|
+
frame, and can differ from what the options asked for — which is why they are reported rather
|
|
254
|
+
than left for the caller to infer. On a cancelled scan `image` is `None` and the partial data is
|
|
255
|
+
discarded; assembling a half-read image would mean returning planes with no data in them.
|
|
256
|
+
|
|
257
|
+
The parameters were collapsed into one `ScanResult` rather than a long positional signature: it
|
|
258
|
+
keeps the callback stable as fields are added, and it is the same frozen-dataclass style the rest
|
|
259
|
+
of the package uses.
|
|
260
|
+
|
|
261
|
+
### Return value
|
|
262
|
+
|
|
263
|
+
`ScanResult.image` is a numpy array of shape **`(lines, pixels_per_line, planes)`** — i.e.
|
|
264
|
+
`(height, width, planes)`, the standard numpy image convention. `image[y, x, c]` indexes row,
|
|
265
|
+
column, channel.
|
|
266
|
+
|
|
267
|
+
- Plane order is **R, G, B, I**, resolved from the per-line channel tag, not from arrival order.
|
|
268
|
+
- `mode="rgbi"` gives 4 planes, `"rgb"` gives 3, `"gray"` gives 1.
|
|
269
|
+
- dtype is `uint8` at `color_depth=8`, `uint16` at 16.
|
|
270
|
+
- Pixels are flat-field (shading) corrected. Infrared dust removal is a separate, later concern.
|
|
271
|
+
|
|
272
|
+
Returning raw numpy rather than PIL is the honest choice here: a 4-plane RGBI scan has no PIL
|
|
273
|
+
mode, and 16-bit multi-channel is awkward in PIL regardless. Callers who want a PIL image can
|
|
274
|
+
convert the RGB planes themselves.
|
|
275
|
+
|
|
276
|
+
## Module structure
|
|
277
|
+
|
|
278
|
+
Split along the C backend's module boundaries.
|
|
279
|
+
|
|
280
|
+
| File | Role | C-backend analog | State |
|
|
281
|
+
|------|------|------------------|-------|
|
|
282
|
+
| `__init__.py` | Public API: `available_devices()`, re-export `Scanner`, types, exceptions | `pieusb.c` | partial |
|
|
283
|
+
| `scanner.py` | **`Scanner`** — options-as-attributes, `scan()`/`cancel()`/`wait()`/`close()`, the worker thread and its state machine | `pieusb_specific.c` | partial |
|
|
284
|
+
| `options.py` | `Option`/`Parameter`/`OptionsTable`, the table generator, whole-table validation | (SANE opt descriptors) | exists as `option.py` |
|
|
285
|
+
| `commands.py` | Typed SCSI wrappers: `inquiry`, `set_mode`, `set_scan_frame`, `set_exposure`, `set_highlight_shadow`, `get`/`set_gain_offset`, `get_shading_parms`, `start_scan`, `get_ccd_mask`, `get_scan_parameters`, `get_scanned_lines` | `pieusb_scancmd.c` | scattered in `option.py` |
|
|
286
|
+
| `transport.py` | **`UASDevice`** — SCSI-over-USB transaction layer | `pieusb_usb.c` | done |
|
|
287
|
+
| `protocol.py` | Opcodes, ports, statuses, MODE SELECT constants | (headers) | inline in `transport.py`/`option.py` |
|
|
288
|
+
| `usb.py` | libusb backend + enumeration | `pieusb_usb.c` | exists as `usb_utils.py` |
|
|
289
|
+
| `inquiry.py` | `InquiryResponse` parsing + model/VID-PID tables | `pieusb_specific.c` | done |
|
|
290
|
+
| `postprocess.py` | Shading correction, (later) IR dust removal. Deinterleave by line tag stayed in the worker, which decodes each chunk as it arrives rather than buffering the whole raw read | `pieusb_buffer.c`, `sanei_pieusb_post` | partial |
|
|
291
|
+
| `types.py` | Enums + dataclasses, incl. `ScanResult`/`ScanPhase` | — | partial |
|
|
292
|
+
| `exceptions.py` | `PieusbError` base; `DeviceNotReady`, `WarmingUp`, `CheckCondition`, `TransportError`, `Timeout`, `ScanInProgress`, `ParamError` | — | done |
|
|
293
|
+
|
|
294
|
+
`_device.py` folds into `__init__.py` + `inquiry.py`.
|
|
295
|
+
|
|
296
|
+
## What the worker does
|
|
297
|
+
|
|
298
|
+
The command sequence is `sane_start()` (`pieusb.c:865-1140`), which is the authoritative order.
|
|
299
|
+
Steps 3 and 8 are reads, not writes — the configuration push is not purely one-directional, and
|
|
300
|
+
`pixels_per_line` from step 3 sizes both the shading read and the CCD mask later.
|
|
301
|
+
|
|
302
|
+
Step 4 runs on the calling thread inside `scan()`, before the worker starts. Everything from
|
|
303
|
+
step 1 onward runs on the worker. The worker checks the cancel flag before each chunked read in
|
|
304
|
+
steps 11 and 12.
|
|
305
|
+
|
|
306
|
+
| Phase (`ScanPhase`) | Steps |
|
|
307
|
+
|---|---|
|
|
308
|
+
| `CONFIGURING` | 1. Refuse if already scanning; READ STATE and check warming-up.<br>2. SET EXPOSURE TIME, SET HIGHLIGHT SHADOW.<br>3. **GET SHADING PARMS** → `pixels_per_line`, `n_lines`.<br>4. **Validate the option table as a whole.**<br>5. SET SCAN FRAME.<br>6. `cmd_17`, on slide-transport models that support it.<br>7. GET GAIN OFFSET → SET GAIN OFFSET (read-modify-write).<br>8. SET MODE.<br>9. SLIDE INIT, on slide-transport models. |
|
|
309
|
+
| `WARMING_UP` | 10. START SCAN, retrying while the device reports warming up. |
|
|
310
|
+
| `CALIBRATING` | 11. Read the shading reference, then the CCD mask, then GET PARAMETERS for authoritative width/lines. |
|
|
311
|
+
| `SCANNING` | 12. Read `planes × height` tagged lines in ≤255-line chunks, splitting each read by channel tag, shading-correcting it and firing `progress`. |
|
|
312
|
+
| `PROCESSING` | 13. Assemble the planes into the final array. |
|
|
313
|
+
|
|
314
|
+
Step 4 is where the cross-option checks live that per-option validators structurally cannot
|
|
315
|
+
express: `tl < br` on both axes, the frame within the device's reported bed, `sharpen` versus
|
|
316
|
+
`fast_infrared`. The C backend does the same, in `sanei_pieusb_analyse_options` at the same point
|
|
317
|
+
in the sequence. Getting the frame wrong has already caused one carriage crash in this project,
|
|
318
|
+
so this must abort before the device is touched — hence running it on the calling thread, where
|
|
319
|
+
it can raise.
|
|
320
|
+
|
|
321
|
+
Note that the C backend performs the entire read inside `sane_start()`; `sane_read()` only copies
|
|
322
|
+
out of an already-filled buffer (`pieusb.c:1363`). There is no streaming mode in this hardware,
|
|
323
|
+
which is the other reason a `start()`/`snap()` split buys nothing here.
|
|
324
|
+
|
|
325
|
+
## Open design questions
|
|
326
|
+
|
|
327
|
+
- **Nothing reports progress before the pixel read.** `progress` only fires during `SCANNING`,
|
|
328
|
+
because it is defined in terms of image lines. But `CONFIGURING` through `CALIBRATING` includes
|
|
329
|
+
lamp warm-up, which can take minutes on a cold scanner, and `scanner.phase` has to be polled to
|
|
330
|
+
observe it. If that turns out to be inadequate, the options are a separate `on_phase` callback
|
|
331
|
+
or a phase argument on `progress`.
|
|
332
|
+
- **Frame coordinate units.** The wire format is native-resolution units (10000 dpi on the
|
|
333
|
+
ProScan 10T), independent of the requested scan dpi. The option table currently exposes those
|
|
334
|
+
raw units. Exposing millimetres instead would be friendlier and is what the C backend's options
|
|
335
|
+
do, at the cost of a conversion layer. Unresolved; see `TODO.md` C-14.
|
|
336
|
+
- **`gray` mode.** It maps to `SCAN_COLOR_FORMAT_PIXEL`, so the returned data carries no per-line
|
|
337
|
+
channel tags and the tag-based deinterleave does not apply. `progress`'s `plane` argument is
|
|
338
|
+
meaningless there. Either handle both layouts or reject `gray` until it is implemented.
|
|
339
|
+
- **Auto-exposure / preview pass.** The `auto_exp` option exists but nothing implements it. The C
|
|
340
|
+
backend derives gain/offset from a preview scan (`OPT_CALIBRATION_MODE`); that is a second
|
|
341
|
+
acquisition inside the worker, and it should be decided whether it reports progress separately
|
|
342
|
+
or is folded into `CONFIGURING`.
|
|
343
|
+
|
|
344
|
+
## References
|
|
345
|
+
|
|
346
|
+
- SANE C backend: `~/backends/backend/pieusb*.c` — `pieusb.c` (entry points, `sane_start`),
|
|
347
|
+
`pieusb_specific.c` (options → device), `pieusb_scancmd.c` (SCSI wrappers),
|
|
348
|
+
`pieusb_buffer.c`, `pieusb_usb.c`. Authoritative for anything on the wire.
|
|
349
|
+
- PoC: `~/sw/pieusb_ref/pieusb_proscan10t_poc.py` — linear, single-device implementation of the
|
|
350
|
+
full open → INQUIRY → configure → scan → numpy flow, verified against a real ProScan 10T.
|
|
351
|
+
Authoritative for what has actually been observed to work; ProScan 10T only.
|
|
352
|
+
- `TODO.md` — current state and the concrete work remaining.
|