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

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

2import logging 

3import numpy as np 

4import cv2 

5 

6logger = logging.getLogger(__name__) 

7 

8class FocusAnalyzer: 

9 """ 

10 Provides focus metrics and best focus selection. 

11 

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. 

15 

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: 

19 

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 

23 

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

25 with an explicit metric parameter: 

26 

27 ```python 

28 best_image, best_idx, scores = FocusAnalyzer.select_best_focus( 

29 image_stack, 

30 metric="laplacian" # Explicitly specify the metric 

31 ) 

32 ``` 

33 

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 

40 

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

42 

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

51 

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 } 

63 

64 @staticmethod 

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

66 """ 

67 Normalized variance focus measure. 

68 Robust to illumination changes. 

69 

70 Args: 

71 img: Input grayscale image 

72 

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 

79 

80 return np.var(img) / mean_val 

81 

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. 

87 

88 Args: 

89 img: Input grayscale image 

90 ksize: Kernel size for Laplacian 

91 

92 Returns: 

93 Focus quality score 

94 """ 

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

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

97 

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. 

103 

104 Args: 

105 img: Input grayscale image 

106 ksize: Kernel size for Sobel operator 

107 threshold: Threshold for gradient magnitude 

108 

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 

116 

117 return np.mean(fm) 

118 

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. 

124 

125 Args: 

126 img: Input grayscale image 

127 

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) 

135 

136 # Calculate image statistics for adaptive thresholding 

137 # Only img_std is used for thresholding 

138 img_std = np.std(img) 

139 

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 

143 

144 # Count frequency components above threshold 

145 high_freq_count = np.sum(magnitude > threshold) 

146 

147 # Normalize by image size 

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

149 

150 return score 

151 

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. 

160 

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. 

164 

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 

174 

175 Returns: 

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

177 

178 Example: 

179 ```python 

180 # Use default weights 

181 score = FocusAnalyzer.combined_focus_measure(image) 

182 

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 

191 

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) 

197 

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 ) 

205 

206 return score 

207 

208 @staticmethod 

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

210 """ 

211 Get the appropriate focus measure function based on metric. 

212 

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 

217 

218 Returns: 

219 callable: The focus measure function and any additional arguments 

220 

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) 

227 

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 

239 

240 # If we get here, the metric is unknown 

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

242 

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. 

250 

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 

256 

257 Returns: 

258 Tuple of (best_focus_index, focus_scores) 

259 """ 

260 focus_scores = [] 

261 

262 # Get the appropriate focus measure function 

263 focus_func = FocusAnalyzer._get_focus_function(metric) 

264 

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

270 

271 # Find index with maximum focus score 

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

273 

274 return best_focus_idx, focus_scores 

275 

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. 

283 

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 

289 

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 

295 

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. 

301 

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 

307 

308 Returns: 

309 List of focus scores for each image 

310 """ 

311 focus_scores = [] 

312 

313 # Get the appropriate focus measure function 

314 focus_func = FocusAnalyzer._get_focus_function(metric) 

315 

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) 

321 

322 return focus_scores