citrascope 0.6.1__py3-none-any.whl → 0.8.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.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +97 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- citrascope/hardware/adapter_registry.py +10 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +67 -96
- citrascope/hardware/nina_adv_http_adapter.py +81 -64
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -0
- citrascope/tasks/runner.py +105 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +13 -0
- citrascope/time/time_health.py +96 -0
- citrascope/time/time_monitor.py +164 -0
- citrascope/time/time_sources.py +62 -0
- citrascope/web/app.py +274 -51
- citrascope/web/static/app.js +379 -36
- citrascope/web/static/config.js +448 -108
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.6.1.dist-info/RECORD +0 -41
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""Ximea hyperspectral imaging camera adapter."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, cast
|
|
7
|
+
|
|
8
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import SettingSchemaEntry
|
|
9
|
+
from citrascope.hardware.devices.camera import AbstractCamera
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class XimeaHyperspectralCamera(AbstractCamera):
|
|
13
|
+
"""Adapter for Ximea hyperspectral imaging cameras.
|
|
14
|
+
|
|
15
|
+
Supports Ximea MQ series cameras with snapshot mosaic hyperspectral sensors.
|
|
16
|
+
Requires ximea-api package (xiAPI Python wrapper).
|
|
17
|
+
|
|
18
|
+
Configuration:
|
|
19
|
+
serial_number (str): Camera serial number for multi-camera setups
|
|
20
|
+
default_gain (float): Default gain in dB
|
|
21
|
+
default_exposure_ms (float): Default exposure time in milliseconds
|
|
22
|
+
spectral_bands (int): Number of spectral bands (e.g., 16 for SM4X4, 25 for SM5X5)
|
|
23
|
+
output_format (str): Output format - 'raw' (2D mosaic) or 'datacube' (3D bands)
|
|
24
|
+
vertical_flip (bool): Flip image vertically
|
|
25
|
+
horizontal_flip (bool): Flip image horizontally
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_friendly_name(cls) -> str:
|
|
30
|
+
"""Return human-readable name for this camera device.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Friendly display name
|
|
34
|
+
"""
|
|
35
|
+
return "Ximea Hyperspectral Camera (MQ Series)"
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def get_dependencies(cls) -> dict[str, str | list[str]]:
|
|
39
|
+
"""Return required Python packages.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Dict with packages and install extra
|
|
43
|
+
"""
|
|
44
|
+
return {
|
|
45
|
+
"packages": ["ximea"],
|
|
46
|
+
"install_extra": "ximea",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
51
|
+
"""Return schema for Ximea camera settings.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of setting schema entries (without 'camera_' prefix)
|
|
55
|
+
"""
|
|
56
|
+
schema = [
|
|
57
|
+
{
|
|
58
|
+
"name": "serial_number",
|
|
59
|
+
"friendly_name": "Camera Serial Number",
|
|
60
|
+
"type": "str",
|
|
61
|
+
"default": "",
|
|
62
|
+
"description": "Camera serial number (for multi-camera setups)",
|
|
63
|
+
"required": False,
|
|
64
|
+
"placeholder": "Leave empty to auto-detect",
|
|
65
|
+
"group": "Camera",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "default_gain",
|
|
69
|
+
"friendly_name": "Default Gain (dB)",
|
|
70
|
+
"type": "float",
|
|
71
|
+
"default": 0.0,
|
|
72
|
+
"description": "Default camera gain setting in dB",
|
|
73
|
+
"required": False,
|
|
74
|
+
"min": 0.0,
|
|
75
|
+
"max": 24.0,
|
|
76
|
+
"group": "Camera",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "default_exposure_ms",
|
|
80
|
+
"friendly_name": "Default Exposure (ms)",
|
|
81
|
+
"type": "float",
|
|
82
|
+
"default": 100.0,
|
|
83
|
+
"description": "Default exposure time in milliseconds (e.g., 300 = 0.3 seconds)",
|
|
84
|
+
"required": False,
|
|
85
|
+
"min": 0.001,
|
|
86
|
+
"max": 1000.0, # Hardware limitation: ~1 second max for MQ series
|
|
87
|
+
"group": "Camera",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "spectral_bands",
|
|
91
|
+
"friendly_name": "Spectral Bands",
|
|
92
|
+
"type": "int",
|
|
93
|
+
"default": 25,
|
|
94
|
+
"description": "Number of spectral bands (e.g., 25 for MQ022HG-IM-SM5X5)",
|
|
95
|
+
"required": False,
|
|
96
|
+
"min": 1,
|
|
97
|
+
"max": 500,
|
|
98
|
+
"group": "Camera",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "output_format",
|
|
102
|
+
"friendly_name": "Output Format",
|
|
103
|
+
"type": "str",
|
|
104
|
+
"default": "raw",
|
|
105
|
+
"description": "Output format: raw (2D mosaic) or datacube (3D separated bands)",
|
|
106
|
+
"required": False,
|
|
107
|
+
"options": ["raw", "datacube"],
|
|
108
|
+
"group": "Camera",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "vertical_flip",
|
|
112
|
+
"friendly_name": "Vertical Flip",
|
|
113
|
+
"type": "bool",
|
|
114
|
+
"default": False,
|
|
115
|
+
"description": "Flip image vertically (upside down)",
|
|
116
|
+
"required": False,
|
|
117
|
+
"group": "Camera",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "horizontal_flip",
|
|
121
|
+
"friendly_name": "Horizontal Flip",
|
|
122
|
+
"type": "bool",
|
|
123
|
+
"default": False,
|
|
124
|
+
"description": "Flip image horizontally (mirror)",
|
|
125
|
+
"required": False,
|
|
126
|
+
"group": "Camera",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "wavelength_calibration",
|
|
130
|
+
"friendly_name": "Wavelength Calibration (nm)",
|
|
131
|
+
"type": "str",
|
|
132
|
+
"default": "",
|
|
133
|
+
"description": "Comma-separated wavelengths in nanometers for each spectral band (e.g., 470,520,570,620)",
|
|
134
|
+
"required": False,
|
|
135
|
+
"placeholder": "Leave empty if not calibrated",
|
|
136
|
+
"group": "Camera",
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
return cast(list[SettingSchemaEntry], schema)
|
|
140
|
+
|
|
141
|
+
def __init__(self, logger: logging.Logger, **kwargs):
|
|
142
|
+
"""Initialize the Ximea camera.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
logger: Logger instance for this device
|
|
146
|
+
**kwargs: Configuration including serial_number, default_gain, etc.
|
|
147
|
+
"""
|
|
148
|
+
super().__init__(logger, **kwargs)
|
|
149
|
+
|
|
150
|
+
self.serial_number: Optional[str] = kwargs.get("serial_number")
|
|
151
|
+
self.default_gain: float = kwargs.get("default_gain", 0.0)
|
|
152
|
+
self.default_exposure_ms: float = kwargs.get("default_exposure_ms", 100.0)
|
|
153
|
+
self.spectral_bands: int = kwargs.get("spectral_bands", 25)
|
|
154
|
+
self.output_format: str = kwargs.get("output_format", "raw")
|
|
155
|
+
self.vertical_flip: bool = kwargs.get("vertical_flip", False)
|
|
156
|
+
self.horizontal_flip: bool = kwargs.get("horizontal_flip", False)
|
|
157
|
+
|
|
158
|
+
# Parse wavelength calibration (comma-separated string to list of floats)
|
|
159
|
+
wavelength_str = kwargs.get("wavelength_calibration", "")
|
|
160
|
+
self.wavelength_calibration: list[float] = []
|
|
161
|
+
if wavelength_str:
|
|
162
|
+
try:
|
|
163
|
+
self.wavelength_calibration = [float(w.strip()) for w in wavelength_str.split(",")]
|
|
164
|
+
if len(self.wavelength_calibration) != self.spectral_bands:
|
|
165
|
+
self.logger.warning(
|
|
166
|
+
f"Wavelength calibration has {len(self.wavelength_calibration)} values "
|
|
167
|
+
f"but spectral_bands is {self.spectral_bands}. Calibration will not be used."
|
|
168
|
+
)
|
|
169
|
+
self.wavelength_calibration = []
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
self.logger.warning(f"Invalid wavelength_calibration format: {e}. Expected comma-separated numbers.")
|
|
172
|
+
self.wavelength_calibration = []
|
|
173
|
+
|
|
174
|
+
# Camera handle (will be initialized on connect)
|
|
175
|
+
self._camera = None
|
|
176
|
+
self._is_connected = False
|
|
177
|
+
|
|
178
|
+
# Camera info cache
|
|
179
|
+
self._camera_info = {}
|
|
180
|
+
|
|
181
|
+
def connect(self) -> bool:
|
|
182
|
+
"""Connect to the Ximea camera.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if connection successful, False otherwise
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
# Import ximea API (lazy import to avoid hard dependency)
|
|
189
|
+
try:
|
|
190
|
+
from ximea import xiapi
|
|
191
|
+
except ImportError:
|
|
192
|
+
self.logger.error(
|
|
193
|
+
"XIMEA Python bindings not found. Installation instructions:\n"
|
|
194
|
+
"1. Download and mount XIMEA macOS Software Package from:\n"
|
|
195
|
+
" https://www.ximea.com/support/wiki/apis/XIMEA_macOS_Software_Package\n"
|
|
196
|
+
"2. Run the installer on the mounted volume\n"
|
|
197
|
+
"3. Copy Python bindings to your venv:\n"
|
|
198
|
+
" cp -r /Volumes/XIMEA/Examples/xiPython/v3/ximea $VIRTUAL_ENV/lib/python*/site-packages/\n"
|
|
199
|
+
"4. Verify installation: python -c 'import ximea; print(ximea.__version__)'\n"
|
|
200
|
+
"Note: The XIMEA bindings are not available via pip and must be installed manually."
|
|
201
|
+
)
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
self.logger.info("Connecting to Ximea hyperspectral camera...")
|
|
205
|
+
self.logger.debug("Ximea xiapi module imported successfully")
|
|
206
|
+
|
|
207
|
+
# Create camera instance
|
|
208
|
+
self.logger.debug("Creating Ximea camera instance...")
|
|
209
|
+
self._camera = xiapi.Camera()
|
|
210
|
+
self.logger.debug("Camera instance created")
|
|
211
|
+
|
|
212
|
+
# Open camera (by serial number if specified)
|
|
213
|
+
if self.serial_number:
|
|
214
|
+
self.logger.info(f"Opening camera with serial number: {self.serial_number}")
|
|
215
|
+
try:
|
|
216
|
+
self._camera.open_device_by_SN(self.serial_number)
|
|
217
|
+
self.logger.debug(f"Camera with SN {self.serial_number} opened successfully")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self.logger.error(f"Failed to open camera by serial number {self.serial_number}: {e}")
|
|
220
|
+
raise
|
|
221
|
+
else:
|
|
222
|
+
self.logger.info("Opening first available Ximea camera (no serial number specified)")
|
|
223
|
+
try:
|
|
224
|
+
self._camera.open_device()
|
|
225
|
+
self.logger.debug("First available camera opened successfully")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
self.logger.error(f"Failed to open first available camera: {e}")
|
|
228
|
+
self.logger.info("Make sure camera is connected and no other application is using it")
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
# Configure camera
|
|
232
|
+
self.logger.debug("Configuring camera...")
|
|
233
|
+
self._configure_camera()
|
|
234
|
+
self.logger.debug("Camera configuration complete")
|
|
235
|
+
|
|
236
|
+
# Cache camera info
|
|
237
|
+
self.logger.debug("Reading camera info...")
|
|
238
|
+
self._camera_info = self._read_camera_info()
|
|
239
|
+
self.logger.debug(f"Camera info: {self._camera_info}")
|
|
240
|
+
|
|
241
|
+
self._is_connected = True
|
|
242
|
+
self.logger.info(
|
|
243
|
+
f"Connected to Ximea camera: {self._camera_info.get('model', 'Unknown')} "
|
|
244
|
+
f"(SN: {self._camera_info.get('serial_number', 'Unknown')})"
|
|
245
|
+
)
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
self.logger.error(f"Failed to connect to Ximea camera: {e}", exc_info=True)
|
|
250
|
+
self._is_connected = False
|
|
251
|
+
if self._camera is not None:
|
|
252
|
+
try:
|
|
253
|
+
self._camera.close_device()
|
|
254
|
+
except Exception as close_error:
|
|
255
|
+
self.logger.debug(f"Error closing camera after failed connection: {close_error}")
|
|
256
|
+
self._camera = None
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def disconnect(self):
|
|
260
|
+
"""Disconnect from the Ximea camera."""
|
|
261
|
+
if self._camera is not None:
|
|
262
|
+
try:
|
|
263
|
+
self.logger.info("Disconnecting from Ximea camera...")
|
|
264
|
+
self._camera.close_device()
|
|
265
|
+
self._is_connected = False
|
|
266
|
+
self.logger.info("Ximea camera disconnected")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
self.logger.error(f"Error disconnecting from Ximea camera: {e}")
|
|
269
|
+
finally:
|
|
270
|
+
self._camera = None
|
|
271
|
+
|
|
272
|
+
def is_connected(self) -> bool:
|
|
273
|
+
"""Check if camera is connected and responsive.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if connected, False otherwise
|
|
277
|
+
"""
|
|
278
|
+
return self._is_connected and self._camera is not None
|
|
279
|
+
|
|
280
|
+
def take_exposure(
|
|
281
|
+
self,
|
|
282
|
+
duration: float,
|
|
283
|
+
gain: Optional[int] = None,
|
|
284
|
+
offset: Optional[int] = None,
|
|
285
|
+
binning: int = 1,
|
|
286
|
+
save_path: Optional[Path] = None,
|
|
287
|
+
) -> Path:
|
|
288
|
+
"""Capture a hyperspectral image exposure.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
duration: Exposure duration in seconds
|
|
292
|
+
gain: Camera gain in dB (if None, use default)
|
|
293
|
+
offset: Not used for Ximea cameras
|
|
294
|
+
binning: Pixel binning factor (1=no binning, 2=2x2, etc.)
|
|
295
|
+
save_path: Optional path to save the image
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Path to the saved image file
|
|
299
|
+
"""
|
|
300
|
+
if not self.is_connected():
|
|
301
|
+
raise RuntimeError("Camera not connected")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
from ximea import xiapi
|
|
305
|
+
except ImportError:
|
|
306
|
+
raise RuntimeError("ximea-api package not installed")
|
|
307
|
+
|
|
308
|
+
self.logger.info(
|
|
309
|
+
f"Starting hyperspectral exposure: {duration}s, "
|
|
310
|
+
f"gain={gain if gain is not None else self.default_gain}dB, "
|
|
311
|
+
f"binning={binning}x{binning}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Configure exposure parameters (xiAPI expects microseconds)
|
|
315
|
+
exposure_us = duration * 1000000.0
|
|
316
|
+
self.logger.debug(f"Setting exposure to {int(exposure_us)} microseconds ({duration}s)")
|
|
317
|
+
self._camera.set_exposure(int(exposure_us))
|
|
318
|
+
actual_exposure = self._camera.get_exposure()
|
|
319
|
+
self.logger.debug(f"Exposure set and verified: {actual_exposure} microseconds")
|
|
320
|
+
|
|
321
|
+
# Store for FITS metadata
|
|
322
|
+
self._last_exposure_us = actual_exposure
|
|
323
|
+
|
|
324
|
+
# Set gain (use default if not specified)
|
|
325
|
+
gain_to_use = gain if gain is not None else self.default_gain
|
|
326
|
+
try:
|
|
327
|
+
# Check if camera supports gain and get valid range
|
|
328
|
+
min_gain = self._camera.get_gain_minimum()
|
|
329
|
+
max_gain = self._camera.get_gain_maximum()
|
|
330
|
+
|
|
331
|
+
# Clamp gain to valid range
|
|
332
|
+
if gain_to_use < min_gain or gain_to_use > max_gain:
|
|
333
|
+
self.logger.warning(
|
|
334
|
+
f"Requested gain {gain_to_use} dB is outside valid range "
|
|
335
|
+
f"[{min_gain}, {max_gain}] dB. Clamping to valid range."
|
|
336
|
+
)
|
|
337
|
+
gain_to_use = max(min_gain, min(gain_to_use, max_gain))
|
|
338
|
+
|
|
339
|
+
self.logger.debug(f"Setting gain to {gain_to_use} dB (range: {min_gain}-{max_gain} dB)")
|
|
340
|
+
self._camera.set_gain(float(gain_to_use))
|
|
341
|
+
actual_gain = self._camera.get_gain()
|
|
342
|
+
self.logger.debug(f"Gain set and verified: {actual_gain} dB")
|
|
343
|
+
|
|
344
|
+
# Store for FITS metadata
|
|
345
|
+
self._last_gain_db = actual_gain
|
|
346
|
+
except Exception as e:
|
|
347
|
+
self.logger.warning(f"Could not set gain: {e}. Continuing with current camera gain setting.")
|
|
348
|
+
|
|
349
|
+
if binning > 1:
|
|
350
|
+
self.logger.debug(f"Setting downsampling to {binning}x{binning}")
|
|
351
|
+
self._camera.set_downsampling(str(binning))
|
|
352
|
+
actual_binning = self._camera.get_downsampling()
|
|
353
|
+
self.logger.debug(f"Downsampling set and verified: {actual_binning}")
|
|
354
|
+
|
|
355
|
+
# Create image buffer
|
|
356
|
+
img = xiapi.Image()
|
|
357
|
+
|
|
358
|
+
# Start acquisition
|
|
359
|
+
self.logger.debug("Starting camera acquisition...")
|
|
360
|
+
self._camera.start_acquisition()
|
|
361
|
+
self.logger.debug("Acquisition started")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
# Get image (timeout in milliseconds: exposure time + 5 second buffer)
|
|
365
|
+
timeout_ms = int((exposure_us / 1000.0) + 5000)
|
|
366
|
+
self.logger.debug(f"Waiting for image with timeout of {timeout_ms}ms...")
|
|
367
|
+
start_time = time.time()
|
|
368
|
+
self._camera.get_image(img, timeout=timeout_ms)
|
|
369
|
+
capture_time = time.time() - start_time
|
|
370
|
+
self.logger.debug(f"Image captured in {capture_time:.2f}s")
|
|
371
|
+
self.logger.debug(f"Image size: {img.width}x{img.height}, format: {img.frm}")
|
|
372
|
+
|
|
373
|
+
# Generate save path
|
|
374
|
+
if save_path is None:
|
|
375
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
376
|
+
save_path = Path(f"ximea_hyperspectral_{timestamp}.tiff")
|
|
377
|
+
|
|
378
|
+
# Save image (format depends on output_format setting)
|
|
379
|
+
self.logger.debug(f"Saving image to: {save_path}")
|
|
380
|
+
self._save_hyperspectral_image(img, save_path)
|
|
381
|
+
|
|
382
|
+
self.logger.info(f"Hyperspectral image saved to: {save_path}")
|
|
383
|
+
return save_path
|
|
384
|
+
|
|
385
|
+
finally:
|
|
386
|
+
# Stop acquisition
|
|
387
|
+
self.logger.debug("Stopping acquisition...")
|
|
388
|
+
self._camera.stop_acquisition()
|
|
389
|
+
self.logger.debug("Acquisition stopped")
|
|
390
|
+
|
|
391
|
+
# Reset binning if changed
|
|
392
|
+
if binning > 1:
|
|
393
|
+
self.logger.debug("Resetting downsampling to 1")
|
|
394
|
+
self._camera.set_downsampling("1")
|
|
395
|
+
|
|
396
|
+
def abort_exposure(self):
|
|
397
|
+
"""Abort the current exposure if one is in progress."""
|
|
398
|
+
if self.is_connected() and self._camera is not None:
|
|
399
|
+
try:
|
|
400
|
+
self._camera.stop_acquisition()
|
|
401
|
+
self.logger.info("Ximea exposure aborted")
|
|
402
|
+
except Exception as e:
|
|
403
|
+
self.logger.error(f"Error aborting exposure: {e}")
|
|
404
|
+
|
|
405
|
+
def get_temperature(self) -> Optional[float]:
|
|
406
|
+
"""Get the current camera sensor temperature.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Temperature in degrees Celsius, or None if not available
|
|
410
|
+
"""
|
|
411
|
+
if not self.is_connected():
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
# Ximea cameras report temperature in Celsius
|
|
416
|
+
temp = self._camera.get_temp()
|
|
417
|
+
return float(temp)
|
|
418
|
+
except Exception as e:
|
|
419
|
+
self.logger.warning(f"Could not read camera temperature: {e}")
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
def set_temperature(self, temperature: float) -> bool:
|
|
423
|
+
"""Set the target camera sensor temperature.
|
|
424
|
+
|
|
425
|
+
Note: Most Ximea cameras do not support active cooling.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
temperature: Target temperature in degrees Celsius
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
False (Ximea cameras typically don't support temperature control)
|
|
432
|
+
"""
|
|
433
|
+
self.logger.warning("Ximea cameras do not support temperature control")
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
def start_cooling(self) -> bool:
|
|
437
|
+
"""Enable camera cooling system.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
False (Ximea cameras typically don't have active cooling)
|
|
441
|
+
"""
|
|
442
|
+
self.logger.warning("Ximea cameras do not have active cooling")
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
def stop_cooling(self) -> bool:
|
|
446
|
+
"""Disable camera cooling system.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
False (Ximea cameras typically don't have active cooling)
|
|
450
|
+
"""
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
def is_hyperspectral(self) -> bool:
|
|
454
|
+
"""Indicates whether this camera captures hyperspectral data.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
bool: True (Ximea MQ cameras are hyperspectral)
|
|
458
|
+
"""
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
def get_camera_info(self) -> dict:
|
|
462
|
+
"""Get camera capabilities and information.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Dictionary containing camera specs
|
|
466
|
+
"""
|
|
467
|
+
return self._camera_info.copy()
|
|
468
|
+
|
|
469
|
+
# Helper methods
|
|
470
|
+
|
|
471
|
+
def _configure_camera(self):
|
|
472
|
+
"""Configure camera with default settings."""
|
|
473
|
+
if self._camera is None:
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
# Set default exposure (xiAPI expects microseconds)
|
|
478
|
+
default_exposure_us = self.default_exposure_ms * 1000
|
|
479
|
+
self._camera.set_exposure(int(default_exposure_us))
|
|
480
|
+
|
|
481
|
+
# Set default gain
|
|
482
|
+
try:
|
|
483
|
+
min_gain = self._camera.get_gain_minimum()
|
|
484
|
+
max_gain = self._camera.get_gain_maximum()
|
|
485
|
+
gain_to_set = max(min_gain, min(self.default_gain, max_gain))
|
|
486
|
+
self._camera.set_gain(gain_to_set)
|
|
487
|
+
self.logger.debug(f"Gain set to {gain_to_set} dB (range: {min_gain}-{max_gain} dB)")
|
|
488
|
+
except Exception as e:
|
|
489
|
+
self.logger.warning(f"Could not set default gain: {e}")
|
|
490
|
+
|
|
491
|
+
# Set image format
|
|
492
|
+
# For hyperspectral, typically use RAW16 or RAW8
|
|
493
|
+
self._camera.set_imgdataformat("XI_RAW16")
|
|
494
|
+
|
|
495
|
+
# Set image orientation
|
|
496
|
+
if self.vertical_flip:
|
|
497
|
+
self.logger.debug("Enabling vertical flip")
|
|
498
|
+
self._camera.enable_vertical_flip()
|
|
499
|
+
else:
|
|
500
|
+
self._camera.disable_vertical_flip()
|
|
501
|
+
|
|
502
|
+
if self.horizontal_flip:
|
|
503
|
+
self.logger.debug("Enabling horizontal flip")
|
|
504
|
+
self._camera.enable_horizontal_flip()
|
|
505
|
+
else:
|
|
506
|
+
self._camera.disable_horizontal_flip()
|
|
507
|
+
|
|
508
|
+
self.logger.info("Ximea camera configured with default settings")
|
|
509
|
+
|
|
510
|
+
except Exception as e:
|
|
511
|
+
self.logger.warning(f"Error configuring camera settings: {e}")
|
|
512
|
+
|
|
513
|
+
def _read_camera_info(self) -> dict:
|
|
514
|
+
"""Read camera information and capabilities."""
|
|
515
|
+
info = {}
|
|
516
|
+
|
|
517
|
+
if self._camera is None:
|
|
518
|
+
return info
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
info["model"] = (
|
|
522
|
+
self._camera.get_device_name().decode()
|
|
523
|
+
if hasattr(self._camera.get_device_name(), "decode")
|
|
524
|
+
else str(self._camera.get_device_name())
|
|
525
|
+
)
|
|
526
|
+
info["serial_number"] = (
|
|
527
|
+
self._camera.get_device_sn().decode()
|
|
528
|
+
if hasattr(self._camera.get_device_sn(), "decode")
|
|
529
|
+
else str(self._camera.get_device_sn())
|
|
530
|
+
)
|
|
531
|
+
info["width"] = self._camera.get_width()
|
|
532
|
+
info["height"] = self._camera.get_height()
|
|
533
|
+
info["pixel_size_um"] = 3.45 # MQ series typically 3.45µm
|
|
534
|
+
info["bit_depth"] = 12 # MQ series typically 12-bit
|
|
535
|
+
info["spectral_bands"] = self.spectral_bands
|
|
536
|
+
info["type"] = "hyperspectral"
|
|
537
|
+
|
|
538
|
+
except Exception as e:
|
|
539
|
+
self.logger.warning(f"Error reading camera info: {e}")
|
|
540
|
+
|
|
541
|
+
return info
|
|
542
|
+
|
|
543
|
+
def _save_hyperspectral_image(self, img, save_path: Path):
|
|
544
|
+
"""Save hyperspectral image data.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
img: Ximea image object
|
|
548
|
+
save_path: Path to save the image
|
|
549
|
+
"""
|
|
550
|
+
import numpy as np
|
|
551
|
+
|
|
552
|
+
# Get image data as numpy array
|
|
553
|
+
data = img.get_image_data_numpy()
|
|
554
|
+
|
|
555
|
+
# Process based on output_format setting
|
|
556
|
+
if self.output_format == "datacube":
|
|
557
|
+
# Create 3D datacube by demosaicing the spectral mosaic
|
|
558
|
+
datacube = self._demosaic_to_datacube(data)
|
|
559
|
+
|
|
560
|
+
# Save as multi-extension FITS (one extension per spectral band)
|
|
561
|
+
try:
|
|
562
|
+
# Create primary HDU with basic metadata
|
|
563
|
+
from datetime import datetime, timezone
|
|
564
|
+
|
|
565
|
+
from astropy.io import fits
|
|
566
|
+
|
|
567
|
+
primary_hdu = fits.PrimaryHDU()
|
|
568
|
+
# Hyperspectral metadata
|
|
569
|
+
primary_hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
|
|
570
|
+
primary_hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
|
|
571
|
+
primary_hdu.header["HIERARCH OUTPUT_FORMAT"] = "datacube"
|
|
572
|
+
primary_hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
|
|
573
|
+
|
|
574
|
+
# Capture metadata
|
|
575
|
+
if hasattr(self, "_last_exposure_us"):
|
|
576
|
+
primary_hdu.header["EXPTIME"] = self._last_exposure_us / 1000000.0 # seconds
|
|
577
|
+
if hasattr(self, "_last_gain_db"):
|
|
578
|
+
primary_hdu.header["GAIN"] = self._last_gain_db
|
|
579
|
+
primary_hdu.header["DATE-OBS"] = datetime.now(timezone.utc).isoformat()
|
|
580
|
+
|
|
581
|
+
# Camera metadata
|
|
582
|
+
if self._camera_info:
|
|
583
|
+
if "serial_number" in self._camera_info:
|
|
584
|
+
primary_hdu.header["CAMSER"] = self._camera_info["serial_number"]
|
|
585
|
+
if "model" in self._camera_info:
|
|
586
|
+
primary_hdu.header["INSTRUME"] = self._camera_info["model"]
|
|
587
|
+
|
|
588
|
+
# Wavelength calibration metadata in primary header (if available)
|
|
589
|
+
if self.wavelength_calibration:
|
|
590
|
+
primary_hdu.header["HIERARCH WAVELENGTH_UNIT"] = "nm"
|
|
591
|
+
primary_hdu.header["HIERARCH WAVELENGTH_COUNT"] = len(self.wavelength_calibration)
|
|
592
|
+
|
|
593
|
+
# Create image HDU for each spectral band
|
|
594
|
+
hdu_list = [primary_hdu]
|
|
595
|
+
for i in range(datacube.shape[2]):
|
|
596
|
+
band_hdu = fits.ImageHDU(datacube[:, :, i], name=f"BAND_{i:03d}")
|
|
597
|
+
band_hdu.header["BANDNUM"] = i
|
|
598
|
+
|
|
599
|
+
# Add wavelength information if calibrated
|
|
600
|
+
if self.wavelength_calibration and i < len(self.wavelength_calibration):
|
|
601
|
+
band_hdu.header["WAVELENG"] = self.wavelength_calibration[i]
|
|
602
|
+
band_hdu.header["WAVEUNIT"] = "nm"
|
|
603
|
+
|
|
604
|
+
hdu_list.append(band_hdu)
|
|
605
|
+
|
|
606
|
+
hdul = fits.HDUList(hdu_list)
|
|
607
|
+
hdul.writeto(save_path, overwrite=True)
|
|
608
|
+
self.logger.debug(f"Saved hyperspectral datacube as multi-extension FITS: {save_path}")
|
|
609
|
+
except ImportError:
|
|
610
|
+
# Fallback: save as 3D numpy array
|
|
611
|
+
np.save(save_path.with_suffix(".npy"), datacube)
|
|
612
|
+
self.logger.warning("astropy not available, saved datacube as .npy")
|
|
613
|
+
|
|
614
|
+
else: # "raw" or default
|
|
615
|
+
# Save raw mosaic as-is
|
|
616
|
+
self._save_raw_image(data, save_path)
|
|
617
|
+
|
|
618
|
+
def _save_raw_image(self, data, save_path: Path):
|
|
619
|
+
"""Save raw image data based on file extension.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
data: Numpy array of image data
|
|
623
|
+
save_path: Path to save the image
|
|
624
|
+
"""
|
|
625
|
+
import numpy as np
|
|
626
|
+
|
|
627
|
+
suffix = save_path.suffix.lower()
|
|
628
|
+
|
|
629
|
+
if suffix == ".fits":
|
|
630
|
+
try:
|
|
631
|
+
from datetime import datetime, timezone
|
|
632
|
+
|
|
633
|
+
from astropy.io import fits
|
|
634
|
+
|
|
635
|
+
hdu = fits.PrimaryHDU(data)
|
|
636
|
+
# Hyperspectral metadata
|
|
637
|
+
hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
|
|
638
|
+
hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
|
|
639
|
+
hdu.header["HIERARCH OUTPUT_FORMAT"] = "raw"
|
|
640
|
+
hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
|
|
641
|
+
|
|
642
|
+
# Capture metadata
|
|
643
|
+
if hasattr(self, "_last_exposure_us"):
|
|
644
|
+
hdu.header["EXPTIME"] = self._last_exposure_us / 1000000.0 # seconds
|
|
645
|
+
if hasattr(self, "_last_gain_db"):
|
|
646
|
+
hdu.header["GAIN"] = self._last_gain_db
|
|
647
|
+
hdu.header["DATE-OBS"] = datetime.now(timezone.utc).isoformat()
|
|
648
|
+
|
|
649
|
+
# Camera metadata
|
|
650
|
+
if self._camera_info:
|
|
651
|
+
if "serial_number" in self._camera_info:
|
|
652
|
+
hdu.header["CAMSER"] = self._camera_info["serial_number"]
|
|
653
|
+
if "model" in self._camera_info:
|
|
654
|
+
hdu.header["INSTRUME"] = self._camera_info["model"]
|
|
655
|
+
|
|
656
|
+
# Wavelength calibration metadata (if available)
|
|
657
|
+
if self.wavelength_calibration:
|
|
658
|
+
hdu.header["HIERARCH WAVELENGTH_UNIT"] = "nm"
|
|
659
|
+
hdu.header["HIERARCH WAVELENGTH_COUNT"] = len(self.wavelength_calibration)
|
|
660
|
+
# Store wavelengths as comma-separated values (FITS has 80 char limit per line)
|
|
661
|
+
wavelength_str = ",".join(str(w) for w in self.wavelength_calibration)
|
|
662
|
+
# Split into multiple HIERARCH keywords if needed
|
|
663
|
+
if len(wavelength_str) <= 68: # 80 - len('HIERARCH WAVELENGTHS = ')
|
|
664
|
+
hdu.header["HIERARCH WAVELENGTHS"] = wavelength_str
|
|
665
|
+
else:
|
|
666
|
+
# Split into chunks
|
|
667
|
+
chunk_size = 68
|
|
668
|
+
for i, chunk_start in enumerate(range(0, len(wavelength_str), chunk_size)):
|
|
669
|
+
chunk = wavelength_str[chunk_start : chunk_start + chunk_size]
|
|
670
|
+
hdu.header[f"HIERARCH WAVELENGTHS_{i}"] = chunk
|
|
671
|
+
|
|
672
|
+
hdu.writeto(save_path, overwrite=True)
|
|
673
|
+
self.logger.debug(f"Saved hyperspectral image as FITS: {save_path}")
|
|
674
|
+
except ImportError:
|
|
675
|
+
np.save(save_path.with_suffix(".npy"), data)
|
|
676
|
+
self.logger.warning("astropy not available, saved as .npy instead of FITS")
|
|
677
|
+
elif suffix in [".tif", ".tiff"]:
|
|
678
|
+
try:
|
|
679
|
+
from PIL import Image
|
|
680
|
+
|
|
681
|
+
pil_img = Image.fromarray(data)
|
|
682
|
+
pil_img.save(save_path)
|
|
683
|
+
self.logger.debug(f"Saved hyperspectral image as TIFF: {save_path}")
|
|
684
|
+
except ImportError:
|
|
685
|
+
np.save(save_path.with_suffix(".npy"), data)
|
|
686
|
+
self.logger.warning("PIL not available, saved as .npy instead of TIFF")
|
|
687
|
+
else:
|
|
688
|
+
# Default: save as numpy array
|
|
689
|
+
np.save(save_path.with_suffix(".npy"), data)
|
|
690
|
+
self.logger.debug(f"Saved hyperspectral image as numpy array: {save_path.with_suffix('.npy')}")
|
|
691
|
+
|
|
692
|
+
def _demosaic_to_datacube(self, mosaic_data):
|
|
693
|
+
"""Demosaic snapshot mosaic into spectral datacube.
|
|
694
|
+
|
|
695
|
+
Extracts individual spectral bands from a snapshot mosaic pattern.
|
|
696
|
+
The mosaic must be a perfect square (N×N pattern) where N² = spectral_bands.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
mosaic_data: 2D numpy array with spectral mosaic pattern
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
3D numpy array (height, width, bands) with separated spectral bands
|
|
703
|
+
|
|
704
|
+
Raises:
|
|
705
|
+
ValueError: If spectral_bands is not a perfect square
|
|
706
|
+
"""
|
|
707
|
+
import math
|
|
708
|
+
|
|
709
|
+
import numpy as np
|
|
710
|
+
|
|
711
|
+
# Calculate pattern size from spectral_bands using square root
|
|
712
|
+
# This works for any N×N pattern (4×4=16, 5×5=25, 6×6=36, 7×7=49, 8×8=64, etc.)
|
|
713
|
+
pattern_size = int(math.sqrt(self.spectral_bands))
|
|
714
|
+
|
|
715
|
+
# Validate that spectral_bands is a perfect square
|
|
716
|
+
if pattern_size * pattern_size != self.spectral_bands:
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"spectral_bands ({self.spectral_bands}) is not a perfect square. "
|
|
719
|
+
f"Snapshot mosaic sensors must have N×N pattern (e.g., 4×4=16, 5×5=25, 6×6=36). "
|
|
720
|
+
f"Nearest valid values: {(pattern_size)**2} ({pattern_size}×{pattern_size}) "
|
|
721
|
+
f"or {(pattern_size+1)**2} ({pattern_size+1}×{pattern_size+1})."
|
|
722
|
+
)
|
|
723
|
+
# Calculate output dimensions (spatial resolution reduced by pattern size)
|
|
724
|
+
out_height = height // pattern_size
|
|
725
|
+
out_width = width // pattern_size
|
|
726
|
+
num_bands = pattern_size * pattern_size
|
|
727
|
+
|
|
728
|
+
# Create output datacube
|
|
729
|
+
datacube = np.zeros((out_height, out_width, num_bands), dtype=mosaic_data.dtype)
|
|
730
|
+
|
|
731
|
+
# Extract each spectral band from the mosaic
|
|
732
|
+
for band_idx in range(num_bands):
|
|
733
|
+
row_offset = band_idx // pattern_size
|
|
734
|
+
col_offset = band_idx % pattern_size
|
|
735
|
+
|
|
736
|
+
# Extract this band's pixels from the mosaic
|
|
737
|
+
band_data = mosaic_data[row_offset::pattern_size, col_offset::pattern_size]
|
|
738
|
+
|
|
739
|
+
# Handle size mismatch (if image dimensions aren't perfect multiples)
|
|
740
|
+
datacube[:, :, band_idx] = band_data[:out_height, :out_width]
|
|
741
|
+
|
|
742
|
+
self.logger.debug(f"Demosaiced {height}x{width} mosaic into {out_height}x{out_width}x{num_bands} datacube")
|
|
743
|
+
|
|
744
|
+
return datacube
|