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.
- astrafocus/__init__.py +0 -0
- astrafocus/autofocuser.py +507 -0
- astrafocus/focus_measure_operators.py +197 -0
- astrafocus/interface/__init__.py +0 -0
- astrafocus/interface/alpaca.py +91 -0
- astrafocus/interface/camera.py +47 -0
- astrafocus/interface/device_manager.py +121 -0
- astrafocus/interface/focuser.py +163 -0
- astrafocus/interface/simulation.py +252 -0
- astrafocus/interface/telescope.py +49 -0
- astrafocus/interface/telescope_specs.py +210 -0
- astrafocus/models/__init__.py +0 -0
- astrafocus/models/elliptical_moffat_2D.py +198 -0
- astrafocus/models/half_flux_radius_2D.py +59 -0
- astrafocus/sql/__init__.py +0 -0
- astrafocus/sql/local_gaia_database_query.py +248 -0
- astrafocus/sql/shardwise_query.py +62 -0
- astrafocus/star_finder.py +72 -0
- astrafocus/star_fitter.py +262 -0
- astrafocus/star_size_focus_measure_operators.py +247 -0
- astrafocus/targeting/__init__.py +0 -0
- astrafocus/targeting/airmass_models.py +164 -0
- astrafocus/targeting/angle_difference.py +18 -0
- astrafocus/targeting/celestial_bounds_calculator.py +117 -0
- astrafocus/targeting/tangential_plane_projector.py +192 -0
- astrafocus/targeting/zenith_angle_calculator.py +49 -0
- astrafocus/targeting/zenith_neighbourhood.py +412 -0
- astrafocus/targeting/zenith_neighbourhood_query.py +216 -0
- astrafocus/targeting/zenith_neighbourhood_query_result.py +162 -0
- astrafocus/utils/__init__.py +0 -0
- astrafocus/utils/load_fits_from_directory.py +31 -0
- astrafocus/utils/logger.py +184 -0
- astrafocus/utils/plot.py +62 -0
- astrafocus/utils/timer.py +50 -0
- astrafocus/utils/typing.py +6 -0
- astrafocus-0.0.2.dist-info/LICENSE +674 -0
- astrafocus-0.0.2.dist-info/METADATA +111 -0
- astrafocus-0.0.2.dist-info/RECORD +40 -0
- astrafocus-0.0.2.dist-info/WHEEL +5 -0
- 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))
|