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

1from __future__ import annotations 

2 

3import logging 

4from typing import Dict, List, Optional, Tuple, Union 

5 

6import cv2 

7import numpy as np 

8 

9logger = logging.getLogger(__name__) 

10 

11class FocusAnalyzer: 

12 """ 

13 Provides focus metrics and best focus selection. 

14 

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. 

18 

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: 

22 

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 

26 

27 For most use cases, it's recommended to use the select_best_focus method 

28 with an explicit metric parameter: 

29 

30 ```python 

31 best_image, best_idx, scores = FocusAnalyzer.select_best_focus( 

32 image_stack, 

33 metric="laplacian" # Explicitly specify the metric 

34 ) 

35 ``` 

36 

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 

43 

44 You can also provide a custom weights dictionary for the combined metric: 

45 

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 """ 

54 

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 } 

66 

67 @staticmethod 

68 def normalized_variance(img: np.ndarray) -> float: 

69 """ 

70 Normalized variance focus measure. 

71 Robust to illumination changes. 

72 

73 Args: 

74 img: Input grayscale image 

75 

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 

82 

83 return np.var(img) / mean_val 

84 

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. 

90 

91 Args: 

92 img: Input grayscale image 

93 ksize: Kernel size for Laplacian 

94 

95 Returns: 

96 Focus quality score 

97 """ 

98 lap = cv2.Laplacian(img, cv2.CV_64F, ksize=ksize) 

99 return np.mean(np.square(lap)) 

100 

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. 

106 

107 Args: 

108 img: Input grayscale image 

109 ksize: Kernel size for Sobel operator 

110 threshold: Threshold for gradient magnitude 

111 

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 

119 

120 return np.mean(fm) 

121 

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. 

127 

128 Args: 

129 img: Input grayscale image 

130 

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) 

138 

139 # Calculate image statistics for adaptive thresholding 

140 # Only img_std is used for thresholding 

141 img_std = np.std(img) 

142 

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 

146 

147 # Count frequency components above threshold 

148 high_freq_count = np.sum(magnitude > threshold) 

149 

150 # Normalize by image size 

151 score = high_freq_count / (img.shape[0] * img.shape[1]) 

152 

153 return score 

154 

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. 

163 

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. 

167 

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 

177 

178 Returns: 

179 Combined focus quality score (higher values indicate better focus) 

180 

181 Example: 

182 ```python 

183 # Use default weights 

184 score = FocusAnalyzer.combined_focus_measure(image) 

185 

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 

194 

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) 

200 

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 ) 

208 

209 return score 

210 

211 @staticmethod 

212 def _get_focus_function(metric: Union[str, Dict[str, float]]): 

213 """ 

214 Get the appropriate focus measure function based on metric. 

215 

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 

220 

221 Returns: 

222 callable: The focus measure function and any additional arguments 

223 

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) 

230 

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 

242 

243 # If we get here, the metric is unknown 

244 raise ValueError(f"Unknown focus method: {metric}") 

245 

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. 

253 

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 

259 

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).") 

265 

266 focus_scores = [] 

267 focus_func = FocusAnalyzer._get_focus_function(metric) 

268 

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)) 

273 

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.") 

276 

277 best_focus_idx = max(focus_scores, key=lambda x: x[1])[0] 

278 return best_focus_idx, focus_scores 

279 

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. 

287 

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 

293 

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 

301 

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. 

307 

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 

313 

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).") 

319 

320 focus_scores = [] 

321 focus_func = FocusAnalyzer._get_focus_function(metric) 

322 

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) 

327 

328 return focus_scores