dirigo 0.3.8__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.
- dirigo-0.3.8/LICENSE +21 -0
- dirigo-0.3.8/PKG-INFO +71 -0
- dirigo-0.3.8/README.md +46 -0
- dirigo-0.3.8/dirigo/__init__.py +6 -0
- dirigo-0.3.8/dirigo/components/__init__.py +0 -0
- dirigo-0.3.8/dirigo/components/hardware.py +206 -0
- dirigo-0.3.8/dirigo/components/io.py +226 -0
- dirigo-0.3.8/dirigo/components/optics.py +82 -0
- dirigo-0.3.8/dirigo/components/profiling.py +128 -0
- dirigo-0.3.8/dirigo/components/units.py +648 -0
- dirigo-0.3.8/dirigo/hw_interfaces/__init__.py +4 -0
- dirigo-0.3.8/dirigo/hw_interfaces/camera.py +221 -0
- dirigo-0.3.8/dirigo/hw_interfaces/detector.py +112 -0
- dirigo-0.3.8/dirigo/hw_interfaces/digitizer.py +870 -0
- dirigo-0.3.8/dirigo/hw_interfaces/encoder.py +122 -0
- dirigo-0.3.8/dirigo/hw_interfaces/hw_interface.py +24 -0
- dirigo-0.3.8/dirigo/hw_interfaces/illuminator.py +35 -0
- dirigo-0.3.8/dirigo/hw_interfaces/scanner.py +453 -0
- dirigo-0.3.8/dirigo/hw_interfaces/stage.py +316 -0
- dirigo-0.3.8/dirigo/main.py +164 -0
- dirigo-0.3.8/dirigo/plugins/__init__.py +0 -0
- dirigo-0.3.8/dirigo/plugins/acquisitions.py +997 -0
- dirigo-0.3.8/dirigo/plugins/calibrations.py +413 -0
- dirigo-0.3.8/dirigo/plugins/detectors.py +66 -0
- dirigo-0.3.8/dirigo/plugins/displays.py +413 -0
- dirigo-0.3.8/dirigo/plugins/encoders.py +313 -0
- dirigo-0.3.8/dirigo/plugins/illuminators.py +43 -0
- dirigo-0.3.8/dirigo/plugins/loaders.py +94 -0
- dirigo-0.3.8/dirigo/plugins/processors.py +722 -0
- dirigo-0.3.8/dirigo/plugins/scanners.py +836 -0
- dirigo-0.3.8/dirigo/plugins/writers.py +292 -0
- dirigo-0.3.8/dirigo/sw_interfaces/__init__.py +6 -0
- dirigo-0.3.8/dirigo/sw_interfaces/acquisition.py +168 -0
- dirigo-0.3.8/dirigo/sw_interfaces/display.py +235 -0
- dirigo-0.3.8/dirigo/sw_interfaces/processor.py +77 -0
- dirigo-0.3.8/dirigo/sw_interfaces/worker.py +222 -0
- dirigo-0.3.8/dirigo/sw_interfaces/writer.py +84 -0
- dirigo-0.3.8/dirigo.egg-info/PKG-INFO +71 -0
- dirigo-0.3.8/dirigo.egg-info/SOURCES.txt +43 -0
- dirigo-0.3.8/dirigo.egg-info/dependency_links.txt +1 -0
- dirigo-0.3.8/dirigo.egg-info/entry_points.txt +52 -0
- dirigo-0.3.8/dirigo.egg-info/requires.txt +6 -0
- dirigo-0.3.8/dirigo.egg-info/top_level.txt +1 -0
- dirigo-0.3.8/pyproject.toml +89 -0
- dirigo-0.3.8/setup.cfg +4 -0
dirigo-0.3.8/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Massachusetts Institute of Technology
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dirigo-0.3.8/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirigo
|
|
3
|
+
Version: 0.3.8
|
|
4
|
+
Summary: Dirigo is an extensible backend for scientific image acquisition
|
|
5
|
+
Author-email: "T. D. Weber" <tweber@mit.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dirigo-developers/dirigo
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Topic :: System :: Hardware
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numba
|
|
19
|
+
Requires-Dist: scipy
|
|
20
|
+
Requires-Dist: toml
|
|
21
|
+
Requires-Dist: platformdirs
|
|
22
|
+
Requires-Dist: tifffile
|
|
23
|
+
Requires-Dist: nidaqmx
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Dirigo
|
|
27
|
+

