Coverage for ezstitcher/core/focus_analyzer.py: 79%
81 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-04-30 13:20 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2025-04-30 13:20 +0000
1from typing import Dict, List, Tuple, Union, Optional
2import logging
3import numpy as np
4import cv2
6logger = logging.getLogger(__name__)
8class FocusAnalyzer:
9 """
10 Provides focus metrics and best focus selection.
12 This class implements various focus measure algorithms and methods to find
13 the best focused image in a Z-stack. All methods are static and do not require
14 an instance.
16 Usage:
17 The class provides several focus metrics and methods to select the best focused
18 image from a Z-stack. The main methods are:
20 - find_best_focus: Returns the index of the best focused image in a stack
21 - select_best_focus: Returns the best focused image from a stack
22 - compute_focus_metrics: Computes focus metrics for all images in a stack
24 For most use cases, it's recommended to use the select_best_focus method
25 with an explicit metric parameter:
27 ```python
28 best_image, best_idx, scores = FocusAnalyzer.select_best_focus(
29 image_stack,
30 metric="laplacian" # Explicitly specify the metric
31 )
32 ```
34 Available metrics:
35 - "combined": Uses a weighted combination of all metrics (default)
36 - "normalized_variance" or "nvar": Uses normalized variance
37 - "laplacian" or "lap": Uses Laplacian energy
38 - "tenengrad" or "ten": Uses Tenengrad variance
39 - "fft": Uses adaptive FFT focus measure
41 You can also provide a custom weights dictionary for the combined metric:
43 ```python
44 custom_weights = {'nvar': 0.4, 'lap': 0.4, 'ten': 0.1, 'fft': 0.1}
45 best_image, best_idx, scores = FocusAnalyzer.select_best_focus(
46 image_stack,
47 metric=custom_weights
48 )
49 ```
50 """
52 # Default weights for the combined focus measure.
53 # These weights are used when no custom weights are provided to the
54 # combined_focus_measure method or when using the "combined" metric
55 # with find_best_focus, select_best_focus, or compute_focus_metrics.
56 # The weights determine the contribution of each focus metric to the final score:
57 DEFAULT_WEIGHTS = {
58 'nvar': 0.3, # Normalized variance (robust to illumination changes)
59 'lap': 0.3, # Laplacian energy (sensitive to edges)
60 'ten': 0.2, # Tenengrad variance (based on gradient magnitude)
61 'fft': 0.2 # FFT-based focus (frequency domain analysis)
62 }
64 @staticmethod
65 def normalized_variance(img: np.ndarray) -> float:
66 """
67 Normalized variance focus measure.
68 Robust to illumination changes.
70 Args:
71 img: Input grayscale image
73 Returns:
74 Focus quality score
75 """
76 mean_val = np.mean(img)
77 if mean_val == 0: # Avoid division by zero
78 return 0
80 return np.var(img) / mean_val
82 @staticmethod
83 def laplacian_energy(img: np.ndarray, ksize: int = 3) -> float:
84 """
85 Laplacian energy focus measure.
86 Sensitive to edges and high-frequency content.
88 Args:
89 img: Input grayscale image
90 ksize: Kernel size for Laplacian
92 Returns:
93 Focus quality score
94 """
95 lap = cv2.Laplacian(img, cv2.CV_64F, ksize=ksize)
96 return np.mean(np.square(lap))
98 @staticmethod
99 def tenengrad_variance(img: np.ndarray, ksize: int = 3, threshold: float = 0) -> float:
100 """
101 Tenengrad variance focus measure.
102 Based on gradient magnitude.
104 Args:
105 img: Input grayscale image
106 ksize: Kernel size for Sobel operator
107 threshold: Threshold for gradient magnitude
109 Returns:
110 Focus quality score
111 """
112 gx = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=ksize)
113 gy = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=ksize)
114 fm = gx**2 + gy**2
115 fm[fm < threshold] = 0 # Thresholding to reduce noise impact
117 return np.mean(fm)
119 @staticmethod
120 def adaptive_fft_focus(img: np.ndarray) -> float:
121 """
122 Adaptive FFT focus measure optimized for low-contrast microscopy images.
123 Uses image statistics to set threshold adaptively.
125 Args:
126 img: Input grayscale image
128 Returns:
129 Focus quality score
130 """
131 # Apply FFT
132 fft = np.fft.fft2(img)
133 fft_shift = np.fft.fftshift(fft)
134 magnitude = np.abs(fft_shift)
136 # Calculate image statistics for adaptive thresholding
137 # Only img_std is used for thresholding
138 img_std = np.std(img)
140 # Adaptive threshold based on image statistics
141 threshold_factor = max(0.1, min(1.0, img_std / 50.0))
142 threshold = np.max(magnitude) * threshold_factor
144 # Count frequency components above threshold
145 high_freq_count = np.sum(magnitude > threshold)
147 # Normalize by image size
148 score = high_freq_count / (img.shape[0] * img.shape[1])
150 return score
152 @staticmethod
153 def combined_focus_measure(
154 img: np.ndarray,
155 weights: Optional[Dict[str, float]] = None
156 ) -> float:
157 """
158 Combined focus measure using multiple metrics.
159 Optimized for microscopy images, especially low-contrast specimens.
161 This method combines multiple focus metrics (normalized variance, Laplacian energy,
162 Tenengrad variance, and FFT-based focus) using weighted averaging. The weights
163 determine the contribution of each metric to the final score.
165 Args:
166 img: Input grayscale image
167 weights: Weights for each metric as a dictionary with keys 'nvar', 'lap', 'ten', 'fft'.
168 If None, uses DEFAULT_WEIGHTS (nvar=0.3, lap=0.3, ten=0.2, fft=0.2).
169 Provide custom weights when you want to emphasize specific focus characteristics:
170 - Increase 'nvar' weight for better performance with illumination variations
171 - Increase 'lap' weight for better edge detection
172 - Increase 'ten' weight for better gradient-based focus
173 - Increase 'fft' weight for better frequency domain analysis
175 Returns:
176 Combined focus quality score (higher values indicate better focus)
178 Example:
179 ```python
180 # Use default weights
181 score = FocusAnalyzer.combined_focus_measure(image)
183 # Use custom weights to emphasize edges
184 custom_weights = {'nvar': 0.2, 'lap': 0.5, 'ten': 0.2, 'fft': 0.1}
185 score = FocusAnalyzer.combined_focus_measure(image, weights=custom_weights)
186 ```
187 """
188 # Use provided weights or defaults
189 if weights is None:
190 weights = FocusAnalyzer.DEFAULT_WEIGHTS
192 # Calculate individual metrics
193 nvar = FocusAnalyzer.normalized_variance(img)
194 lap = FocusAnalyzer.laplacian_energy(img)
195 ten = FocusAnalyzer.tenengrad_variance(img)
196 fft = FocusAnalyzer.adaptive_fft_focus(img)
198 # Weighted combination
199 score = (
200 weights.get('nvar', 0.3) * nvar +
201 weights.get('lap', 0.3) * lap +
202 weights.get('ten', 0.2) * ten +
203 weights.get('fft', 0.2) * fft
204 )
206 return score
208 @staticmethod
209 def _get_focus_function(metric: Union[str, Dict[str, float]]):
210 """
211 Get the appropriate focus measure function based on metric.
213 Args:
214 metric: Focus detection method name or weights dictionary
215 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
216 If dict: Weights for combined focus measure
218 Returns:
219 callable: The focus measure function and any additional arguments
221 Raises:
222 ValueError: If the method is unknown
223 """
224 # If metric is a dictionary, use it as weights for combined focus measure
225 if isinstance(metric, dict):
226 return lambda img: FocusAnalyzer.combined_focus_measure(img, metric)
228 # Otherwise, treat it as a string method name
229 if metric == 'combined':
230 return FocusAnalyzer.combined_focus_measure
231 if metric in ('nvar', 'normalized_variance'):
232 return FocusAnalyzer.normalized_variance
233 if metric in ('lap', 'laplacian'):
234 return FocusAnalyzer.laplacian_energy
235 if metric in ('ten', 'tenengrad'):
236 return FocusAnalyzer.tenengrad_variance
237 if metric == 'fft':
238 return FocusAnalyzer.adaptive_fft_focus
240 # If we get here, the metric is unknown
241 raise ValueError(f"Unknown focus method: {metric}")
243 @staticmethod
244 def find_best_focus(
245 image_stack: List[np.ndarray],
246 metric: Union[str, Dict[str, float]] = "combined"
247 ) -> Tuple[int, List[Tuple[int, float]]]:
248 """
249 Find the best focused image in a stack using specified method.
251 Args:
252 image_stack: List of images
253 metric: Focus detection method or weights dictionary
254 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
255 If dict: Weights for combined focus measure
257 Returns:
258 Tuple of (best_focus_index, focus_scores)
259 """
260 focus_scores = []
262 # Get the appropriate focus measure function
263 focus_func = FocusAnalyzer._get_focus_function(metric)
265 # Process each image in stack
266 for i, img in enumerate(image_stack):
267 # Calculate focus score
268 score = focus_func(img)
269 focus_scores.append((i, score))
271 # Find index with maximum focus score
272 best_focus_idx = max(focus_scores, key=lambda x: x[1])[0]
274 return best_focus_idx, focus_scores
276 @staticmethod
277 def select_best_focus(
278 image_stack: List[np.ndarray],
279 metric: Union[str, Dict[str, float]] = "combined"
280 ) -> Tuple[np.ndarray, int, List[Tuple[int, float]]]:
281 """
282 Select the best focus plane from a stack of images.
284 Args:
285 image_stack: List of images
286 metric: Focus detection method or weights dictionary
287 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
288 If dict: Weights for combined focus measure
290 Returns:
291 Tuple of (best_focus_image, best_focus_index, focus_scores)
292 """
293 best_idx, scores = FocusAnalyzer.find_best_focus(image_stack, metric)
294 return image_stack[best_idx], best_idx, scores
296 @staticmethod
297 def compute_focus_metrics(image_stack: List[np.ndarray],
298 metric: Union[str, Dict[str, float]] = "combined") -> List[float]:
299 """
300 Compute focus metrics for a stack of images.
302 Args:
303 image_stack: List of images
304 metric: Focus detection method or weights dictionary
305 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
306 If dict: Weights for combined focus measure
308 Returns:
309 List of focus scores for each image
310 """
311 focus_scores = []
313 # Get the appropriate focus measure function
314 focus_func = FocusAnalyzer._get_focus_function(metric)
316 # Process each image in stack
317 for img in image_stack:
318 # Calculate focus score
319 score = focus_func(img)
320 focus_scores.append(score)
322 return focus_scores