astrafocus 0.0.2__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 (40) hide show
  1. astrafocus/__init__.py +0 -0
  2. astrafocus/autofocuser.py +507 -0
  3. astrafocus/focus_measure_operators.py +197 -0
  4. astrafocus/interface/__init__.py +0 -0
  5. astrafocus/interface/alpaca.py +91 -0
  6. astrafocus/interface/camera.py +47 -0
  7. astrafocus/interface/device_manager.py +121 -0
  8. astrafocus/interface/focuser.py +163 -0
  9. astrafocus/interface/simulation.py +252 -0
  10. astrafocus/interface/telescope.py +49 -0
  11. astrafocus/interface/telescope_specs.py +210 -0
  12. astrafocus/models/__init__.py +0 -0
  13. astrafocus/models/elliptical_moffat_2D.py +198 -0
  14. astrafocus/models/half_flux_radius_2D.py +59 -0
  15. astrafocus/sql/__init__.py +0 -0
  16. astrafocus/sql/local_gaia_database_query.py +248 -0
  17. astrafocus/sql/shardwise_query.py +62 -0
  18. astrafocus/star_finder.py +72 -0
  19. astrafocus/star_fitter.py +262 -0
  20. astrafocus/star_size_focus_measure_operators.py +247 -0
  21. astrafocus/targeting/__init__.py +0 -0
  22. astrafocus/targeting/airmass_models.py +164 -0
  23. astrafocus/targeting/angle_difference.py +18 -0
  24. astrafocus/targeting/celestial_bounds_calculator.py +117 -0
  25. astrafocus/targeting/tangential_plane_projector.py +192 -0
  26. astrafocus/targeting/zenith_angle_calculator.py +49 -0
  27. astrafocus/targeting/zenith_neighbourhood.py +412 -0
  28. astrafocus/targeting/zenith_neighbourhood_query.py +216 -0
  29. astrafocus/targeting/zenith_neighbourhood_query_result.py +162 -0
  30. astrafocus/utils/__init__.py +0 -0
  31. astrafocus/utils/load_fits_from_directory.py +31 -0
  32. astrafocus/utils/logger.py +184 -0
  33. astrafocus/utils/plot.py +62 -0
  34. astrafocus/utils/timer.py +50 -0
  35. astrafocus/utils/typing.py +6 -0
  36. astrafocus-0.0.2.dist-info/LICENSE +674 -0
  37. astrafocus-0.0.2.dist-info/METADATA +111 -0
  38. astrafocus-0.0.2.dist-info/RECORD +40 -0
  39. astrafocus-0.0.2.dist-info/WHEEL +5 -0
  40. astrafocus-0.0.2.dist-info/top_level.txt +1 -0