|
|
28
|
+
[](https://dirigo.readthedocs.io/en/latest/?badge=latest)
|
|
29
|
+
|
|
30
|
+
**Dirigo** is an extensible, high-performance backend for scientific image acquisition, designed with high-speed laser scanning microscopy in mind but adaptable to a wide range of medium- to high-complexity imaging systems.
|
|
31
|
+
|
|
32
|
+
Dirigo separates hardware control, acquisition logic, and user interface, making it easy to:
|
|
33
|
+
|
|
34
|
+
- Add new hardware via plugin drivers that implement generic device interfaces (digitizers, scanners, stages, cameras, etc.)
|
|
35
|
+
|
|
36
|
+
- Reconfigure data acquisition and processing pipelines using a Worker/publisher–subscriber model
|
|
37
|
+
|
|
38
|
+
- Integrate adaptive acquisition strategies through feedback loops between processing and control
|
|
39
|
+
|
|
40
|
+
- Build custom GUIs or integrate with existing tools via a clean API (a reference GUI is available as a [separate package](https://github.com/dirigo-developers/dirigo-gui))
|
|
41
|
+
|
|
42
|
+
Performance-critical operations are accelerated with [Numba](https://numba.pydata.org/) JIT compilation, releasing the GIL during execution and enabling parallel/vectorized processing.
|
|
43
|
+
|
|
44
|
+
Dirigo follows a modular, package-oriented architecture: almost all components—hardware drivers, processing modules, GUIs—are separate Python packages that can be developed, installed, and updated independently.
|
|
45
|
+
|
|
46
|
+
Dirigo is in *very* early development. While the API and architecture are functional, documentation and ready-to-use releases are in progress.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Digitizer ↔ LSM mode compatibility
|
|
50
|
+
|
|
51
|
+
**Legend:** ✓ supported now · △ possible/experimental · ✗ not recommended/unsupported · — unknown
|
|
52
|
+
|
|
53
|
+
| Digitizer (vendor/model family) | Galvo–Galvo **analog** | Galvo–Galvo **photon counting** | Resonant–Galvo **analog** | Polygon–Galvo **analog** |
|
|
54
|
+
|---|:---:|:---:|:---:|:---:|
|
|
55
|
+
| **NI X-Series** (e.g., PCIe-63xx) | ✓* | ✓ (up to 4 chan.) | ✗†| ✗†|
|
|
56
|
+
| **NI S-Series** (e.g., PCI-6110/6115) | ✓ | ✓ (up to 2 chan.) | △§ | △§ |
|
|
57
|
+
| **AlazarTech** (e.g., ATS9440) | ✓ | ✗ | ✓ | ✓ |
|
|
58
|
+
| **Teledyne SP Devices** (e.g., ADQ32) | ✓ | ✗ | ✓ | ✓ |
|
|
59
|
+
| **Other / custom** (contact [TDW](https://github.com/tweber225)) | — | — | — | — |
|
|
60
|
+
|
|
61
|
+
*Notes*
|
|
62
|
+
\* Multichannel acquisition subject to aggregate AI sample rate (e.g 2 channels: 500 kS/s, 4 channels 250 kS/s).
|
|
63
|
+
†AI sample rate typically insufficient for resonant/polygon rates.
|
|
64
|
+
§ Borderline: Max sample rate may limit pixels per line, dependent on scanner frequency. Not yet validated.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
## Funding
|
|
68
|
+
|
|
69
|
+
Development of Dirigo has been supported in part by the National Cancer Institute of the National Institutes of Health under award number R01CA249151.
|
|
70
|
+
|
|
71
|
+
The content of this repository is solely the responsibility of the authors and does not necessarily represent the official views of the National Institutes of Health.
|
dirigo-0.3.8/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Dirigo
|
|
2
|
+

|
|
3
|
+
[](https://dirigo.readthedocs.io/en/latest/?badge=latest)
|
|
4
|
+
|
|
5
|
+
**Dirigo** is an extensible, high-performance backend for scientific image acquisition, designed with high-speed laser scanning microscopy in mind but adaptable to a wide range of medium- to high-complexity imaging systems.
|
|
6
|
+
|
|
7
|
+
Dirigo separates hardware control, acquisition logic, and user interface, making it easy to:
|
|
8
|
+
|
|
9
|
+
- Add new hardware via plugin drivers that implement generic device interfaces (digitizers, scanners, stages, cameras, etc.)
|
|
10
|
+
|
|
11
|
+
- Reconfigure data acquisition and processing pipelines using a Worker/publisher–subscriber model
|
|
12
|
+
|
|
13
|
+
- Integrate adaptive acquisition strategies through feedback loops between processing and control
|
|
14
|
+
|
|
15
|
+
- Build custom GUIs or integrate with existing tools via a clean API (a reference GUI is available as a [separate package](https://github.com/dirigo-developers/dirigo-gui))
|
|
16
|
+
|
|
17
|
+
Performance-critical operations are accelerated with [Numba](https://numba.pydata.org/) JIT compilation, releasing the GIL during execution and enabling parallel/vectorized processing.
|
|
18
|
+
|
|
19
|
+
Dirigo follows a modular, package-oriented architecture: almost all components—hardware drivers, processing modules, GUIs—are separate Python packages that can be developed, installed, and updated independently.
|
|
20
|
+
|
|
21
|
+
Dirigo is in *very* early development. While the API and architecture are functional, documentation and ready-to-use releases are in progress.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Digitizer ↔ LSM mode compatibility
|
|
25
|
+
|
|
26
|
+
**Legend:** ✓ supported now · △ possible/experimental · ✗ not recommended/unsupported · — unknown
|
|
27
|
+
|
|
28
|
+
| Digitizer (vendor/model family) | Galvo–Galvo **analog** | Galvo–Galvo **photon counting** | Resonant–Galvo **analog** | Polygon–Galvo **analog** |
|
|
29
|
+
|---|:---:|:---:|:---:|:---:|
|
|
30
|
+
| **NI X-Series** (e.g., PCIe-63xx) | ✓* | ✓ (up to 4 chan.) | ✗†| ✗†|
|
|
31
|
+
| **NI S-Series** (e.g., PCI-6110/6115) | ✓ | ✓ (up to 2 chan.) | △§ | △§ |
|
|
32
|
+
| **AlazarTech** (e.g., ATS9440) | ✓ | ✗ | ✓ | ✓ |
|
|
33
|
+
| **Teledyne SP Devices** (e.g., ADQ32) | ✓ | ✗ | ✓ | ✓ |
|
|
34
|
+
| **Other / custom** (contact [TDW](https://github.com/tweber225)) | — | — | — | — |
|
|
35
|
+
|
|
36
|
+
*Notes*
|
|
37
|
+
\* Multichannel acquisition subject to aggregate AI sample rate (e.g 2 channels: 500 kS/s, 4 channels 250 kS/s).
|
|
38
|
+
†AI sample rate typically insufficient for resonant/polygon rates.
|
|
39
|
+
§ Borderline: Max sample rate may limit pixels per line, dependent on scanner frequency. Not yet validated.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Funding
|
|
43
|
+
|
|
44
|
+
Development of Dirigo has been supported in part by the National Cancer Institute of the National Institutes of Health under award number R01CA249151.
|
|
45
|
+
|
|
46
|
+
The content of this repository is solely the responsibility of the authors and does not necessarily represent the official views of the National Institutes of Health.
|
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from functools import lru_cache, cached_property
|
|
2
|
+
import importlib.metadata as im
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from dirigo.components.optics import LaserScanningOptics, CameraOptics
|
|
6
|
+
from dirigo.hw_interfaces.detector import DetectorSet, Detector
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from dirigo.components.io import SystemConfig
|
|
10
|
+
from dirigo.hw_interfaces.digitizer import Digitizer
|
|
11
|
+
from dirigo.hw_interfaces.stage import MultiAxisStage
|
|
12
|
+
from dirigo.hw_interfaces.encoder import MultiAxisLinearEncoder
|
|
13
|
+
from dirigo.hw_interfaces.scanner import FastRasterScanner, SlowRasterScanner, ObjectiveZScanner
|
|
14
|
+
from dirigo.hw_interfaces.camera import FrameGrabber, LineCamera
|
|
15
|
+
from dirigo.hw_interfaces.illuminator import Illuminator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache
|
|
20
|
+
def _eps(group: str) -> dict[str, im.EntryPoint]:
|
|
21
|
+
return {ep.name.lower(): ep for ep in im.entry_points().select(group=group)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HardwareError(RuntimeError): pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotConfiguredError(HardwareError):
|
|
28
|
+
def __init__(self, section: str):
|
|
29
|
+
super().__init__(f"[{section}] missing in system_config.toml")
|
|
30
|
+
self.section = section
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PluginNotFoundError(HardwareError):
|
|
34
|
+
def __init__(self, group: str, type_name: str, available: list[str]):
|
|
35
|
+
avail = ", ".join(available) or "none"
|
|
36
|
+
super().__init__(f"No plugin '{type_name}' in entry-point group '{group}'. Available: {avail}")
|
|
37
|
+
self.group, self.type_name, self.available = group, type_name, available
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PluginInitError(HardwareError):
|
|
41
|
+
def __init__(self, cls, kwargs: dict):
|
|
42
|
+
super().__init__(f"Failed to initialize {cls.__name__} with kwargs {kwargs}")
|
|
43
|
+
self.cls, self.kwargs = cls, kwargs
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Hardware:
|
|
47
|
+
"""
|
|
48
|
+
Loads hardware specified in system_config.toml and holds references to the
|
|
49
|
+
components.
|
|
50
|
+
"""
|
|
51
|
+
def __init__(self, system_config: "SystemConfig") -> None:
|
|
52
|
+
self._cfg = system_config
|
|
53
|
+
|
|
54
|
+
# --- helper ---
|
|
55
|
+
def _load(self, group: str, type_name: str, **kw):
|
|
56
|
+
"""
|
|
57
|
+
Lazy-load the concrete driver class registered under *type_name*
|
|
58
|
+
in entry-point *group*, instantiate it with **kw, and return it.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
cls = _eps(group)[type_name.lower()].load()
|
|
62
|
+
except KeyError as e:
|
|
63
|
+
raise PluginNotFoundError(group, type_name, available=list(_eps(group)).copy()) from e
|
|
64
|
+
try:
|
|
65
|
+
return cls(**kw)
|
|
66
|
+
except TypeError as e:
|
|
67
|
+
raise PluginInitError(cls, kwargs=kw) from e
|
|
68
|
+
|
|
69
|
+
# --- hardward devices (lazy loading) ---
|
|
70
|
+
@cached_property
|
|
71
|
+
def digitizer(self) -> "Digitizer":
|
|
72
|
+
cfg = self._cfg.digitizer
|
|
73
|
+
if cfg is None:
|
|
74
|
+
raise NotConfiguredError("digitizer")
|
|
75
|
+
return self._load("dirigo_digitizers", cfg["type"], **cfg)
|
|
76
|
+
|
|
77
|
+
@cached_property
|
|
78
|
+
def fast_raster_scanner(self) -> "FastRasterScanner":
|
|
79
|
+
cfg = self._cfg.fast_raster_scanner
|
|
80
|
+
if cfg is None:
|
|
81
|
+
raise NotConfiguredError("fast raster scanner")
|
|
82
|
+
return self._load("dirigo_scanners", cfg["type"], **cfg)
|
|
83
|
+
|
|
84
|
+
@cached_property
|
|
85
|
+
def slow_raster_scanner(self) -> "SlowRasterScanner":
|
|
86
|
+
cfg = self._cfg.slow_raster_scanner
|
|
87
|
+
if cfg is None:
|
|
88
|
+
raise NotConfiguredError("slow raster scanner")
|
|
89
|
+
return self._load("dirigo_scanners", cfg["type"],
|
|
90
|
+
fast_scanner=self.fast_raster_scanner, **cfg)
|
|
91
|
+
|
|
92
|
+
@cached_property
|
|
93
|
+
def objective_z_scanner(self) -> "ObjectiveZScanner":
|
|
94
|
+
cfg = self._cfg.objective_z_scanner
|
|
95
|
+
if cfg is None:
|
|
96
|
+
raise NotConfiguredError("objective z scanner")
|
|
97
|
+
return self._load("dirigo_scanners", cfg["type"], **cfg)
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def stages(self) -> "MultiAxisStage":
|
|
101
|
+
cfg = self._cfg.stages
|
|
102
|
+
if cfg is None:
|
|
103
|
+
raise NotConfiguredError("stages")
|
|
104
|
+
return self._load("dirigo_stages", cfg["type"], **cfg)
|
|
105
|
+
|
|
106
|
+
@cached_property
|
|
107
|
+
def preferred_z_motor(self):
|
|
108
|
+
"""Returns the objective z scanner or multi-axis stage z axis."""
|
|
109
|
+
try:
|
|
110
|
+
return self.objective_z_scanner
|
|
111
|
+
except NotConfiguredError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
return self.stages.z
|
|
116
|
+
except (NotConfiguredError, NotImplementedError):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
raise NotConfiguredError("No Z motor")
|
|
120
|
+
|
|
121
|
+
@cached_property
|
|
122
|
+
def encoders(self) -> "MultiAxisLinearEncoder":
|
|
123
|
+
cfg = self._cfg.encoders
|
|
124
|
+
if cfg is None:
|
|
125
|
+
raise NotConfiguredError("encoders")
|
|
126
|
+
return self._load("dirigo_encoders", cfg["type"], **cfg)
|
|
127
|
+
|
|
128
|
+
@cached_property
|
|
129
|
+
def detectors(self) -> DetectorSet[Detector]:
|
|
130
|
+
cfg = self._cfg.detectors
|
|
131
|
+
if cfg is None:
|
|
132
|
+
raise NotConfiguredError("detectors")
|
|
133
|
+
dset = DetectorSet()
|
|
134
|
+
for key, det_cfg in cfg.items():
|
|
135
|
+
det = self._load("dirigo_detectors",
|
|
136
|
+
det_cfg.pop("type"),
|
|
137
|
+
**det_cfg,
|
|
138
|
+
fast_scanner=self.fast_raster_scanner)
|
|
139
|
+
dset.append(det)
|
|
140
|
+
return dset
|
|
141
|
+
|
|
142
|
+
@cached_property
|
|
143
|
+
def frame_grabber(self) -> "FrameGrabber":
|
|
144
|
+
cfg = self._cfg.frame_grabber
|
|
145
|
+
if cfg is None:
|
|
146
|
+
raise NotConfiguredError("frame grabber")
|
|
147
|
+
return self._load("dirigo_frame_grabbers", cfg["type"], **cfg)
|
|
148
|
+
|
|
149
|
+
@cached_property
|
|
150
|
+
def line_camera(self) -> "LineCamera": # this could just be camera
|
|
151
|
+
cfg = self._cfg.line_camera
|
|
152
|
+
if cfg is None:
|
|
153
|
+
raise NotConfiguredError("line camera")
|
|
154
|
+
return self._load("dirigo_line_cameras", cfg["type"],
|
|
155
|
+
frame_grabber=self.frame_grabber, **cfg)
|
|
156
|
+
|
|
157
|
+
@cached_property
|
|
158
|
+
def illuminator(self) -> "Illuminator":
|
|
159
|
+
cfg = self._cfg.illuminator
|
|
160
|
+
if cfg is None:
|
|
161
|
+
raise NotConfiguredError("illuminator")
|
|
162
|
+
return self._load("dirigo_illuminators", cfg["type"], **cfg)
|
|
163
|
+
|
|
164
|
+
@cached_property
|
|
165
|
+
def laser_scanning_optics(self) -> LaserScanningOptics:
|
|
166
|
+
cfg = self._cfg.laser_scanning_optics
|
|
167
|
+
if cfg is None:
|
|
168
|
+
raise NotConfiguredError("laser scanning optics")
|
|
169
|
+
return LaserScanningOptics(**cfg)
|
|
170
|
+
|
|
171
|
+
@cached_property
|
|
172
|
+
def camera_optics(self) -> CameraOptics:
|
|
173
|
+
cfg = self._cfg.camera_optics
|
|
174
|
+
if cfg is None:
|
|
175
|
+
raise NotConfiguredError("camera optics")
|
|
176
|
+
return CameraOptics(**cfg)
|
|
177
|
+
|
|
178
|
+
# --- conveniences ---
|
|
179
|
+
# TODO deprecate these methods to restore Hardware as a container/lazy loader
|
|
180
|
+
@property
|
|
181
|
+
def nchannels_enabled(self) -> int:
|
|
182
|
+
"""
|
|
183
|
+
Returns the number channels currently enabled on the primary data
|
|
184
|
+
acquisition device.
|
|
185
|
+
"""
|
|
186
|
+
if self._cfg.digitizer:
|
|
187
|
+
return sum([channel.enabled for channel in self.digitizer.channels])
|
|
188
|
+
elif self._cfg.line_camera:
|
|
189
|
+
return 1 # monochrome only for now, but RGB cameras should be 3-channel
|
|
190
|
+
else:
|
|
191
|
+
raise RuntimeError("No channels available: lacking digitizer or camera")
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def nchannels_present(self) -> int:
|
|
195
|
+
"""
|
|
196
|
+
Returns the number channels present on the primary data acquisition
|
|
197
|
+
device.
|
|
198
|
+
"""
|
|
199
|
+
if self._cfg.digitizer:
|
|
200
|
+
return len(self.digitizer.channels)
|
|
201
|
+
elif self._cfg.line_camera:
|
|
202
|
+
return 1 # monochrome only for now, but RGB cameras should be 3-channel
|
|
203
|
+
else:
|
|
204
|
+
raise RuntimeError("No channels available: lacking digitizer or camera")
|
|
205
|
+
|
|
206
|
+
# TODO __repr__ method
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import tomllib
|
|
3
|
+
import csv
|
|
4
|
+
from typing import Any
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.polynomial.polynomial import Polynomial
|
|
11
|
+
import platformdirs as pd
|
|
12
|
+
|
|
13
|
+
from dirigo.components import units
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_path() -> Path:
|
|
18
|
+
return pd.user_config_path("Dirigo")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_toml(file_name: Path | str) -> dict[str, Any]:
|
|
22
|
+
file_name = Path(file_name)
|
|
23
|
+
if not file_name.exists():
|
|
24
|
+
raise FileNotFoundError(f"Can not find TOML file: {file_name}")
|
|
25
|
+
if file_name.suffix != ".toml":
|
|
26
|
+
raise ValueError(f"Requested to load a non-TOML file: {file_name}")
|
|
27
|
+
with open(file_name, mode="rb") as toml_file:
|
|
28
|
+
toml_contents = tomllib.load(toml_file)
|
|
29
|
+
return toml_contents
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
d = load_toml(config_path() / "logging.toml") # 1st choice: user-specified path
|
|
34
|
+
_data_path = Path(d['data_path'])
|
|
35
|
+
except:
|
|
36
|
+
_data_path = pd.user_documents_path() / "Dirigo" # Backup path
|
|
37
|
+
|
|
38
|
+
def data_path() -> Path:
|
|
39
|
+
return _data_path
|
|
40
|
+
|
|
41
|
+
def load_scanner_calibration(
|
|
42
|
+
path: Path = config_path() / "scanner/calibration.csv"
|
|
43
|
+
) -> tuple:
|
|
44
|
+
|
|
45
|
+
ampls, freqs, phases = np.loadtxt(
|
|
46
|
+
path,
|
|
47
|
+
delimiter=',',
|
|
48
|
+
unpack=True,
|
|
49
|
+
skiprows=1
|
|
50
|
+
)
|
|
51
|
+
return ampls, freqs, phases
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_line_distortion_calibration(
|
|
55
|
+
amplitude: units.Angle,
|
|
56
|
+
path: Path = config_path() / "optics/line_distortion_calibration.csv"
|
|
57
|
+
):
|
|
58
|
+
data = np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1, ndmin=2)
|
|
59
|
+
amplitudes = data[:,0]
|
|
60
|
+
coefs = data[:,1:]
|
|
61
|
+
|
|
62
|
+
for i,a in enumerate(amplitudes):
|
|
63
|
+
if abs(a - amplitude)/amplitude < 0.001:
|
|
64
|
+
return Polynomial(coefs[i])
|
|
65
|
+
|
|
66
|
+
raise RuntimeError("Could not find distortion calibration")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def update_distortion_table(
|
|
70
|
+
scanner_ampl: units.Angle,
|
|
71
|
+
c0=None,
|
|
72
|
+
c1=None,
|
|
73
|
+
c2=None,
|
|
74
|
+
path: Path = config_path() / "optics/line_distortion_calibration.csv"
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
Update polynomial coefficients for a given scanner amplitude.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
scanner_ampl : units.Angle
|
|
82
|
+
Scanner amplitude (matched in radians).
|
|
83
|
+
c0, c1, c2 : float | None
|
|
84
|
+
Polynomial coefficients to update. None leaves value unchanged.
|
|
85
|
+
path : Path
|
|
86
|
+
CSV file path.
|
|
87
|
+
"""
|
|
88
|
+
rows = []
|
|
89
|
+
updated = False
|
|
90
|
+
|
|
91
|
+
with path.open("r", newline="") as f:
|
|
92
|
+
reader = csv.DictReader(f)
|
|
93
|
+
fieldnames = reader.fieldnames
|
|
94
|
+
if fieldnames is None:
|
|
95
|
+
raise ValueError("CSV has no header")
|
|
96
|
+
|
|
97
|
+
for row in reader:
|
|
98
|
+
row_rad = float(row["# scanner amplitude (rad)"])
|
|
99
|
+
|
|
100
|
+
if abs(row_rad - scanner_ampl) <= 1e-3:
|
|
101
|
+
if c0 is not None:
|
|
102
|
+
row["c0"] = f"{float(c0):.16e}"
|
|
103
|
+
if c1 is not None:
|
|
104
|
+
row["c1"] = f"{float(c1):.16e}"
|
|
105
|
+
if c2 is not None:
|
|
106
|
+
row["c2"] = f"{float(c2):.16e}"
|
|
107
|
+
updated = True
|
|
108
|
+
|
|
109
|
+
rows.append(row)
|
|
110
|
+
|
|
111
|
+
if not updated:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"No entry found for scanner amplitude {scanner_ampl}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with path.open("w", newline="") as f:
|
|
117
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
118
|
+
writer.writeheader()
|
|
119
|
+
writer.writerows(rows)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_stage_scanner_angle(
|
|
123
|
+
path: Path = config_path() / "optics/stage_scanner_angle.csv"
|
|
124
|
+
) -> units.Angle:
|
|
125
|
+
try:
|
|
126
|
+
data = np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1)
|
|
127
|
+
return units.Angle(float(data))
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
# If not calibrated, then return 0 angle (no error axis error)
|
|
130
|
+
return units.Angle("0 deg")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_signal_offset(
|
|
134
|
+
path: Path = config_path() / "digitizer/signal_offset.csv"
|
|
135
|
+
):
|
|
136
|
+
try:
|
|
137
|
+
return np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1, ndmin=1)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return np.array([0])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_line_gradient_calibration(
|
|
143
|
+
line_width: units.Position,
|
|
144
|
+
pixel_size: units.Position,
|
|
145
|
+
path: Path = config_path() / "optics/line_gradient.csv"
|
|
146
|
+
):
|
|
147
|
+
"""Returns a function to correct intensity vignetting."""
|
|
148
|
+
|
|
149
|
+
entries = np.loadtxt(
|
|
150
|
+
fname = path,
|
|
151
|
+
delimiter = ',',
|
|
152
|
+
dtype = np.float64,
|
|
153
|
+
skiprows = 1,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
x = entries[:,0]
|
|
157
|
+
if abs(line_width - (x[-1] - x[0])) / line_width > 0.001:
|
|
158
|
+
raise RuntimeError("Line gradient calibrated on different line size.")
|
|
159
|
+
print(abs(pixel_size - (x[1] - x[0])))
|
|
160
|
+
if abs(pixel_size - (x[1] - x[0])) / pixel_size > 0.01:
|
|
161
|
+
raise RuntimeError("Line gradient calibrated on different pixel size.")
|
|
162
|
+
# one could interpolate here probably TODO
|
|
163
|
+
|
|
164
|
+
n_x, n_c = entries.shape
|
|
165
|
+
n_c -= 1 # -1 to account for x axis label
|
|
166
|
+
correction = np.zeros((n_x,n_c), dtype=np.float32)
|
|
167
|
+
for c in range(n_c):
|
|
168
|
+
y = entries[:,c+1]
|
|
169
|
+
correction[:,c] = 1 / y
|
|
170
|
+
|
|
171
|
+
return np.average(correction,axis=1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_CONFIG_KEYS = (
|
|
175
|
+
"laser_scanning_optics", "camera_optics", "digitizer", "detectors",
|
|
176
|
+
"objective_z_scanner", "stages", "line_camera", "illuminator",
|
|
177
|
+
"frame_grabber", "encoders", "fast_raster_scanner", "slow_raster_scanner",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
class SystemConfig:
|
|
181
|
+
"""
|
|
182
|
+
Slotted, read-mostly config: each known section becomes an attribute whose
|
|
183
|
+
value is either a dict[...] or None if the section is absent in TOML.
|
|
184
|
+
No cached_property needed; lookups are direct attribute access.
|
|
185
|
+
"""
|
|
186
|
+
__slots__ = (*_CONFIG_KEYS, "_raw")
|
|
187
|
+
|
|
188
|
+
# explicit (optional) type hints for IDEs / type checkers
|
|
189
|
+
laser_scanning_optics: dict[str, Any] | None
|
|
190
|
+
camera_optics: dict[str, Any] | None
|
|
191
|
+
digitizer: dict[str, Any] | None
|
|
192
|
+
detectors: dict[str, Any] | None
|
|
193
|
+
objective_z_scanner: dict[str, Any] | None
|
|
194
|
+
stages: dict[str, Any] | None
|
|
195
|
+
line_camera: dict[str, Any] | None
|
|
196
|
+
illuminator: dict[str, Any] | None
|
|
197
|
+
frame_grabber: dict[str, Any] | None
|
|
198
|
+
encoders: dict[str, Any] | None
|
|
199
|
+
fast_raster_scanner: dict[str, Any] | None
|
|
200
|
+
slow_raster_scanner: dict[str, Any] | None
|
|
201
|
+
|
|
202
|
+
def __init__(self, data: dict[str, dict[str, Any]]):
|
|
203
|
+
self._raw = data
|
|
204
|
+
for key in _CONFIG_KEYS:
|
|
205
|
+
setattr(self, key, data.get(key)) # missing -> None
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_toml(cls, toml_path: "Path") -> "SystemConfig":
|
|
209
|
+
return cls(load_toml(toml_path))
|
|
210
|
+
|
|
211
|
+
def has(self, key: str) -> bool:
|
|
212
|
+
return getattr(self, key) is not None
|
|
213
|
+
|
|
214
|
+
def to_dict(self, *, copy: bool = False, readonly: bool = False) -> dict[str, dict[str, Any] | None]:
|
|
215
|
+
# expose the normalized view (sections -> dict | None)
|
|
216
|
+
d = {k: getattr(self, k) for k in _CONFIG_KEYS}
|
|
217
|
+
if copy:
|
|
218
|
+
return deepcopy(d)
|
|
219
|
+
if readonly:
|
|
220
|
+
return MappingProxyType(d)
|
|
221
|
+
return d
|
|
222
|
+
|
|
223
|
+
def raw(self) -> dict[str, dict[str, Any]]:
|
|
224
|
+
# original TOML mapping (no None entries; missing keys absent)
|
|
225
|
+
return self._raw
|
|
226
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from dirigo.components import units, io
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Notes:
|
|
8
|
+
Concerning distortion and magnification error, we assume that the scanner always
|
|
9
|
+
produces the correct angle. This may or may not be true, but besides the point.
|
|
10
|
+
Any corrections are applied at Optics class level.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
class LaserScanningOptics:
|
|
14
|
+
def __init__(self,
|
|
15
|
+
objective_focal_length: str,
|
|
16
|
+
relay_magnification: float) -> None:
|
|
17
|
+
"""
|
|
18
|
+
fast_axis_correction (float): extends scan by factor to correct mag error
|
|
19
|
+
"""
|
|
20
|
+
self._objective_focal_length = units.Position(objective_focal_length)
|
|
21
|
+
self._relay_magnification = float(relay_magnification)
|
|
22
|
+
|
|
23
|
+
# load line width calibration, TODO
|
|
24
|
+
|
|
25
|
+
# Load stage-scanner angle
|
|
26
|
+
self.stage_scanner_angle = io.load_stage_scanner_angle()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def objective_focal_length(self) -> units.Position:
|
|
31
|
+
"""Returns the objective focal length."""
|
|
32
|
+
return self._objective_focal_length
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def relay_magnification(self) -> float:
|
|
36
|
+
"""Returns the scan relay system (typically: scan lens + tube lens)
|
|
37
|
+
lateral magnification.
|
|
38
|
+
"""
|
|
39
|
+
return self._relay_magnification
|
|
40
|
+
|
|
41
|
+
def scan_angle_to_object_position(self,
|
|
42
|
+
angle: units.Angle,
|
|
43
|
+
axis: Optional[str] = None
|
|
44
|
+
) -> units.Position:
|
|
45
|
+
"""
|
|
46
|
+
Return the focus position for a certain scanner angle (optical).
|
|
47
|
+
|
|
48
|
+
Specify axis ('fast', 'slow') to invoke correction factor.
|
|
49
|
+
"""
|
|
50
|
+
objective_angle = angle / self.relay_magnification
|
|
51
|
+
position = float(objective_angle) * self.objective_focal_length
|
|
52
|
+
return units.Position(position)
|
|
53
|
+
|
|
54
|
+
def object_position_to_scan_angle(self,
|
|
55
|
+
position: units.Position) -> units.Angle:
|
|
56
|
+
"""
|
|
57
|
+
Return the scanner angle (optical) required for a certain focus position.
|
|
58
|
+
|
|
59
|
+
Specify axis ('fast', 'slow') to invoke correction factor.
|
|
60
|
+
"""
|
|
61
|
+
objective_angle = position / self.objective_focal_length
|
|
62
|
+
angle = objective_angle * self.relay_magnification
|
|
63
|
+
|
|
64
|
+
return units.Angle(angle)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CameraOptics:
|
|
68
|
+
"""
|
|
69
|
+
Optics to use with a parallel array of detectors, usually an image sensor.
|
|
70
|
+
"""
|
|
71
|
+
def __init__(self, magnification: float | int, **kwargs):
|
|
72
|
+
if not (isinstance(magnification, float) or isinstance(magnification, int) ):
|
|
73
|
+
raise ValueError("Magnification must be a float or an integer")
|
|
74
|
+
self._magnification = float(magnification)
|
|
75
|
+
|
|
76
|
+
self.stage_camera_angle = units.Angle(0) # TODO load this from calibration
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def magnification(self) -> float:
|
|
80
|
+
return self._magnification
|
|
81
|
+
|
|
82
|
+
|