coolscanpy 0.1.0__py3-none-any.whl
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.
- coolscanpy/__init__.py +102 -0
- coolscanpy/_device.py +454 -0
- coolscanpy/_logging.py +27 -0
- coolscanpy/_roll.py +768 -0
- coolscanpy/capture/__init__.py +2 -0
- coolscanpy/capture/frame_registration.py +535 -0
- coolscanpy/capture/full_negative.py +206 -0
- coolscanpy/capture/full_negative_capture.py +219 -0
- coolscanpy/capture/full_negative_workflow.py +433 -0
- coolscanpy/capture/sane_rgb_geometry.py +196 -0
- coolscanpy/capture/single_pass_workflow.py +1288 -0
- coolscanpy/cli/__init__.py +1 -0
- coolscanpy/cli/practical_parity.py +723 -0
- coolscanpy/cli/roll_scan.py +745 -0
- coolscanpy/exceptions.py +114 -0
- coolscanpy/io/__init__.py +1 -0
- coolscanpy/io/encoders.py +341 -0
- coolscanpy/protocol/__init__.py +1 -0
- coolscanpy/protocol/ls5000_single_pass/__init__.py +104 -0
- coolscanpy/protocol/ls5000_single_pass/bundle.py +123 -0
- coolscanpy/protocol/ls5000_single_pass/capture_process.py +2287 -0
- coolscanpy/protocol/ls5000_single_pass/continuation_plan.py +356 -0
- coolscanpy/protocol/ls5000_single_pass/data/__init__.py +1 -0
- coolscanpy/protocol/ls5000_single_pass/data/replay-first-rgbi4-manifest.json +114 -0
- coolscanpy/protocol/ls5000_single_pass/data/replay-first-rgbi4-plan.jsonl +607 -0
- coolscanpy/protocol/ls5000_single_pass/data/replay-next-rgbi4-plan.json +128 -0
- coolscanpy/protocol/ls5000_single_pass/meter.py +543 -0
- coolscanpy/protocol/ls5000_single_pass/packed.py +210 -0
- coolscanpy/protocol/ls5000_single_pass/plan.py +77 -0
- coolscanpy/protocol/ls5000_single_pass/roll_index.py +1298 -0
- coolscanpy/protocol/ls5000_single_pass/window.py +96 -0
- coolscanpy/protocol/ls5000_single_pass/worker.py +4397 -0
- coolscanpy/py.typed +0 -0
- coolscanpy/receipts/__init__.py +2 -0
- coolscanpy/receipts/ice_bundle.py +103 -0
- coolscanpy/receipts/outputs.py +266 -0
- coolscanpy/receipts/provenance.py +938 -0
- coolscanpy/receipts/quality.py +614 -0
- coolscanpy/receipts/tiff_contract.py +25 -0
- coolscanpy/receipts/writer.py +303 -0
- coolscanpy/roll/__init__.py +2 -0
- coolscanpy/roll/controls.py +43 -0
- coolscanpy/roll/forward.py +385 -0
- coolscanpy/roll/preview_session.py +897 -0
- coolscanpy/roll/registration.py +344 -0
- coolscanpy/roll/service.py +1979 -0
- coolscanpy/session/__init__.py +2 -0
- coolscanpy/session/backend.py +60 -0
- coolscanpy/session/filenames.py +50 -0
- coolscanpy/session/params.py +104 -0
- coolscanpy/session/result.py +105 -0
- coolscanpy/session/service.py +75 -0
- coolscanpy/transport/__init__.py +21 -0
- coolscanpy/transport/libsane_dual_source.py +1492 -0
- coolscanpy/transport/sane.py +2029 -0
- coolscanpy/transport/split_alignment_policy.py +64 -0
- coolscanpy/types.py +260 -0
- coolscanpy-0.1.0.dist-info/METADATA +202 -0
- coolscanpy-0.1.0.dist-info/RECORD +63 -0
- coolscanpy-0.1.0.dist-info/WHEEL +5 -0
- coolscanpy-0.1.0.dist-info/entry_points.txt +3 -0
- coolscanpy-0.1.0.dist-info/licenses/LICENSE +674 -0
- coolscanpy-0.1.0.dist-info/top_level.txt +1 -0
coolscanpy/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""coolscanpy -- direct-USB acquisition library for Nikon Coolscan film scanners.
|
|
2
|
+
|
|
3
|
+
A python-sane-style API (module-level ``get_devices()``/``open()``, a
|
|
4
|
+
``Device`` with typed option attributes, ``dev.scan() -> ndarray``) plus a
|
|
5
|
+
roll-feeder extension (``Device.roll() -> Roll``: whole-roll preview,
|
|
6
|
+
spacing-offset registration, fingerprint-refusal safety, one-shot batch fine
|
|
7
|
+
scanning, receipts). See the package README for the hardware this has
|
|
8
|
+
actually been validated against and what remains untested.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from importlib.metadata import version as _pkg_version
|
|
15
|
+
|
|
16
|
+
__version__ = _pkg_version("coolscanpy")
|
|
17
|
+
except Exception: # pragma: no cover - only when the package isn't installed
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from coolscanpy._device import Device, get_devices, open
|
|
21
|
+
from coolscanpy._roll import Roll
|
|
22
|
+
from coolscanpy.exceptions import (
|
|
23
|
+
BatchIntegrityError,
|
|
24
|
+
DeviceBusy,
|
|
25
|
+
DeviceNotFound,
|
|
26
|
+
EjectFailed,
|
|
27
|
+
FeederParked,
|
|
28
|
+
FingerprintRefused,
|
|
29
|
+
GeometryValidationError,
|
|
30
|
+
ManualReviewRequired,
|
|
31
|
+
PyCoolscanError,
|
|
32
|
+
RollMismatch,
|
|
33
|
+
SafeStopRequested,
|
|
34
|
+
SplitAlignmentError,
|
|
35
|
+
TransportSmearDetected,
|
|
36
|
+
)
|
|
37
|
+
from coolscanpy.types import (
|
|
38
|
+
ApprovalReceipt,
|
|
39
|
+
ArtifactEvidence,
|
|
40
|
+
Capabilities,
|
|
41
|
+
ClippingTelemetry,
|
|
42
|
+
DeviceInfo,
|
|
43
|
+
ExposureVector,
|
|
44
|
+
FingerprintComparison,
|
|
45
|
+
FocusDetailTelemetry,
|
|
46
|
+
Frame,
|
|
47
|
+
Material,
|
|
48
|
+
Option,
|
|
49
|
+
OptionType,
|
|
50
|
+
OptionUnit,
|
|
51
|
+
Progress,
|
|
52
|
+
Receipt,
|
|
53
|
+
RollFingerprint,
|
|
54
|
+
SplitAlignment,
|
|
55
|
+
Thumbnail,
|
|
56
|
+
TransportSmearAssessment,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"__version__",
|
|
61
|
+
# module-level (python-sane-shaped)
|
|
62
|
+
"get_devices",
|
|
63
|
+
"open",
|
|
64
|
+
# core objects
|
|
65
|
+
"Device",
|
|
66
|
+
"Option",
|
|
67
|
+
"OptionType",
|
|
68
|
+
"OptionUnit",
|
|
69
|
+
"Capabilities",
|
|
70
|
+
"DeviceInfo",
|
|
71
|
+
# roll extension
|
|
72
|
+
"Roll",
|
|
73
|
+
"Material",
|
|
74
|
+
"Thumbnail",
|
|
75
|
+
"Frame",
|
|
76
|
+
"RollFingerprint",
|
|
77
|
+
"FingerprintComparison",
|
|
78
|
+
"Progress",
|
|
79
|
+
# receipts
|
|
80
|
+
"Receipt",
|
|
81
|
+
"ExposureVector",
|
|
82
|
+
"SplitAlignment",
|
|
83
|
+
"ClippingTelemetry",
|
|
84
|
+
"FocusDetailTelemetry",
|
|
85
|
+
"TransportSmearAssessment",
|
|
86
|
+
"ArtifactEvidence",
|
|
87
|
+
"ApprovalReceipt",
|
|
88
|
+
# exceptions
|
|
89
|
+
"PyCoolscanError",
|
|
90
|
+
"DeviceNotFound",
|
|
91
|
+
"DeviceBusy",
|
|
92
|
+
"EjectFailed",
|
|
93
|
+
"SafeStopRequested",
|
|
94
|
+
"FeederParked",
|
|
95
|
+
"RollMismatch",
|
|
96
|
+
"FingerprintRefused",
|
|
97
|
+
"ManualReviewRequired",
|
|
98
|
+
"GeometryValidationError",
|
|
99
|
+
"TransportSmearDetected",
|
|
100
|
+
"SplitAlignmentError",
|
|
101
|
+
"BatchIntegrityError",
|
|
102
|
+
]
|
coolscanpy/_device.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""The python-sane-shaped plain surface: ``get_devices``/``open``/``Device``.
|
|
2
|
+
|
|
3
|
+
This module is thin by design: it adapts ``session.service.ScannerService``
|
|
4
|
+
(itself backed by ``transport.sane.SaneBackend``) to the fixed, typed option
|
|
5
|
+
attributes and constraint-introspection shape the public API contract
|
|
6
|
+
describes. It adds exactly two pieces of new behavior beyond adaptation and
|
|
7
|
+
argument validation, both explicitly called for by the API contract:
|
|
8
|
+
|
|
9
|
+
* an in-process registry so a second ``open()`` of an already-open device
|
|
10
|
+
raises :class:`~coolscanpy.exceptions.DeviceBusy` instead of silently
|
|
11
|
+
racing it (there is no cross-process reservation for the plain scan path);
|
|
12
|
+
* a non-blocking per-device lock so a concurrent ``scan()``/``roll()`` call
|
|
13
|
+
raises :class:`~coolscanpy.exceptions.DeviceBusy` rather than blocking.
|
|
14
|
+
|
|
15
|
+
``coolscanpy.session.service.ScannerService`` and ``coolscanpy.transport.sane``
|
|
16
|
+
are imported lazily (inside functions, not at module level) so that plain
|
|
17
|
+
``import coolscanpy`` stays cheap and does not pull in ``cv2``/``python-sane``
|
|
18
|
+
-- matching this package's existing convention that only the code paths
|
|
19
|
+
which actually need the SANE-backed transport pay for it (see the
|
|
20
|
+
``[scanner]`` extra in ``pyproject.toml``).
|
|
21
|
+
|
|
22
|
+
Enumeration itself never requires python-sane to be installed: ``get_devices()``
|
|
23
|
+
tries the SANE route first, and only when python-sane is not importable falls
|
|
24
|
+
back to direct USB enumeration (``pyusb``, already a runtime dependency,
|
|
25
|
+
matching the LS-5000's fixed vendor/product id) -- see
|
|
26
|
+
``_usb_fallback_device_infos``. That fallback device carries reduced,
|
|
27
|
+
conservative capabilities, since there is no SANE session to negotiate the
|
|
28
|
+
rest against. SANE-only operations (``Device.scan()``, ``Device.eject()``)
|
|
29
|
+
still raise ``ImportError`` when actually called on such a device -- only
|
|
30
|
+
enumeration and the roll-feeder extension, which never uses SANE, are
|
|
31
|
+
unaffected by its absence.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import threading
|
|
37
|
+
from typing import TYPE_CHECKING, Callable
|
|
38
|
+
|
|
39
|
+
from coolscanpy.exceptions import DeviceBusy, DeviceNotFound, EjectFailed
|
|
40
|
+
from coolscanpy.session.backend import ScannerCapabilities, ScannerDevice
|
|
41
|
+
from coolscanpy.session.params import ScanParams
|
|
42
|
+
from coolscanpy.types import Capabilities, DeviceInfo, Material, Option, OptionType, OptionUnit
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from coolscanpy._roll import Roll
|
|
46
|
+
from coolscanpy.session.service import ScannerService
|
|
47
|
+
|
|
48
|
+
_COOLSCAN3_PREFIX = "coolscan3:"
|
|
49
|
+
|
|
50
|
+
_OPTION_NAMES: tuple[str, ...] = ("resolution", "depth", "samples", "autofocus", "auto_exposure")
|
|
51
|
+
|
|
52
|
+
# The Nikon Coolscan LS-5000's fixed USB identity. The roll engine's capture
|
|
53
|
+
# subprocess (protocol.ls5000_single_pass.worker) locates the physical unit
|
|
54
|
+
# itself with this exact pair -- it never consumes a device id from this
|
|
55
|
+
# module -- so these are duplicated constants, not a shared import, matching
|
|
56
|
+
# that module's pinned/untouched status.
|
|
57
|
+
_LS5000_USB_VENDOR_ID = 0x04B0
|
|
58
|
+
_LS5000_USB_PRODUCT_ID = 0x4002
|
|
59
|
+
_USB_FALLBACK_ID_PREFIX = "usb"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _default_service_factory() -> "ScannerService":
|
|
63
|
+
from coolscanpy.session.service import ScannerService
|
|
64
|
+
|
|
65
|
+
return ScannerService()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Rebindable so tests can substitute a service that wraps a fake backend
|
|
69
|
+
# without touching hardware or python-sane. See tests/test_facade.py.
|
|
70
|
+
_service_factory: Callable[[], "ScannerService"] = _default_service_factory
|
|
71
|
+
|
|
72
|
+
_open_devices: set[str] = set()
|
|
73
|
+
_open_devices_lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _register_open_device(device_id: str) -> None:
|
|
77
|
+
with _open_devices_lock:
|
|
78
|
+
if device_id in _open_devices:
|
|
79
|
+
raise DeviceBusy(f"device {device_id!r} is already open in this process")
|
|
80
|
+
_open_devices.add(device_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unregister_open_device(device_id: str) -> None:
|
|
84
|
+
with _open_devices_lock:
|
|
85
|
+
_open_devices.discard(device_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_coolscan_device_id(device_id: str) -> bool:
|
|
89
|
+
"""True for a Coolscan LS-5000 SANE device id, direct or via saned.
|
|
90
|
+
|
|
91
|
+
``get_devices()`` scope is one vendor: filter out every other SANE
|
|
92
|
+
backend's device (pieusb, generic flatbeds, etc.) so a caller never sees
|
|
93
|
+
a non-Coolscan unit, matching the module-level scope note. Imports
|
|
94
|
+
``transport.sane`` lazily -- see the module docstring.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
from coolscanpy.transport.sane import _strip_net_prefix
|
|
98
|
+
|
|
99
|
+
return _strip_net_prefix(device_id).startswith(_COOLSCAN3_PREFIX)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _capabilities_from(caps: ScannerCapabilities) -> Capabilities:
|
|
103
|
+
return Capabilities(
|
|
104
|
+
ir_channel=caps.ir_channel,
|
|
105
|
+
supported_dpi=caps.supported_dpi,
|
|
106
|
+
supported_depths=caps.supported_depths,
|
|
107
|
+
multi_sample=caps.multi_sample,
|
|
108
|
+
adapter_frame_capacity=caps.adapter_frame_capacity,
|
|
109
|
+
adapter_frame_control=caps.adapter_frame_control,
|
|
110
|
+
auto_exposure=caps.auto_exposure,
|
|
111
|
+
registered_geometry=caps.registered_geometry,
|
|
112
|
+
can_eject=caps.can_eject,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _device_info_from(device: ScannerDevice) -> DeviceInfo:
|
|
117
|
+
return DeviceInfo(
|
|
118
|
+
id=device.id,
|
|
119
|
+
vendor=device.vendor,
|
|
120
|
+
model=device.model,
|
|
121
|
+
capabilities=_capabilities_from(device.capabilities),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _usb_fallback_capabilities() -> Capabilities:
|
|
126
|
+
"""Conservative capabilities for a device found only via direct USB.
|
|
127
|
+
|
|
128
|
+
No SANE session exists to negotiate a real capability set, so this
|
|
129
|
+
reports only what holds for every LS-5000 regardless of software stack:
|
|
130
|
+
a fixed 4000 dpi / 16-bit native fine-scan (what the roll engine's
|
|
131
|
+
single-pass capture always requests, independent of ``Device.resolution``/
|
|
132
|
+
``.depth``) and frame-position addressing, so ``Device.roll()``'s
|
|
133
|
+
capability gate passes. Everything SANE would otherwise negotiate
|
|
134
|
+
per-unit (hardware multi-sample, auto-exposure, registered-geometry
|
|
135
|
+
positioning, medium eject) is conservatively false/absent rather than
|
|
136
|
+
guessed -- those remain genuinely SANE-only.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
return Capabilities(
|
|
140
|
+
ir_channel=True,
|
|
141
|
+
supported_dpi=(4_000,),
|
|
142
|
+
supported_depths=(16,),
|
|
143
|
+
multi_sample=False,
|
|
144
|
+
adapter_frame_capacity=None,
|
|
145
|
+
adapter_frame_control=True,
|
|
146
|
+
auto_exposure=False,
|
|
147
|
+
registered_geometry=False,
|
|
148
|
+
can_eject=False,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _usb_fallback_device_infos() -> list[DeviceInfo]:
|
|
153
|
+
"""Enumerate attached LS-5000 units directly over USB, no python-sane.
|
|
154
|
+
|
|
155
|
+
Used by :func:`get_devices` only when python-sane is not importable.
|
|
156
|
+
``usb.core`` is imported lazily here, matching this package's convention
|
|
157
|
+
of scoping USB imports to the code that actually touches the bus.
|
|
158
|
+
|
|
159
|
+
The returned id is synthetic (``"usb:<bus>:<address>"``): honest about
|
|
160
|
+
the USB topology it was found on, but not a SANE device string, since
|
|
161
|
+
none was ever negotiated. Nothing downstream needs it to be one --
|
|
162
|
+
:meth:`Device.roll`'s capture subprocess locates the physical unit itself
|
|
163
|
+
by the same fixed vendor/product id (see
|
|
164
|
+
``protocol.ls5000_single_pass.worker``), never through this id; the id
|
|
165
|
+
here only labels the in-process open-device registry and the ``Receipt``
|
|
166
|
+
metadata a roll capture carries.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
import usb.core
|
|
170
|
+
|
|
171
|
+
capabilities = _usb_fallback_capabilities()
|
|
172
|
+
found = usb.core.find(
|
|
173
|
+
find_all=True,
|
|
174
|
+
idVendor=_LS5000_USB_VENDOR_ID,
|
|
175
|
+
idProduct=_LS5000_USB_PRODUCT_ID,
|
|
176
|
+
)
|
|
177
|
+
return [
|
|
178
|
+
DeviceInfo(
|
|
179
|
+
id=f"{_USB_FALLBACK_ID_PREFIX}:{device.bus}:{device.address}",
|
|
180
|
+
vendor="Nikon",
|
|
181
|
+
model="LS-5000 ED",
|
|
182
|
+
capabilities=capabilities,
|
|
183
|
+
)
|
|
184
|
+
for device in found
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_devices(local_only: bool = False) -> list[DeviceInfo]:
|
|
189
|
+
"""Enumerate attached Coolscan LS-5000 units.
|
|
190
|
+
|
|
191
|
+
Mirrors ``sane.get_devices()``; unlike SANE, this never returns a
|
|
192
|
+
non-Coolscan device -- there is no backend negotiation. ``local_only`` is
|
|
193
|
+
accepted for signature-compatibility with ``sane.get_devices()`` and is
|
|
194
|
+
currently always true (no network transport exists for this package).
|
|
195
|
+
|
|
196
|
+
Tries the SANE route first. When python-sane is not importable, falls
|
|
197
|
+
back to direct USB enumeration instead of raising -- see
|
|
198
|
+
:func:`_usb_fallback_device_infos`.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
del local_only
|
|
202
|
+
try:
|
|
203
|
+
service = _service_factory()
|
|
204
|
+
devices = service.list_devices()
|
|
205
|
+
except ImportError:
|
|
206
|
+
return _usb_fallback_device_infos()
|
|
207
|
+
return [_device_info_from(device) for device in devices if _is_coolscan_device_id(device.id)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def open(devname: str) -> "Device":
|
|
211
|
+
"""Open one Coolscan LS-5000. Mirrors ``sane.open(devname)``.
|
|
212
|
+
|
|
213
|
+
``devname`` is either ``"ls5000"`` (friendly alias for "the one attached
|
|
214
|
+
unit") or an exact :class:`~coolscanpy.types.DeviceInfo.id` string
|
|
215
|
+
returned by :func:`get_devices`.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
infos = get_devices()
|
|
219
|
+
if devname == "ls5000":
|
|
220
|
+
if not infos:
|
|
221
|
+
raise DeviceNotFound("no Coolscan LS-5000 unit is attached")
|
|
222
|
+
if len(infos) > 1:
|
|
223
|
+
raise DeviceNotFound(
|
|
224
|
+
"more than one Coolscan LS-5000 unit is attached; "
|
|
225
|
+
"disambiguate via get_devices()"
|
|
226
|
+
)
|
|
227
|
+
info = infos[0]
|
|
228
|
+
else:
|
|
229
|
+
matches = [candidate for candidate in infos if candidate.id == devname]
|
|
230
|
+
if not matches:
|
|
231
|
+
raise DeviceNotFound(f"no attached Coolscan LS-5000 unit matches {devname!r}")
|
|
232
|
+
info = matches[0]
|
|
233
|
+
|
|
234
|
+
_register_open_device(info.id)
|
|
235
|
+
try:
|
|
236
|
+
return Device(info, _service_factory())
|
|
237
|
+
except BaseException:
|
|
238
|
+
_unregister_open_device(info.id)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Device:
|
|
243
|
+
"""One opened Coolscan LS-5000 unit.
|
|
244
|
+
|
|
245
|
+
Construct via :func:`open`, not directly, except in tests that want to
|
|
246
|
+
drive a specific :class:`DeviceInfo`/service pair without going through
|
|
247
|
+
device enumeration.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, info: DeviceInfo, service: ScannerService) -> None:
|
|
251
|
+
self._info = info
|
|
252
|
+
self._service = service
|
|
253
|
+
self._lock = threading.Lock()
|
|
254
|
+
self._roll_lock = threading.Lock()
|
|
255
|
+
self._cancel_event: threading.Event | None = None
|
|
256
|
+
self._closed = False
|
|
257
|
+
|
|
258
|
+
caps = info.capabilities
|
|
259
|
+
self.resolution = max(caps.supported_dpi) if caps.supported_dpi else 4000
|
|
260
|
+
self.depth = 16 if 16 in caps.supported_depths else (
|
|
261
|
+
max(caps.supported_depths) if caps.supported_depths else 16
|
|
262
|
+
)
|
|
263
|
+
self.samples = 1
|
|
264
|
+
self.autofocus = True
|
|
265
|
+
self.auto_exposure = False
|
|
266
|
+
|
|
267
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
268
|
+
if name in _OPTION_NAMES:
|
|
269
|
+
self._validate_option(name, value)
|
|
270
|
+
object.__setattr__(self, name, value)
|
|
271
|
+
|
|
272
|
+
def _validate_option(self, name: str, value: object) -> None:
|
|
273
|
+
caps = self._info.capabilities
|
|
274
|
+
if name == "resolution":
|
|
275
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in caps.supported_dpi:
|
|
276
|
+
raise ValueError(f"resolution {value!r} is not in supported_dpi {caps.supported_dpi}")
|
|
277
|
+
elif name == "depth":
|
|
278
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in caps.supported_depths:
|
|
279
|
+
raise ValueError(f"depth {value!r} is not in supported_depths {caps.supported_depths}")
|
|
280
|
+
elif name == "samples":
|
|
281
|
+
allowed = (1, 4) if caps.multi_sample else (1,)
|
|
282
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in allowed:
|
|
283
|
+
raise ValueError(f"samples {value!r} is not in {allowed}")
|
|
284
|
+
elif name == "autofocus":
|
|
285
|
+
if not isinstance(value, bool):
|
|
286
|
+
raise TypeError("autofocus must be a bool")
|
|
287
|
+
elif name == "auto_exposure":
|
|
288
|
+
if not isinstance(value, bool):
|
|
289
|
+
raise TypeError("auto_exposure must be a bool")
|
|
290
|
+
if value and not caps.auto_exposure:
|
|
291
|
+
raise ValueError("device has no auto_exposure capability")
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def capabilities(self):
|
|
295
|
+
return self._info.capabilities
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def option_names(self) -> list[str]:
|
|
299
|
+
"""Mirrors sane's ``optlist``."""
|
|
300
|
+
|
|
301
|
+
return list(_OPTION_NAMES)
|
|
302
|
+
|
|
303
|
+
def __getitem__(self, name: str) -> Option:
|
|
304
|
+
"""Mirrors sane's ``dev['name']`` descriptor lookup."""
|
|
305
|
+
|
|
306
|
+
caps = self._info.capabilities
|
|
307
|
+
if name == "resolution":
|
|
308
|
+
return Option(
|
|
309
|
+
name="resolution",
|
|
310
|
+
title="Resolution",
|
|
311
|
+
desc="Scan resolution, in dots per inch.",
|
|
312
|
+
type=OptionType.INT,
|
|
313
|
+
unit=OptionUnit.DPI,
|
|
314
|
+
constraint=tuple(caps.supported_dpi),
|
|
315
|
+
active=True,
|
|
316
|
+
settable=len(caps.supported_dpi) > 1,
|
|
317
|
+
)
|
|
318
|
+
if name == "depth":
|
|
319
|
+
return Option(
|
|
320
|
+
name="depth",
|
|
321
|
+
title="Depth",
|
|
322
|
+
desc="Bits per sample.",
|
|
323
|
+
type=OptionType.INT,
|
|
324
|
+
unit=OptionUnit.NONE,
|
|
325
|
+
constraint=tuple(caps.supported_depths),
|
|
326
|
+
active=True,
|
|
327
|
+
settable=len(caps.supported_depths) > 1,
|
|
328
|
+
)
|
|
329
|
+
if name == "samples":
|
|
330
|
+
constraint = (1, 4) if caps.multi_sample else (1,)
|
|
331
|
+
return Option(
|
|
332
|
+
name="samples",
|
|
333
|
+
title="Samples",
|
|
334
|
+
desc="Hardware oversampling passes per line.",
|
|
335
|
+
type=OptionType.INT,
|
|
336
|
+
unit=OptionUnit.NONE,
|
|
337
|
+
constraint=constraint,
|
|
338
|
+
active=True,
|
|
339
|
+
settable=caps.multi_sample,
|
|
340
|
+
)
|
|
341
|
+
if name == "autofocus":
|
|
342
|
+
return Option(
|
|
343
|
+
name="autofocus",
|
|
344
|
+
title="Autofocus",
|
|
345
|
+
desc="Autofocus before each scan.",
|
|
346
|
+
type=OptionType.BOOL,
|
|
347
|
+
unit=OptionUnit.NONE,
|
|
348
|
+
constraint=None,
|
|
349
|
+
active=True,
|
|
350
|
+
settable=True,
|
|
351
|
+
)
|
|
352
|
+
if name == "auto_exposure":
|
|
353
|
+
return Option(
|
|
354
|
+
name="auto_exposure",
|
|
355
|
+
title="Auto exposure",
|
|
356
|
+
desc="Hardware auto-exposure metering.",
|
|
357
|
+
type=OptionType.BOOL,
|
|
358
|
+
unit=OptionUnit.NONE,
|
|
359
|
+
constraint=None,
|
|
360
|
+
active=caps.auto_exposure,
|
|
361
|
+
settable=caps.auto_exposure,
|
|
362
|
+
)
|
|
363
|
+
raise KeyError(name)
|
|
364
|
+
|
|
365
|
+
def scan(self, *, progress: Callable[[float], None] | None = None):
|
|
366
|
+
"""Blocking. Returns a uint16 ``(H, W, 3)`` scanner-linear RGB array.
|
|
367
|
+
|
|
368
|
+
The plain, roll-independent scan path: a single ad-hoc capture at
|
|
369
|
+
whatever resolution/depth/samples are currently set. Never touches
|
|
370
|
+
roll bookkeeping, never produces a Receipt, never returns IR.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
self._require_open()
|
|
374
|
+
if not self._lock.acquire(blocking=False):
|
|
375
|
+
raise DeviceBusy(f"device {self._info.id} is already scanning")
|
|
376
|
+
try:
|
|
377
|
+
cancel_event = threading.Event()
|
|
378
|
+
self._cancel_event = cancel_event
|
|
379
|
+
params = ScanParams(
|
|
380
|
+
dpi=self.resolution,
|
|
381
|
+
depth=self.depth,
|
|
382
|
+
capture_ir=False,
|
|
383
|
+
autofocus=self.autofocus,
|
|
384
|
+
samples_per_scan=self.samples,
|
|
385
|
+
auto_exposure=self.auto_exposure,
|
|
386
|
+
)
|
|
387
|
+
result = self._service.run_scan(
|
|
388
|
+
self._info.id,
|
|
389
|
+
params,
|
|
390
|
+
progress if progress is not None else (lambda _fraction: None),
|
|
391
|
+
cancel_event,
|
|
392
|
+
)
|
|
393
|
+
return result.rgb
|
|
394
|
+
finally:
|
|
395
|
+
self._cancel_event = None
|
|
396
|
+
self._lock.release()
|
|
397
|
+
|
|
398
|
+
def cancel(self) -> None:
|
|
399
|
+
"""Mirrors ``sane.SaneDev.cancel()``. Call from a different thread
|
|
400
|
+
than the one blocked in :meth:`scan`."""
|
|
401
|
+
|
|
402
|
+
event = self._cancel_event
|
|
403
|
+
if event is not None:
|
|
404
|
+
event.set()
|
|
405
|
+
|
|
406
|
+
def roll(self, *, material: Material = Material.COLOR_NEGATIVE) -> "Roll":
|
|
407
|
+
"""Open the 40-slot roll-feeder extension."""
|
|
408
|
+
|
|
409
|
+
self._require_open()
|
|
410
|
+
caps = self._info.capabilities
|
|
411
|
+
if caps.adapter_frame_capacity is None and not caps.adapter_frame_control:
|
|
412
|
+
raise ValueError("no roll adapter is attached/detected on this device")
|
|
413
|
+
if not self._roll_lock.acquire(blocking=False):
|
|
414
|
+
raise DeviceBusy(f"a Roll is already open on device {self._info.id}")
|
|
415
|
+
try:
|
|
416
|
+
from coolscanpy._roll import Roll
|
|
417
|
+
|
|
418
|
+
return Roll(self, material)
|
|
419
|
+
except BaseException:
|
|
420
|
+
self._roll_lock.release()
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
def _release_roll_lock(self) -> None:
|
|
424
|
+
try:
|
|
425
|
+
self._roll_lock.release()
|
|
426
|
+
except RuntimeError:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
def eject(self) -> bool:
|
|
430
|
+
"""Capability-gated vendor eject/unload."""
|
|
431
|
+
|
|
432
|
+
self._require_open()
|
|
433
|
+
try:
|
|
434
|
+
return bool(self._service.eject(self._info.id))
|
|
435
|
+
except RuntimeError as error:
|
|
436
|
+
raise EjectFailed(str(error)) from error
|
|
437
|
+
|
|
438
|
+
def close(self) -> None:
|
|
439
|
+
"""Idempotent. Releases any transport claim this Device holds."""
|
|
440
|
+
|
|
441
|
+
if self._closed:
|
|
442
|
+
return
|
|
443
|
+
self._closed = True
|
|
444
|
+
_unregister_open_device(self._info.id)
|
|
445
|
+
|
|
446
|
+
def _require_open(self) -> None:
|
|
447
|
+
if self._closed:
|
|
448
|
+
raise RuntimeError("this Device has been closed")
|
|
449
|
+
|
|
450
|
+
def __enter__(self) -> "Device":
|
|
451
|
+
return self
|
|
452
|
+
|
|
453
|
+
def __exit__(self, *exc: object) -> None:
|
|
454
|
+
self.close()
|
coolscanpy/_logging.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Internal logging helper.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the standard library's ``logging`` module so the rest of
|
|
4
|
+
this package can call ``get_logger(__name__)`` without depending on any host
|
|
5
|
+
application's logging configuration. A ``NullHandler`` is attached to the
|
|
6
|
+
package's root logger so that an application which never configures logging
|
|
7
|
+
gets no output and no "no handlers could be found" warning, per the standard
|
|
8
|
+
library's guidance for library authors.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logging.getLogger("coolscanpy").addHandler(logging.NullHandler())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
17
|
+
"""Return a logger namespaced under ``coolscanpy``.
|
|
18
|
+
|
|
19
|
+
``name`` is typically a module's ``__name__`` (already fully qualified as
|
|
20
|
+
``coolscanpy.<submodule>``), in which case it is used as-is. A bare,
|
|
21
|
+
unqualified name is nested under the package logger instead.
|
|
22
|
+
"""
|
|
23
|
+
if not name:
|
|
24
|
+
return logging.getLogger("coolscanpy")
|
|
25
|
+
if name == "coolscanpy" or name.startswith("coolscanpy."):
|
|
26
|
+
return logging.getLogger(name)
|
|
27
|
+
return logging.getLogger(f"coolscanpy.{name}")
|