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