astrafocus/__init__.py ADDED
File without changes
@@ -0,0 +1,507 @@
1
+ from abc import ABC, abstractmethod, ABCMeta
2
+ from typing import Optional, Tuple, Union
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from astrafocus.interface.device_manager import AutofocusDeviceManager
8
+ from astrafocus.utils.logger import configure_logger
9
+ from astrafocus.focus_measure_operators import (
10
+ FocusMeasureOperator,
11
+ AnalyticResponseFocusedMeasureOperator,
12
+ )
13
+
14
+ logger = configure_logger(stream_handler_level=10) # logging.INFO
15
+
16
+
17
+ class AutofocuserBase(ABC):
18
+ """
19
+ Abstract base class for autofocusing algorithms.
20
+
21
+ Parameters
22
+ ----------
23
+ autofocus_device_manager : AutofocusDeviceManager
24
+ Interface to control the camera and its focuser.
25
+ focus_measure_operator : FocusMeasureOperator
26
+ Operator to measure the focus of images.
27
+ exposure_time : float
28
+ Exposure time for image acquisition.
29
+ search_range : Optional[Tuple[int, int]], optional
30
+ Range of focus positions to search for the best focus (default is None,
31
+ using the telescope's allowed range).
32
+ initial_position : Optional[int], optional
33
+ Initial focus position for the autofocus algorithm (default is None,
34
+ using the telescope's current position).
35
+ keep_images : bool, optional
36
+ Whether to keep images for additional analysis (default is False).
37
+ secondary_focus_measure_operators : Optional[dict], optional
38
+ Dictionary of additional focus measure operators for image analysis
39
+ (default is an empty dictionary).
40
+
41
+ Attributes
42
+ ----------
43
+ focus_record : pd.DataFrame
44
+ DataFrame containing focus positions and corresponding focus measures.
45
+ best_focus_position : int or None
46
+ Best focus position determined by the autofocus algorithm.
47
+ _image_record : list
48
+ List to store images if 'keep_images' is True.
49
+
50
+ Methods
51
+ -------
52
+ measure_focus(image: np.ndarray) -> float:
53
+ Measure the focus of a given image using the specified focus measure operator.
54
+ run():
55
+ Execute the autofocus algorithm. Handles exceptions and resets the focuser on failure.
56
+ _run():
57
+ Abstract method to be implemented by subclasses for the actual autofocus algorithm.
58
+ reset():
59
+ Reset the focuser to the initial position.
60
+ get_focus_record() -> Tuple[np.ndarray, np.ndarray]:
61
+ Retrieve the focus record as sorted arrays of focus positions and corresponding measures.
62
+
63
+ Examples
64
+ --------
65
+ # Instantiate an AutofocuserBase instance
66
+ >>> autofocus_instance = AutofocuserBase(
67
+ ... autofocus_device_manager, focus_measure_operator, exposure_time
68
+ ... )
69
+
70
+ # Run the autofocus algorithm
71
+ >>> autofocus_instance.run()
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ autofocus_device_manager: AutofocusDeviceManager,
77
+ focus_measure_operator: FocusMeasureOperator,
78
+ exposure_time: float,
79
+ search_range: Optional[Tuple[int, int]] = None,
80
+ initial_position: Optional[int] = None,
81
+ keep_images: bool = False,
82
+ secondary_focus_measure_operators: Optional[dict] = None,
83
+ ):
84
+ self.autofocus_device_manager = autofocus_device_manager
85
+ self.focus_measure_operator = focus_measure_operator
86
+ self.exposure_time = exposure_time
87
+ self.search_range = search_range or autofocus_device_manager.focuser.allowed_range
88
+ self.initial_position = initial_position or autofocus_device_manager.focuser.position
89
+
90
+ self._focus_record = pd.DataFrame(columns=["focus_pos", "focus_measure"], dtype=np.float64)
91
+ self.best_focus_position = None
92
+
93
+ self.keep_images = keep_images
94
+ self.secondary_focus_measure_operators = secondary_focus_measure_operators or {}
95
+ self._image_record = []
96
+
97
+ @property
98
+ def focus_record(self):
99
+ df = self._focus_record.copy()
100
+ df["focus_pos"] = df["focus_pos"].astype(int)
101
+
102
+ if self.keep_images:
103
+ for name, fm in self.secondary_focus_measure_operators.items():
104
+ df[name] = np.array([fm.measure_focus(image) for image in self._image_record])
105
+
106
+ return df
107
+
108
+ def measure_focus(self, image: np.ndarray) -> float:
109
+ if self.keep_images:
110
+ self._image_record.append(image)
111
+ return self.focus_measure_operator(image)
112
+
113
+ def run(self) -> bool:
114
+ success = False
115
+ try:
116
+ self._run()
117
+ logger.info("Successfully completed autofocusing.")
118
+ success = True
119
+ except Exception as e:
120
+ logger.exception(e)
121
+ logger.warning("Error in autofocus algorithm. Resetting focuser to initial position.")
122
+ self.reset()
123
+
124
+ return success
125
+
126
+ @abstractmethod
127
+ def _run(self):
128
+ pass
129
+
130
+ def reset(self):
131
+ self.autofocus_device_manager.move_focuser_to_position(self.initial_position)
132
+
133
+ def get_focus_record(self):
134
+ if self._focus_record.size == 0:
135
+ raise ValueError("Focus record is empty. Run the autofocus algorithm first.")
136
+
137
+ focus_pos = self._focus_record.focus_pos[~np.isnan(self._focus_record.focus_pos)].to_numpy(
138
+ int
139
+ )
140
+ focus_measure = self._focus_record.focus_measure[
141
+ ~np.isnan(self._focus_record.focus_measure)
142
+ ].to_numpy()
143
+ sort_ind = np.argsort(focus_pos)
144
+
145
+ return focus_pos[sort_ind], focus_measure[sort_ind]
146
+
147
+ def __repr__(self) -> str:
148
+ return (
149
+ f"AutofocuserBase(self.autofocus_device_manager={self.autofocus_device_manager!r}, "
150
+ f"exposure_time={self.exposure_time!r} sec, "
151
+ f"search_range={self.search_range!r}, "
152
+ f"initial_position={self.initial_position!r})"
153
+ )
154
+
155
+
156
+ class SweepingAutofocuser(AutofocuserBase):
157
+ """
158
+ Autofocuser implementation using a sweeping algorithm.
159
+
160
+ Parameters
161
+ ----------
162
+ autofocus_device_manager : AutofocusDeviceManager
163
+ Interface to control the camera and its focuser.
164
+ exposure_time : float
165
+ Exposure time for image acquisition.
166
+ focus_measure_operator : FocusMeasureOperator
167
+ Operator to measure the focus of images.
168
+ n_steps : Tuple[int], optional
169
+ Number of steps for each sweep (default is (10,)).
170
+ The length of this tuple determines the number of sweeps. The entries specify the number of
171
+ steps for each sweep.
172
+ n_exposures : Union[int, np.ndarray], optional
173
+ Number of exposures at each focus position or an array specifying exposures for each sweep.
174
+ If an integer is given, the same number of exposures is used for each sweep (default is 1).
175
+ If an array is given, the length of the array must match the number of sweeps.
176
+ (default is 1).
177
+ search_range : Optional[Tuple[int, int]], optional
178
+ Range of focus positions to search for the best focus (default is None,
179
+ using the telescope's allowed range).
180
+ decrease_search_range : bool, optional
181
+ Whether to decrease the search range after each sweep (default is True).
182
+ initial_position : Optional[int], optional
183
+ Initial focus position for the autofocus algorithm (default is None,
184
+ using the telescope's current position).
185
+ **kwargs
186
+ Additional keyword arguments.
187
+
188
+ Attributes
189
+ ----------
190
+ n_sweeps : int
191
+ Number of sweeps to perform.
192
+ n_steps : Tuple[int]
193
+ Number of steps for each sweep.
194
+ n_exposures : np.ndarray
195
+ Number of exposures at each focus position.
196
+ decrease_search_range : bool
197
+ Whether to decrease the search range after each sweep.
198
+
199
+ Methods
200
+ -------
201
+ _run():
202
+ Execute the sweeping autofocus algorithm.
203
+ get_initial_direction(min_focus_pos, max_focus_pos) -> int:
204
+ Determine the initial direction of the sweep.
205
+ find_best_focus_position():
206
+ Find and set the best focus position based on the recorded focus measures.
207
+ _find_best_focus_position(focus_pos, focus_measure) -> Tuple[int, float]:
208
+ Abstract method to be implemented by subclasses for finding the best focus position.
209
+ _run_sweep(search_positions, n_exposures):
210
+ Perform a single sweep across the specified focus positions.
211
+ update_search_range(min_focus_pos, max_focus_pos) -> Tuple[int, int]:
212
+ Update the search range after each sweep.
213
+ integer_linspace(min_focus_pos, max_focus_pos, n_steps) -> np.ndarray:
214
+ Generate integer-spaced values within the specified range.
215
+
216
+ Examples
217
+ --------
218
+ # Instantiate a SweepingAutofocuser instance
219
+ >>> sweeping_autofocuser = SweepingAutofocuser(autofocus_device_manager, exposure_time, focus_measure_operator)
220
+
221
+ # Run the sweeping autofocus algorithm
222
+ >>> sweeping_autofocuser.run()
223
+ """
224
+
225
+ def __init__(
226
+ self,
227
+ autofocus_device_manager: AutofocusDeviceManager,
228
+ exposure_time: float,
229
+ focus_measure_operator,
230
+ n_steps: Tuple[int] = (10,),
231
+ n_exposures: Union[int, np.ndarray] = 1,
232
+ search_range: Optional[Tuple[int, int]] = None,
233
+ decrease_search_range=True,
234
+ initial_position: Optional[int] = None,
235
+ **kwargs,
236
+ ):
237
+ super().__init__(
238
+ autofocus_device_manager,
239
+ focus_measure_operator,
240
+ exposure_time,
241
+ search_range,
242
+ initial_position,
243
+ **kwargs,
244
+ )
245
+
246
+ self.n_sweeps = len(n_steps)
247
+ self.n_steps = n_steps
248
+ self.n_exposures = (
249
+ np.array(n_exposures, dtype=int)
250
+ if isinstance(n_exposures, (np.ndarray, list, tuple))
251
+ else np.full(self.n_sweeps, n_exposures, dtype=int)
252
+ )
253
+ if len(self.n_exposures) != self.n_sweeps:
254
+ raise ValueError(
255
+ f"Length of n_exposures ({len(self.n_exposures)}) must match length of n_steps "
256
+ f"({self.n_sweeps})."
257
+ )
258
+
259
+ self._focus_record = pd.DataFrame(
260
+ np.full((np.sum(np.array(n_steps) * self.n_exposures), 2), np.nan),
261
+ columns=self._focus_record.columns,
262
+ dtype=np.float64,
263
+ )
264
+ self.decrease_search_range = decrease_search_range
265
+
266
+ def _run(self):
267
+ min_focus_pos, max_focus_pos = self.search_range
268
+ initial_direction = self.get_initial_direction(min_focus_pos, max_focus_pos)
269
+
270
+ for sweep in range(self.n_sweeps):
271
+ search_positions = self.integer_linspace(
272
+ min_focus_pos, max_focus_pos, self.n_steps[sweep]
273
+ )
274
+
275
+ if sweep % 2 == initial_direction:
276
+ search_positions = np.flip(search_positions) # Reverse order
277
+
278
+ if not self.autofocus_device_manager.check_conditions():
279
+ raise ValueError("Observation conditions are not good enough to take exposures.")
280
+
281
+ logger.info(
282
+ f"Starting sweep {sweep + 1} of {self.n_sweeps}."
283
+ + f" ({np.min(search_positions)}, {np.max(search_positions)}, "
284
+ + f"{self.n_steps[sweep]}"
285
+ + (", reversed" if sweep % 2 == initial_direction else "")
286
+ + ")."
287
+ )
288
+ self._run_sweep(search_positions, self.n_exposures[sweep])
289
+
290
+ if self.decrease_search_range:
291
+ min_focus_pos, max_focus_pos = self.update_search_range(
292
+ min_focus_pos, max_focus_pos
293
+ )
294
+
295
+ self.find_best_focus_position()
296
+
297
+ def get_initial_direction(self, min_focus_pos, max_focus_pos):
298
+ """Move upward if initial position is closer to min_focus_pos than max_focus_pos."""
299
+ initial_direction = (
300
+ 1
301
+ if np.abs(self.initial_position - min_focus_pos)
302
+ < np.abs(self.initial_position - max_focus_pos)
303
+ else 0
304
+ )
305
+ return initial_direction
306
+
307
+ def find_best_focus_position(self):
308
+ focus_pos, focus_measure = self.get_focus_record()
309
+
310
+ best_focus_pos, best_focus_measure = self._find_best_focus_position(
311
+ focus_pos, focus_measure
312
+ )
313
+
314
+ self.best_focus_position = best_focus_pos
315
+ self.autofocus_device_manager.move_focuser_to_position(best_focus_pos)
316
+
317
+ logger.info(
318
+ f"Best focus position: {best_focus_pos} with focus measure value: {best_focus_measure:8.3e}"
319
+ )
320
+
321
+ @abstractmethod
322
+ def _find_best_focus_position(self, focus_pos, focus_measure) -> Tuple[int, float]:
323
+ # min_ind = np.argmin(focus_measure)
324
+ # best_focus_pos, best_focus_val = focus_pos[min_ind], focus_measure[min_ind]
325
+ pass
326
+
327
+ def _run_sweep(self, search_positions, n_exposures):
328
+ start_index = np.where(np.isnan(self._focus_record.iloc[:, 0]))[0][0]
329
+
330
+ for ind, focus_position in enumerate(search_positions):
331
+ if not self.autofocus_device_manager.check_conditions():
332
+ raise ValueError("Observation conditions are not good enough to take exposures.")
333
+ for exposure in range(n_exposures):
334
+ if not self.autofocus_device_manager.check_conditions():
335
+ raise ValueError("Observation conditions are not good enough to take exposures.")
336
+
337
+ # This step should include processing such as hot pixel removal etc.
338
+ image = self.autofocus_device_manager.perform_exposure_at(
339
+ focus_position=focus_position, texp=self.exposure_time
340
+ )
341
+
342
+ fm_value = self.measure_focus(image)
343
+ logger.debug(
344
+ f"Obtained measure value: {fm_value:8.3e} at focus position: {focus_position}"
345
+ )
346
+
347
+ # Save to record
348
+ df_index = start_index + ind * n_exposures + exposure
349
+ self._focus_record.loc[df_index, "focus_pos"] = focus_position
350
+ self._focus_record.loc[df_index, "focus_measure"] = fm_value
351
+
352
+ mean_fm_value = np.mean(
353
+ self._focus_record.loc[
354
+ start_index + ind * n_exposures : start_index + ind * (n_exposures + 1),
355
+ "focus_measure",
356
+ ]
357
+ )
358
+ if mean_fm_value < 1e3:
359
+ logger.info(f"{focus_position:6d} | {mean_fm_value:8.3f}")
360
+ else:
361
+ logger.info(f"{focus_position:6d} | {mean_fm_value:8.3e}")
362
+
363
+ def update_search_range(self, min_focus_pos, max_focus_pos):
364
+ return min_focus_pos, max_focus_pos
365
+
366
+ @staticmethod
367
+ def integer_linspace(min_focus_pos, max_focus_pos, n_steps):
368
+ """
369
+ Notes
370
+ -----
371
+ Search positions can be redundant
372
+ >>> integer_linspace(0, 1, 4)
373
+ array([0, 0, 1, 1])
374
+ """
375
+ search_positions = np.array(
376
+ np.round(np.linspace(min_focus_pos, max_focus_pos, n_steps)), dtype=int
377
+ )
378
+ return search_positions
379
+
380
+
381
+ class AnalyticResponseAutofocuser(SweepingAutofocuser):
382
+ """
383
+ Autofocuser that fits a curve to the focus response curve and finds the best focus position.
384
+
385
+ Parameters
386
+ ----------
387
+ autofocus_device_manager : AutofocusDeviceManager
388
+ Interface to control the telescope and its focuser.
389
+ exposure_time : float
390
+ Exposure time for image acquisition.
391
+ focus_measure_operator : AnalyticResponseFocusedMeasureOperator
392
+ Operator to measure the focus of images using an analytic response curve.
393
+ percent_to_cut : float, optional
394
+ Percentage of worst-performing focus positions to exclude when updating the search range
395
+ (default is 50.0).
396
+ **kwargs
397
+ Additional keyword arguments.
398
+
399
+ Examples
400
+ --------
401
+ >>> from astrafocus.interface.device_manager import AutofocusDeviceManager
402
+ >>> from astrafocus.interface.simulation import ObservationBasedDeviceSimulator
403
+ >>> from astrafocus.star_size_focus_measure_operators import HFRStarFocusMeasure
404
+ >>> from astrafocus.autofocuser import AnalyticResponseAutofocuser
405
+ >>> PATH_TO_FITS = 'path_to_fits'
406
+ >>> autofocus_device_manager = ObservationBasedDeviceSimulator(fits_path=PATH_TO_FITS)
407
+
408
+ >>> np.random.seed(42)
409
+ >>> araf = AnalyticResponseAutofocuser(
410
+ autofocus_device_manager=autofocus_device_manager,
411
+ exposure_time=3.0,
412
+ focus_measure_operator=HFRStarFocusMeasure,
413
+ n_steps=(30, 10),
414
+ n_exposures=(1, 2),
415
+ decrease_search_range=True,
416
+ percent_to_cut=60
417
+ )
418
+ >>> araf.run()
419
+ >>> araf.autofocus_device_manager.total_time
420
+ >>> araf.focus_record
421
+
422
+ >>> import matplotlib.pyplot as plt
423
+ >>> plt.scatter(
424
+ araf.focus_record.focus_pos, araf.focus_record.focus_measure, ls='', marker='.'
425
+ )
426
+ >>> sampled_pos = np.linspace(*araf.search_range, 100)
427
+ >>> sampled_responses = araf.get_focus_response_curve_fit(sampled_pos)
428
+ >>> plt.plot(sampled_pos, sampled_responses)
429
+ >>> plt.axvline(araf.best_focus_position)
430
+ >>> plt.show()
431
+
432
+ """
433
+
434
+ def __init__(
435
+ self,
436
+ autofocus_device_manager: AutofocusDeviceManager,
437
+ exposure_time: float,
438
+ focus_measure_operator: AnalyticResponseFocusedMeasureOperator,
439
+ percent_to_cut: float = 50.0,
440
+ **kwargs,
441
+ ):
442
+ ref_image = autofocus_device_manager.camera.perform_exposure(texp=exposure_time)
443
+
444
+ super().__init__(
445
+ autofocus_device_manager=autofocus_device_manager,
446
+ exposure_time=exposure_time,
447
+ focus_measure_operator=focus_measure_operator(ref_image=ref_image),
448
+ **kwargs,
449
+ )
450
+ self.update_search_range
451
+ self.percent_to_cut = percent_to_cut
452
+
453
+ def _find_best_focus_position(
454
+ self, focus_pos: np.ndarray, focus_measure: np.ndarray
455
+ ) -> Tuple[int, float]:
456
+ return self.fit_focus_response_curve(focus_pos, focus_measure)
457
+
458
+ def fit_focus_response_curve(self, focus_pos: np.ndarray, focus_measure: np.ndarray):
459
+ optimal_focus_pos = self.focus_measure_operator.fit_focus_response_curve(
460
+ focus_pos, focus_measure
461
+ )
462
+ optimal_focus_pos = int(np.round(optimal_focus_pos))
463
+ best_focus_val = self.focus_measure_operator.get_focus_response_curve_fit(optimal_focus_pos)
464
+
465
+ return optimal_focus_pos, best_focus_val
466
+
467
+ def get_focus_response_curve_fit(self, focus_pos: int):
468
+ focus_response_curve_fit = self.focus_measure_operator.get_focus_response_curve_fit(
469
+ focus_pos
470
+ )
471
+ return focus_response_curve_fit
472
+
473
+ def update_search_range(self, min_focus_pos, max_focus_pos) -> Tuple[int, int]:
474
+ """
475
+ Update the search range for optimal focus position based on focus response curve.
476
+
477
+ Notes
478
+ -----
479
+ This function updates the search range for the optimal focus position based on the
480
+ focus response curve. It identifies the worst-performing positions in the current
481
+ interval and adjusts the interval accordingly.
482
+ """
483
+ # Get focus data and fit focus response curve parameters
484
+ focus_pos, focus_measure = self.get_focus_record()
485
+ _ = self.focus_measure_operator.fit_focus_response_curve(focus_pos, focus_measure)
486
+
487
+ # Generate focus response curve
488
+ sampled_pos = np.linspace(min_focus_pos, max_focus_pos, 100)
489
+ sampled_responses = self.get_focus_response_curve_fit(sampled_pos)
490
+
491
+ # Find threshold to exclude worst values
492
+ threshold = np.percentile(sampled_responses, self.percent_to_cut)
493
+
494
+ # Identify indices with responses below threshold
495
+ below_threshold_indices = np.where(sampled_responses < threshold)[0]
496
+
497
+ # Update focus search interval based on below-threshold positions
498
+ new_min_focus_pos = np.maximum(min_focus_pos, np.min(sampled_pos[below_threshold_indices]))
499
+ new_max_focus_pos = np.minimum(max_focus_pos, np.max(sampled_pos[below_threshold_indices]))
500
+ new_min_focus_pos = int(np.floor(new_min_focus_pos))
501
+ new_max_focus_pos = int(np.floor(new_max_focus_pos))
502
+
503
+ logger.info(
504
+ f"Updating search range from ({min_focus_pos}, {max_focus_pos}) to "
505
+ f"({new_min_focus_pos}, {new_max_focus_pos})."
506
+ )
507
+ return new_min_focus_pos, new_max_focus_pos
@@ -0,0 +1,197 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Union
3
+
4
+ import cv2
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+
8
+ from astrafocus.utils.typing import ImageType
9
+
10
+ ImageType = npt.NDArray[Union[np.floating, np.integer]]
11
+
12
+
13
+ class FocusMeasureOperator(ABC):
14
+ """
15
+ Abstract base class for focus measure operators.
16
+
17
+ Methods
18
+ -------
19
+ __call__(image: np.ndarray, **kwargs) -> float
20
+ Compute the focus measure of the input image.
21
+
22
+ measure_focus(image: np.ndarray, **kwargs) -> float
23
+ Abstract method to be implemented by subclasses.
24
+ Compute the focus measure of the input image.
25
+
26
+ convert_to_grayscale(image: np.ndarray) -> np.ndarray
27
+ Convert the input image to grayscale.
28
+
29
+ validate_image(image: np.ndarray)
30
+ Validate the input image for compatibility with focus measure algorithms.
31
+ """
32
+
33
+ def __call__(self, image: npt.NDArray, **kwargs) -> float:
34
+ """
35
+ Compute the focus measure of the input image.
36
+
37
+ Parameters
38
+ ----------
39
+ image : np.ndarray
40
+ Input image.
41
+
42
+ Returns
43
+ -------
44
+ float
45
+ Computed focus measure.
46
+ """
47
+ self.validate_image(image)
48
+ return self.measure_focus(self.convert_to_grayscale(image), **kwargs)
49
+
50
+ @abstractmethod
51
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
52
+ """
53
+ Abstract method to be implemented by subclasses.
54
+ Compute the focus measure of the input image.
55
+
56
+ Parameters
57
+ ----------
58
+ image : np.ndarray
59
+ Input image.
60
+
61
+ Returns
62
+ -------
63
+ float
64
+ Computed focus measure.
65
+ """
66
+ pass
67
+
68
+ @staticmethod
69
+ def convert_to_grayscale(image: ImageType) -> ImageType:
70
+ """Convert the input image to grayscale."""
71
+ if len(image.shape) == 3:
72
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
73
+ if image.ndim != 2:
74
+ raise ValueError("Input must be a 2D array.")
75
+ return image
76
+
77
+ @staticmethod
78
+ def validate_image(image):
79
+ """Validate the input image for compatibility with focus measure algorithms."""
80
+ if not isinstance(image, np.ndarray):
81
+ raise ValueError("Input must be a numpy array.")
82
+ if not (np.issubdtype(image.dtype, np.integer) or np.issubdtype(image.dtype, np.floating)):
83
+ raise ValueError("Values in the numpy array must be either integers or floats.")
84
+
85
+
86
+ class AnalyticResponseFocusedMeasureOperator(FocusMeasureOperator):
87
+ @abstractmethod
88
+ def fit_focus_response_curve(focus_pos, measured_focus):
89
+ pass
90
+
91
+ @abstractmethod
92
+ def get_focus_response_curve_fit(self, focus_pos):
93
+ pass
94
+
95
+
96
+ class AutoCorrelationFocusMeasure(FocusMeasureOperator):
97
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
98
+ if image.dtype == bool:
99
+ raise ValueError("Bools are not allowed")
100
+ return float(np.sum(image[:-1, :] * image[1:, :]) - np.sum(image[:-2, :] * image[2:, :]))
101
+
102
+
103
+ class NormalizedVarianceFocusMeasure(FocusMeasureOperator):
104
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
105
+ return -image.var() / image.mean()
106
+
107
+
108
+ class FFTFocusMeasure(FocusMeasureOperator):
109
+ """
110
+ \mathrm{FM} &= \norm{\bm{R \phi}}_{1} \\
111
+ \mathrm{FFT}(x, y) &= R(x, y) \exp\qty(-i\phi(x,y)) \\
112
+ """
113
+
114
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
115
+ f = np.fft.fft2(image)
116
+ mag = np.abs(f)
117
+ phase = np.arctan2(np.imag(f), np.real(f))
118
+ return -np.sum(np.abs(phase * mag))
119
+
120
+
121
+ class FFTFocusMeasureTan2022(FocusMeasureOperator):
122
+ """
123
+ \mathrm{FM} &= \norm{\bm{R \phi}}_{1} \\
124
+ \mathrm{FFT}(x, y) &= R(x, y) \exp\qty(-i\phi(x,y)) \\
125
+
126
+ """
127
+
128
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
129
+ f = np.fft.fft2(image)
130
+ mag = np.abs(f)
131
+
132
+ n_max_y, n_max_x = np.array(image.shape)//2
133
+
134
+ # average of the high frequency components of the power spectrum
135
+ beta = np.mean(mag[n_max_y//2:n_max_y, n_max_x//2:n_max_x])
136
+ fft_without_noise = mag - beta
137
+
138
+ return np.sum(fft_without_noise[fft_without_noise > 0])
139
+
140
+
141
+ class FFTPowerFocusMeasure(FocusMeasureOperator):
142
+ """
143
+ \mathrm{FM} &= \norm{\bm{R \phi}}_{1} \\
144
+ \mathrm{FFT}(x, y) &= R(x, y) \exp\qty(-i\phi(x,y)) \\
145
+
146
+ """
147
+
148
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
149
+ f = np.fft.fft2(image)
150
+ mag = np.abs(f)
151
+
152
+ n_max_y, n_max_x = np.array(image.shape)//2
153
+
154
+ # average of the high frequency components of the power spectrum
155
+ beta = np.mean(mag[n_max_y//2:n_max_y, n_max_x//2:n_max_x])
156
+ fft_without_noise = mag - beta
157
+
158
+ return np.sum(fft_without_noise[fft_without_noise > 0])
159
+
160
+ class AbsoluteGradientFocusMeasure(FocusMeasureOperator):
161
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
162
+ return np.sum(np.abs(image[:, 1:] - image[:, :-1])) + np.sum(
163
+ np.abs(image[1:, :] - image[:-1, :])
164
+ )
165
+
166
+
167
+ class SquaredGradientFocusMeasure(FocusMeasureOperator):
168
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
169
+ return float(
170
+ np.sum((image[:, 1:] - image[:, :-1]) ** 2)
171
+ + np.sum((image[1:, :] - image[:-1, :]) ** 2)
172
+ )
173
+
174
+
175
+ class VarianceOfLaplacianFocusMeasure(FocusMeasureOperator):
176
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
177
+ return cv2.Laplacian(image, cv2.CV_64F).var()
178
+
179
+
180
+ class LaplacianFocusMeasure(FocusMeasureOperator):
181
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
182
+ return np.sum(np.abs(cv2.Laplacian(image, cv2.CV_64F)))
183
+
184
+
185
+ class TenengradFocusMeasure(FocusMeasureOperator):
186
+ def measure_focus(self, image: ImageType, ksize=1, **kwargs) -> float:
187
+ # Compute the gradients in the x and y directions using Sobel operators
188
+ gradient_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=ksize)
189
+ gradient_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=ksize)
190
+ tenengrad_measure = gradient_x**2 + gradient_y**2
191
+
192
+ return -np.sum(tenengrad_measure)
193
+
194
+
195
+ class BrennerFocusMeasure(FocusMeasureOperator):
196
+ def measure_focus(self, image: ImageType, **kwargs) -> float:
197
+ return float(np.sum(np.abs(image[:-2] - image[2:]) ** 2))