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,102 @@
|
|
|
1
|
+
"""Abstract camera device interface."""
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AbstractCamera(AbstractHardwareDevice):
|
|
11
|
+
"""Abstract base class for camera devices.
|
|
12
|
+
|
|
13
|
+
Provides a common interface for controlling imaging cameras including
|
|
14
|
+
CCDs, CMOS sensors, and hyperspectral cameras.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def take_exposure(
|
|
19
|
+
self,
|
|
20
|
+
duration: float,
|
|
21
|
+
gain: Optional[int] = None,
|
|
22
|
+
offset: Optional[int] = None,
|
|
23
|
+
binning: int = 1,
|
|
24
|
+
save_path: Optional[Path] = None,
|
|
25
|
+
) -> Path:
|
|
26
|
+
"""Capture an image exposure.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
duration: Exposure duration in seconds
|
|
30
|
+
gain: Camera gain setting (device-specific units)
|
|
31
|
+
offset: Camera offset/black level setting
|
|
32
|
+
binning: Pixel binning factor (1=no binning, 2=2x2, etc.)
|
|
33
|
+
save_path: Optional path to save the image (if None, use default)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to the saved image file
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def abort_exposure(self):
|
|
42
|
+
"""Abort the current exposure if one is in progress."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get_temperature(self) -> Optional[float]:
|
|
47
|
+
"""Get the current camera sensor temperature.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Temperature in degrees Celsius, or None if not available
|
|
51
|
+
"""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def set_temperature(self, temperature: float) -> bool:
|
|
56
|
+
"""Set the target camera sensor temperature.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
temperature: Target temperature in degrees Celsius
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if temperature setpoint accepted, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def start_cooling(self) -> bool:
|
|
68
|
+
"""Enable camera cooling system.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if cooling started successfully, False otherwise
|
|
72
|
+
"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def stop_cooling(self) -> bool:
|
|
77
|
+
"""Disable camera cooling system.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if cooling stopped successfully, False otherwise
|
|
81
|
+
"""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def get_camera_info(self) -> dict:
|
|
86
|
+
"""Get camera capabilities and information.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Dictionary containing camera specs (resolution, pixel size, bit depth, etc.)
|
|
90
|
+
"""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def is_hyperspectral(self) -> bool:
|
|
94
|
+
"""Indicates whether this camera captures hyperspectral data.
|
|
95
|
+
|
|
96
|
+
Hyperspectral cameras capture multiple spectral bands simultaneously
|
|
97
|
+
(e.g., snapshot mosaic sensors like Ximea MQ series).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
bool: True if hyperspectral camera, False otherwise (default)
|
|
101
|
+
"""
|
|
102
|
+
return False
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Raspberry Pi High Quality 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 RaspberryPiHQCamera(AbstractCamera):
|
|
13
|
+
"""Adapter for Raspberry Pi High Quality Camera (12.3MP IMX477 sensor).
|
|
14
|
+
|
|
15
|
+
Uses the picamera2 library for camera control. Supports long exposures
|
|
16
|
+
suitable for astrophotography.
|
|
17
|
+
|
|
18
|
+
Configuration:
|
|
19
|
+
default_gain (float): Default analog gain (default: 1.0)
|
|
20
|
+
default_exposure_ms (float): Default exposure time in milliseconds
|
|
21
|
+
output_format (str): Output format - 'fits', 'png', 'jpg', 'raw'
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def get_friendly_name(cls) -> str:
|
|
26
|
+
"""Return human-readable name for this camera device.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Friendly display name
|
|
30
|
+
"""
|
|
31
|
+
return "Raspberry Pi HQ Camera"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def get_dependencies(cls) -> dict[str, str | list[str]]:
|
|
35
|
+
"""Return required Python packages.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dict with packages and install extra
|
|
39
|
+
"""
|
|
40
|
+
return {
|
|
41
|
+
"packages": ["picamera2"],
|
|
42
|
+
"install_extra": "rpi",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
47
|
+
"""Return schema for Raspberry Pi HQ Camera settings.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of setting schema entries
|
|
51
|
+
"""
|
|
52
|
+
schema = [
|
|
53
|
+
{
|
|
54
|
+
"name": "default_gain",
|
|
55
|
+
"friendly_name": "Default Gain",
|
|
56
|
+
"type": "float",
|
|
57
|
+
"default": 1.0,
|
|
58
|
+
"description": "Default analog gain (1.0-16.0)",
|
|
59
|
+
"required": False,
|
|
60
|
+
"min": 1.0,
|
|
61
|
+
"max": 16.0,
|
|
62
|
+
"group": "Camera",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "default_exposure_ms",
|
|
66
|
+
"friendly_name": "Default Exposure (ms)",
|
|
67
|
+
"type": "float",
|
|
68
|
+
"default": 1000.0,
|
|
69
|
+
"description": "Default exposure time in milliseconds",
|
|
70
|
+
"required": False,
|
|
71
|
+
"min": 0.1,
|
|
72
|
+
"max": 600000.0,
|
|
73
|
+
"group": "Camera",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "output_format",
|
|
77
|
+
"friendly_name": "Output Format",
|
|
78
|
+
"type": "str",
|
|
79
|
+
"default": "fits",
|
|
80
|
+
"description": "Image output format",
|
|
81
|
+
"required": False,
|
|
82
|
+
"options": ["fits", "png", "jpg", "raw"],
|
|
83
|
+
"group": "Camera",
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
return cast(list[SettingSchemaEntry], schema)
|
|
87
|
+
|
|
88
|
+
def __init__(self, logger: logging.Logger, **kwargs):
|
|
89
|
+
"""Initialize the Raspberry Pi HQ Camera.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
logger: Logger instance
|
|
93
|
+
**kwargs: Configuration parameters matching the schema
|
|
94
|
+
"""
|
|
95
|
+
super().__init__(logger, **kwargs)
|
|
96
|
+
|
|
97
|
+
# Hardware specs (fixed by IMX477 sensor)
|
|
98
|
+
self.sensor_width = 4056
|
|
99
|
+
self.sensor_height = 3040
|
|
100
|
+
|
|
101
|
+
# Camera settings
|
|
102
|
+
self.default_gain = kwargs.get("default_gain", 1.0)
|
|
103
|
+
self.default_exposure_ms = kwargs.get("default_exposure_ms", 1000.0)
|
|
104
|
+
self.output_format = kwargs.get("output_format", "fits")
|
|
105
|
+
|
|
106
|
+
# Camera instance (lazy loaded)
|
|
107
|
+
self._camera = None
|
|
108
|
+
self._connected = False
|
|
109
|
+
|
|
110
|
+
# Lazy import picamera2 to avoid hard dependency
|
|
111
|
+
self._picamera2_module = None
|
|
112
|
+
|
|
113
|
+
def connect(self) -> bool:
|
|
114
|
+
"""Connect to the Raspberry Pi HQ Camera.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if connection successful, False otherwise
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
# Lazy import
|
|
121
|
+
if self._picamera2_module is None:
|
|
122
|
+
import picamera2 # type: ignore
|
|
123
|
+
|
|
124
|
+
self._picamera2_module = picamera2
|
|
125
|
+
|
|
126
|
+
self.logger.info("Connecting to Raspberry Pi HQ Camera...")
|
|
127
|
+
|
|
128
|
+
# Initialize camera
|
|
129
|
+
self._camera = self._picamera2_module.Picamera2()
|
|
130
|
+
|
|
131
|
+
# Configure camera for full sensor resolution
|
|
132
|
+
config = self._camera.create_still_configuration(
|
|
133
|
+
main={
|
|
134
|
+
"size": (self.sensor_width, self.sensor_height),
|
|
135
|
+
"format": "RGB888", # We'll convert to desired format later
|
|
136
|
+
},
|
|
137
|
+
buffer_count=2,
|
|
138
|
+
)
|
|
139
|
+
self._camera.configure(config)
|
|
140
|
+
|
|
141
|
+
self._camera.start()
|
|
142
|
+
self._connected = True
|
|
143
|
+
|
|
144
|
+
self.logger.info(f"Raspberry Pi HQ Camera connected: {self.sensor_width}x{self.sensor_height}")
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
except ImportError:
|
|
148
|
+
self.logger.error("picamera2 library not found. Install with: pip install picamera2")
|
|
149
|
+
return False
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self.logger.error(f"Failed to connect to Raspberry Pi HQ Camera: {e}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def disconnect(self):
|
|
155
|
+
"""Disconnect from the Raspberry Pi HQ Camera."""
|
|
156
|
+
if self._camera:
|
|
157
|
+
try:
|
|
158
|
+
self._camera.stop()
|
|
159
|
+
self._camera.close()
|
|
160
|
+
self.logger.info("Raspberry Pi HQ Camera disconnected")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
self.logger.error(f"Error disconnecting camera: {e}")
|
|
163
|
+
|
|
164
|
+
self._camera = None
|
|
165
|
+
self._connected = False
|
|
166
|
+
|
|
167
|
+
def is_connected(self) -> bool:
|
|
168
|
+
"""Check if camera is connected and responsive.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if connected, False otherwise
|
|
172
|
+
"""
|
|
173
|
+
return self._connected and self._camera is not None
|
|
174
|
+
|
|
175
|
+
def take_exposure(
|
|
176
|
+
self,
|
|
177
|
+
duration: float,
|
|
178
|
+
gain: Optional[int] = None,
|
|
179
|
+
offset: Optional[int] = None,
|
|
180
|
+
binning: int = 1,
|
|
181
|
+
save_path: Optional[Path] = None,
|
|
182
|
+
) -> Path:
|
|
183
|
+
"""Capture an exposure.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
duration: Exposure time in seconds
|
|
187
|
+
gain: Camera gain (1.0-16.0), uses default if None
|
|
188
|
+
offset: Not used for RPi HQ camera
|
|
189
|
+
binning: Binning factor (1, 2, or 4) - applied during image processing
|
|
190
|
+
save_path: Path to save the image
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Path to the saved image
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
RuntimeError: If camera not connected or capture fails
|
|
197
|
+
"""
|
|
198
|
+
if not self.is_connected():
|
|
199
|
+
raise RuntimeError("Camera not connected")
|
|
200
|
+
|
|
201
|
+
if self._camera is None:
|
|
202
|
+
raise RuntimeError("Camera instance is None")
|
|
203
|
+
|
|
204
|
+
if save_path is None:
|
|
205
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
206
|
+
save_path = Path(f"/tmp/rpi_hq_{timestamp}.{self.output_format}")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Set camera controls
|
|
210
|
+
actual_gain = gain if gain is not None else self.default_gain
|
|
211
|
+
exposure_us = int(duration * 1_000_000) # Convert to microseconds
|
|
212
|
+
|
|
213
|
+
self.logger.info(f"Taking {duration}s exposure, gain={actual_gain}")
|
|
214
|
+
|
|
215
|
+
# Configure exposure settings
|
|
216
|
+
self._camera.set_controls(
|
|
217
|
+
{
|
|
218
|
+
"ExposureTime": exposure_us,
|
|
219
|
+
"AnalogueGain": float(actual_gain),
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Capture image
|
|
224
|
+
request = self._camera.capture_request()
|
|
225
|
+
|
|
226
|
+
# Get the image data
|
|
227
|
+
image_data = request.make_array("main")
|
|
228
|
+
request.release()
|
|
229
|
+
|
|
230
|
+
# Apply binning if requested
|
|
231
|
+
if binning > 1:
|
|
232
|
+
image_data = self._apply_binning(image_data, binning)
|
|
233
|
+
|
|
234
|
+
# Save based on format
|
|
235
|
+
if self.output_format == "fits":
|
|
236
|
+
self._save_as_fits(image_data, save_path)
|
|
237
|
+
elif self.output_format == "png":
|
|
238
|
+
self._save_as_png(image_data, save_path)
|
|
239
|
+
elif self.output_format == "jpg":
|
|
240
|
+
self._save_as_jpg(image_data, save_path)
|
|
241
|
+
elif self.output_format == "raw":
|
|
242
|
+
self._save_as_raw(image_data, save_path)
|
|
243
|
+
|
|
244
|
+
self.logger.info(f"Image saved to {save_path}")
|
|
245
|
+
return save_path
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
self.logger.error(f"Failed to capture image: {e}")
|
|
249
|
+
raise RuntimeError(f"Image capture failed: {e}")
|
|
250
|
+
|
|
251
|
+
def abort_exposure(self):
|
|
252
|
+
"""Abort current exposure.
|
|
253
|
+
|
|
254
|
+
Note: Picamera2 doesn't support aborting exposures, this is a no-op.
|
|
255
|
+
"""
|
|
256
|
+
self.logger.warning("Raspberry Pi camera does not support aborting exposures")
|
|
257
|
+
|
|
258
|
+
def get_temperature(self) -> Optional[float]:
|
|
259
|
+
"""Get camera sensor temperature.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Temperature in Celsius, or None if unavailable
|
|
263
|
+
|
|
264
|
+
Note: RPi HQ camera does not expose temperature readings via picamera2
|
|
265
|
+
"""
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def _apply_binning(self, image_data, binning: int):
|
|
269
|
+
"""Apply pixel binning to reduce resolution and increase sensitivity.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
image_data: Image array (H, W, C) or (H, W)
|
|
273
|
+
binning: Binning factor (2 or 4)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Binned image array
|
|
277
|
+
"""
|
|
278
|
+
import numpy as np
|
|
279
|
+
|
|
280
|
+
if binning == 1:
|
|
281
|
+
return image_data
|
|
282
|
+
|
|
283
|
+
# Crop to be evenly divisible by binning factor
|
|
284
|
+
h, w = image_data.shape[:2]
|
|
285
|
+
h_crop = (h // binning) * binning
|
|
286
|
+
w_crop = (w // binning) * binning
|
|
287
|
+
cropped = image_data[:h_crop, :w_crop]
|
|
288
|
+
|
|
289
|
+
# Reshape and average
|
|
290
|
+
if len(cropped.shape) == 3: # RGB
|
|
291
|
+
h, w, c = cropped.shape
|
|
292
|
+
binned = cropped.reshape(h // binning, binning, w // binning, binning, c).mean(axis=(1, 3))
|
|
293
|
+
else: # Grayscale
|
|
294
|
+
h, w = cropped.shape
|
|
295
|
+
binned = cropped.reshape(h // binning, binning, w // binning, binning).mean(axis=(1, 3))
|
|
296
|
+
|
|
297
|
+
return binned.astype(image_data.dtype)
|
|
298
|
+
|
|
299
|
+
def _save_as_fits(self, image_data, save_path: Path):
|
|
300
|
+
"""Save image as FITS format."""
|
|
301
|
+
try:
|
|
302
|
+
import numpy as np
|
|
303
|
+
from astropy.io import fits
|
|
304
|
+
|
|
305
|
+
# Convert RGB to grayscale for astronomy (luminance)
|
|
306
|
+
if len(image_data.shape) == 3:
|
|
307
|
+
gray = np.mean(image_data, axis=2).astype(np.uint16)
|
|
308
|
+
else:
|
|
309
|
+
gray = image_data.astype(np.uint16)
|
|
310
|
+
|
|
311
|
+
hdu = fits.PrimaryHDU(gray)
|
|
312
|
+
hdu.header["INSTRUME"] = "Raspberry Pi HQ Camera"
|
|
313
|
+
hdu.header["CAMERA"] = "IMX477"
|
|
314
|
+
hdu.writeto(save_path, overwrite=True)
|
|
315
|
+
|
|
316
|
+
except ImportError:
|
|
317
|
+
self.logger.error("astropy not installed. Install with: pip install astropy")
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
def _save_as_png(self, image_data, save_path: Path):
|
|
321
|
+
"""Save image as PNG format."""
|
|
322
|
+
try:
|
|
323
|
+
from PIL import Image
|
|
324
|
+
|
|
325
|
+
img = Image.fromarray(image_data)
|
|
326
|
+
img.save(save_path)
|
|
327
|
+
|
|
328
|
+
except ImportError:
|
|
329
|
+
self.logger.error("Pillow not installed. Install with: pip install Pillow")
|
|
330
|
+
raise
|
|
331
|
+
|
|
332
|
+
def _save_as_jpg(self, image_data, save_path: Path):
|
|
333
|
+
"""Save image as JPEG format."""
|
|
334
|
+
try:
|
|
335
|
+
from PIL import Image
|
|
336
|
+
|
|
337
|
+
img = Image.fromarray(image_data)
|
|
338
|
+
img.save(save_path, quality=95)
|
|
339
|
+
|
|
340
|
+
except ImportError:
|
|
341
|
+
self.logger.error("Pillow not installed. Install with: pip install Pillow")
|
|
342
|
+
raise
|
|
343
|
+
|
|
344
|
+
def _save_as_raw(self, image_data, save_path: Path):
|
|
345
|
+
"""Save raw image data as numpy array."""
|
|
346
|
+
try:
|
|
347
|
+
import numpy as np
|
|
348
|
+
|
|
349
|
+
np.save(save_path.with_suffix(".npy"), image_data)
|
|
350
|
+
|
|
351
|
+
except ImportError:
|
|
352
|
+
self.logger.error("numpy not installed. Install with: pip install numpy")
|
|
353
|
+
raise
|