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.
Files changed (45) 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 +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -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 +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -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 +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {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