Coverage for openhcs/processing/backends/analysis/focus_analyzer.py: 22.2%
91 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1from __future__ import annotations
3import logging
4from typing import Dict, List, Optional, Tuple, Union
6import cv2
7import numpy as np
9logger = logging.getLogger(__name__)
11class FocusAnalyzer:
12 """
13 Provides focus metrics and best focus selection.
15 This class implements various focus measure algorithms and methods to find
16 the best focused image in a Z-stack. All methods are static and do not require
17 an instance.
19 Usage:
20 The class provides several focus metrics and methods to select the best focused
21 image from a Z-stack. The main methods are:
23 - find_best_focus: Returns the index of the best focused image in a stack
24 - select_best_focus: Returns the best focused image from a stack
25 - compute_focus_metrics: Computes focus metrics for all images in a stack
27 For most use cases, it's recommended to use the select_best_focus method
28 with an explicit metric parameter:
30 ```python
31 best_image, best_idx, scores = FocusAnalyzer.select_best_focus(
32 image_stack,
33 metric="laplacian" # Explicitly specify the metric
34 )
35 ```
37 Available metrics:
38 - "combined": Uses a weighted combination of all metrics (default)
39 - "normalized_variance" or "nvar": Uses normalized variance
40 - "laplacian" or "lap": Uses Laplacian energy
41 - "tenengrad" or "ten": Uses Tenengrad variance
42 - "fft": Uses adaptive FFT focus measure
44 You can also provide a custom weights dictionary for the combined metric:
46 ```python
47 custom_weights = {'nvar': 0.4, 'lap': 0.4, 'ten': 0.1, 'fft': 0.1}
48 best_image, best_idx, scores = FocusAnalyzer.select_best_focus(
49 image_stack,
50 metric=custom_weights
51 )
52 ```
53 """
55 # Default weights for the combined focus measure.
56 # These weights are used when no custom weights are provided to the
57 # combined_focus_measure method or when using the "combined" metric
58 # with find_best_focus, select_best_focus, or compute_focus_metrics.
59 # The weights determine the contribution of each focus metric to the final score:
60 DEFAULT_WEIGHTS = {
61 'nvar': 0.3, # Normalized variance (robust to illumination changes)
62 'lap': 0.3, # Laplacian energy (sensitive to edges)
63 'ten': 0.2, # Tenengrad variance (based on gradient magnitude)
64 'fft': 0.2 # FFT-based focus (frequency domain analysis)
65 }
67 @staticmethod
68 def normalized_variance(img: np.ndarray) -> float:
69 """
70 Normalized variance focus measure.
71 Robust to illumination changes.
73 Args:
74 img: Input grayscale image
76 Returns:
77 Focus quality score
78 """
79 mean_val = np.mean(img)
80 if mean_val == 0: # Avoid division by zero
81 return 0
83 return np.var(img) / mean_val
85 @staticmethod
86 def laplacian_energy(img: np.ndarray, ksize: int = 3) -> float:
87 """
88 Laplacian energy focus measure.
89 Sensitive to edges and high-frequency content.
91 Args:
92 img: Input grayscale image
93 ksize: Kernel size for Laplacian
95 Returns:
96 Focus quality score
97 """
98 lap = cv2.Laplacian(img, cv2.CV_64F, ksize=ksize)
99 return np.mean(np.square(lap))
101 @staticmethod
102 def tenengrad_variance(img: np.ndarray, ksize: int = 3, threshold: float = 0) -> float:
103 """
104 Tenengrad variance focus measure.
105 Based on gradient magnitude.
107 Args:
108 img: Input grayscale image
109 ksize: Kernel size for Sobel operator
110 threshold: Threshold for gradient magnitude
112 Returns:
113 Focus quality score
114 """
115 gx = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=ksize)
116 gy = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=ksize)
117 fm = gx**2 + gy**2
118 fm[fm < threshold] = 0 # Thresholding to reduce noise impact
120 return np.mean(fm)
122 @staticmethod
123 def adaptive_fft_focus(img: np.ndarray) -> float:
124 """
125 Adaptive FFT focus measure optimized for low-contrast microscopy images.
126 Uses image statistics to set threshold adaptively.
128 Args:
129 img: Input grayscale image
131 Returns:
132 Focus quality score
133 """
134 # Apply FFT
135 fft = np.fft.fft2(img)
136 fft_shift = np.fft.fftshift(fft)
137 magnitude = np.abs(fft_shift)
139 # Calculate image statistics for adaptive thresholding
140 # Only img_std is used for thresholding
141 img_std = np.std(img)
143 # Adaptive threshold based on image statistics
144 threshold_factor = max(0.1, min(1.0, img_std / 50.0))
145 threshold = np.max(magnitude) * threshold_factor
147 # Count frequency components above threshold
148 high_freq_count = np.sum(magnitude > threshold)
150 # Normalize by image size
151 score = high_freq_count / (img.shape[0] * img.shape[1])
153 return score
155 @staticmethod
156 def combined_focus_measure(
157 img: np.ndarray,
158 weights: Optional[Dict[str, float]] = None
159 ) -> float:
160 """
161 Combined focus measure using multiple metrics.
162 Optimized for microscopy images, especially low-contrast specimens.
164 This method combines multiple focus metrics (normalized variance, Laplacian energy,
165 Tenengrad variance, and FFT-based focus) using weighted averaging. The weights
166 determine the contribution of each metric to the final score.
168 Args:
169 img: Input grayscale image
170 weights: Weights for each metric as a dictionary with keys 'nvar', 'lap', 'ten', 'fft'.
171 If None, uses DEFAULT_WEIGHTS (nvar=0.3, lap=0.3, ten=0.2, fft=0.2).
172 Provide custom weights when you want to emphasize specific focus characteristics:
173 - Increase 'nvar' weight for better performance with illumination variations
174 - Increase 'lap' weight for better edge detection
175 - Increase 'ten' weight for better gradient-based focus
176 - Increase 'fft' weight for better frequency domain analysis
178 Returns:
179 Combined focus quality score (higher values indicate better focus)
181 Example:
182 ```python
183 # Use default weights
184 score = FocusAnalyzer.combined_focus_measure(image)
186 # Use custom weights to emphasize edges
187 custom_weights = {'nvar': 0.2, 'lap': 0.5, 'ten': 0.2, 'fft': 0.1}
188 score = FocusAnalyzer.combined_focus_measure(image, weights=custom_weights)
189 ```
190 """
191 # Use provided weights or defaults
192 if weights is None:
193 weights = FocusAnalyzer.DEFAULT_WEIGHTS
195 # Calculate individual metrics
196 nvar = FocusAnalyzer.normalized_variance(img)
197 lap = FocusAnalyzer.laplacian_energy(img)
198 ten = FocusAnalyzer.tenengrad_variance(img)
199 fft = FocusAnalyzer.adaptive_fft_focus(img)
201 # Weighted combination
202 score = (
203 weights.get('nvar', 0.3) * nvar +
204 weights.get('lap', 0.3) * lap +
205 weights.get('ten', 0.2) * ten +
206 weights.get('fft', 0.2) * fft
207 )
209 return score
211 @staticmethod
212 def _get_focus_function(metric: Union[str, Dict[str, float]]):
213 """
214 Get the appropriate focus measure function based on metric.
216 Args:
217 metric: Focus detection method name or weights dictionary
218 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
219 If dict: Weights for combined focus measure
221 Returns:
222 callable: The focus measure function and any additional arguments
224 Raises:
225 ValueError: If the method is unknown
226 """
227 # If metric is a dictionary, use it as weights for combined focus measure
228 if isinstance(metric, dict):
229 return lambda img: FocusAnalyzer.combined_focus_measure(img, metric)
231 # Otherwise, treat it as a string method name
232 if metric == 'combined':
233 return FocusAnalyzer.combined_focus_measure
234 if metric in ('nvar', 'normalized_variance'):
235 return FocusAnalyzer.normalized_variance
236 if metric in ('lap', 'laplacian'):
237 return FocusAnalyzer.laplacian_energy
238 if metric in ('ten', 'tenengrad'):
239 return FocusAnalyzer.tenengrad_variance
240 if metric == 'fft':
241 return FocusAnalyzer.adaptive_fft_focus
243 # If we get here, the metric is unknown
244 raise ValueError(f"Unknown focus method: {metric}")
246 @staticmethod
247 def find_best_focus(
248 image_stack: np.ndarray, # Changed from List[np.ndarray] to np.ndarray (Z, H, W)
249 metric: Union[str, Dict[str, float]] = "combined"
250 ) -> Tuple[int, List[Tuple[int, float]]]:
251 """
252 Find the best focused image in a 3D stack using specified method.
254 Args:
255 image_stack: 3D NumPy array of shape (Z, H, W).
256 metric: Focus detection method or weights dictionary
257 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
258 If dict: Weights for combined focus measure
260 Returns:
261 Tuple of (best_focus_index, focus_scores)
262 """
263 if not isinstance(image_stack, np.ndarray) or image_stack.ndim != 3:
264 raise TypeError("image_stack must be a 3D NumPy ndarray of shape (Z, H, W).")
266 focus_scores = []
267 focus_func = FocusAnalyzer._get_focus_function(metric)
269 for i in range(image_stack.shape[0]): # Iterate over Z dimension
270 img_slice = image_stack[i, :, :]
271 score = focus_func(img_slice)
272 focus_scores.append((i, score))
274 if not focus_scores: # Should not happen if image_stack is not empty
275 raise ValueError("Could not compute focus scores, image_stack might be empty or invalid.")
277 best_focus_idx = max(focus_scores, key=lambda x: x[1])[0]
278 return best_focus_idx, focus_scores
280 @staticmethod
281 def select_best_focus(
282 image_stack: np.ndarray, # Changed from List[np.ndarray] to np.ndarray (Z, H, W)
283 metric: Union[str, Dict[str, float]] = "combined"
284 ) -> Tuple[np.ndarray, int, List[Tuple[int, float]]]: # Return best image as (1,H,W)
285 """
286 Select the best focus plane from a 3D stack of images.
288 Args:
289 image_stack: 3D NumPy array of shape (Z, H, W).
290 metric: Focus detection method or weights dictionary
291 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
292 If dict: Weights for combined focus measure
294 Returns:
295 Tuple of (best_focus_image (1,H,W), best_focus_index, focus_scores)
296 """
297 best_idx, scores = FocusAnalyzer.find_best_focus(image_stack, metric)
298 best_image_slice = image_stack[best_idx, :, :]
299 # Return as a 3D array with a single Z-slice
300 return best_image_slice.reshape(1, best_image_slice.shape[0], best_image_slice.shape[1]), best_idx, scores
302 @staticmethod
303 def compute_focus_metrics(image_stack: np.ndarray, # Changed from List[np.ndarray]
304 metric: Union[str, Dict[str, float]] = "combined") -> List[float]:
305 """
306 Compute focus metrics for a 3D stack of images.
308 Args:
309 image_stack: 3D NumPy array of shape (Z, H, W).
310 metric: Focus detection method or weights dictionary
311 If string: "combined", "normalized_variance", "laplacian", "tenengrad", "fft"
312 If dict: Weights for combined focus measure
314 Returns:
315 List of focus scores for each image slice
316 """
317 if not isinstance(image_stack, np.ndarray) or image_stack.ndim != 3:
318 raise TypeError("image_stack must be a 3D NumPy ndarray of shape (Z, H, W).")
320 focus_scores = []
321 focus_func = FocusAnalyzer._get_focus_function(metric)
323 for i in range(image_stack.shape[0]): # Iterate over Z dimension
324 img_slice = image_stack[i, :, :]
325 score = focus_func(img_slice)
326 focus_scores.append(score)
328 return focus_scores