Source code for autoemx.core.em_runtime.sample_finder

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Electron Microscope Sample Finder module.

Provides utilities for locating and managing samples within the EM, particularly
for detecting carbon tape position and dimensions.

Classes
-------
EM_Sample_Finder
    Utilities for locating samples and detecting carbon tape.

Created on 2026
@author: Andrea
"""
import os
from typing import Optional, Union, Tuple

import numpy as np
import cv2

from autoemx.utils.helper import EMError, print_single_separator
from autoemx.core.em_runtime.image_utilities import normalise_img
import autoemx.utils.constants as cnst
from autoemx import microscope_drivers as EM_driver

from autoemx._logging import get_logger
logger = get_logger(__name__)


[docs] class EM_Sample_Finder: """ Class for locating and managing samples in an electron microscope (EM). Provides methods for detecting sample features (such as the center and size of a C tape) using the microscope's navigation camera. Attributes ---------- microscope_ID : str Identifier for the target microscope. center_pos : np.ndarray Initial center position of sample as [x, y] coordinates in mm. results_dir : Optional[str] Directory path to save results, or None if not set. verbose : bool If True, enables detailed debug output. development_mode : bool If True, enables offline testing without real-time acquisition. Example ------- >>> import numpy as np >>> sample_center = np.array([0.0, 0.0]) >>> finder = EM_Sample_Finder( ... microscope_ID='MySEM', ... center_pos=sample_center, ... sample_half_width_mm=3, ... substrate_width_mm=12, ... results_dir='./results', ... verbose=True ... ) >>> ctape_result = finder.detect_Ctape() >>> if ctape_result is not None: ... center_pos, sample_hw_mm = ctape_result ... print("C tape center (mm):", center_pos) ... print("C tape half-width (mm):", sample_hw_mm) Notes ----- - The navigation camera image can be provided directly (for offline testing) or acquired live from the microscope. - For successful detection, the microscope calibration file must include: 'navcam_im_w_mm', 'navcam_x_offset', 'navcam_y_offset'. """ def __init__( self, microscope_ID: str, center_pos: Union[np.ndarray, tuple, list], sample_half_width_mm: float, substrate_width_mm: float, development_mode: Optional[bool] = False, results_dir: Optional[str] = None, verbose: bool = False ): """ Initialize the EM_Sample_Finder object. Parameters ---------- microscope_ID : str Identifier for the target microscope. center_pos : array-like Initial center position as [x, y] coordinates in mm. sample_half_width_mm : float Half-width of sample in mm. substrate_width_mm : float Width of substrate (e.g., carbon tape) in mm. development_mode : bool, optional If True, enables offline testing mode (default: False). results_dir : Optional[str], optional Directory path to save results (default: None). verbose : bool, optional If True, enables detailed debug output (default: False). """ self.microscope_ID = microscope_ID # Load microscope driver EM_driver.load_microscope_driver(microscope_ID) self.EM_driver = EM_driver if not development_mode: try: connect_result = False if hasattr(self.EM_driver, "connect_to_microscope"): connect_result = bool(self.EM_driver.connect_to_microscope(warn_if_unavailable=True)) is_connected = connect_result if hasattr(self.EM_driver, "is_microscope_connected"): is_connected = bool(self.EM_driver.is_microscope_connected()) or is_connected except Exception as e: raise EMError("Instrument driver could not be loaded") from e if not is_connected: raise EMError("Instrument driver could not be loaded") self._sample_half_width_mm = sample_half_width_mm self._substrate_width_mm = substrate_width_mm self._center_pos = np.array(center_pos, dtype=float) self.results_dir = results_dir self.development_mode = development_mode self.verbose = verbose
[docs] def detect_Ctape( self, navcam_im: Optional[np.ndarray] = None, ) -> Optional[Tuple[np.ndarray, float]]: """ Detect the effective center position and radius of the C tape. Uses the provided navigation camera image or acquires it from the microscope. Applies edge detection and circle fitting to locate the tape. Parameters ---------- navcam_im : Optional[np.ndarray] Navigation camera image. If None, acquires from microscope. Returns ------- Optional[Tuple[np.ndarray, float]] Tuple of (center_pos, sample_hw_mm) if detection successful, else None. """ if self.verbose: print_single_separator('🔬 Detecting position of C tape...') # Collect NavCam image if navcam_im is None: navcam_im = self.EM_driver.get_navigation_camera_image() if navcam_im is None or not hasattr(navcam_im, "shape"): logger.warning("⚠️ No valid navigation camera image provided or acquired. C-tape detection skipped") return None # Get size of image in pixels navcam_h, navcam_w, _ = navcam_im.shape # Load navigation camera calibrated parameters required_attrs = ['navcam_im_w_mm', 'navcam_x_offset', 'navcam_y_offset'] for attr in required_attrs: if not hasattr(self.EM_driver, attr): raise AttributeError( f"The driver of microscope '{self.microscope_ID}' is missing the required navigation camera " f"calibration attribute '{attr}'" ) # Calculate pixel size navcam_pixel_size = self.EM_driver.navcam_im_w_mm / navcam_w # Calculate position of center of stub within navcam_im (in pixels) stub_c = ( (self._center_pos / navcam_pixel_size + np.array([navcam_w, -navcam_h]) / 2) * np.array([1, -1]) ).astype(np.uint16) stub_c += np.array([ self.EM_driver.navcam_x_offset, self.EM_driver.navcam_y_offset ]).astype(np.uint16) # Calculate size of stub half-width in pixels stub_hw = int(self._substrate_width_mm / navcam_pixel_size / 2) # Crop image around stub y1 = max(stub_c[1] - stub_hw, 0) y2 = min(stub_c[1] + stub_hw + 1, navcam_h) x1 = max(stub_c[0] - stub_hw, 0) x2 = min(stub_c[0] + stub_hw + 1, navcam_w) stub_im = navcam_im[y1:y2, x1:x2] # Detect edges stub_im_normalized = normalise_img(stub_im) channels = cv2.split(stub_im_normalized) edges_channels = [ cv2.Canny(cv2.GaussianBlur(channel, (9, 9), 1.5), 100, 200) for channel in channels ] edges_combined = cv2.merge(edges_channels) if self.development_mode: cv2.imshow('Combined Edges (RGB)', edges_combined) gray = cv2.cvtColor(edges_combined, cv2.COLOR_BGR2GRAY) gray[gray < 100] = 0 if self.development_mode: cv2.imshow('Thresholded Gray', gray) # Detect circles in image min_radius = int(self._sample_half_width_mm / navcam_pixel_size / 1.5) max_radius = int(stub_hw * 0.9) circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1.4, minDist=5, param1=50, param2=50, minRadius=min_radius, maxRadius=max_radius ) if self.development_mode and circles is not None: output = stub_im.copy() circles_uint16 = np.uint16(np.around(circles)) for (x, y, r) in circles_uint16[0, :]: cv2.circle(output, (x, y), r, (0, 255, 0), 2) cv2.circle(output, (x, y), 2, (0, 0, 255), 3) cv2.imshow('Detected Circles', output) # Filter circles by intensity stub_im_original = normalise_img(stub_im) filtered_circles = self._filter_circles_by_intensity( circles, stub_im_original, min_radius ) if self.development_mode: filtered_debug = stub_im_original.copy() for (x, y, r) in filtered_circles: cv2.circle(filtered_debug, (x, y), r, (255, 0, 0), 2) cv2.imshow('Filtered Circles', filtered_debug) # Find best circle x, y, r = self._find_best_circle(filtered_circles, min_radius) # Compute output if len(filtered_circles) > 0 and x is not None and y is not None and r is not None: center_pos_eff = ( self._center_pos + (np.array([x, y]) - stub_hw) * navcam_pixel_size * np.array([1, -1]) ) sample_hw_mm = r * navcam_pixel_size * 0.9 Ctape_coords = (center_pos_eff, sample_hw_mm) if self.verbose: logger.info('✅ C tape detected.') else: x, y = stub_hw, stub_hw r = int(self._sample_half_width_mm / navcam_pixel_size) Ctape_coords = None if self.verbose: logger.warning( f'⚠️ The C tape could not be automatically detected. ' f'Using {tuple(float(v) for v in self._center_pos)} instead.' ) # Draw detected circle on image cv2.circle(stub_im, (x, y), r, (0, 255, 0), 1) cv2.rectangle(stub_im, (x - 5, y - 5), (x + 5, y + 5), (0, 128, 255), -1) if self.development_mode: cv2.imshow('Detected region', stub_im) # Save result image if results_dir is set if self.results_dir: filename = os.path.join(self.results_dir, cnst.NAVCAM_IM_FILENAME + '.png') cv2.imwrite(filename, stub_im) return Ctape_coords
def _filter_circles_by_intensity(self, circles, stub_im, min_radius): """Filter detected circles by average pixel intensity.""" filtered_circles = [] if circles is not None: circles_int = np.round(circles[0, :]).astype("int") for (x, y, r) in circles_int: avg_intensity = self._average_pixel_intensity(stub_im, (x, y), r) if avg_intensity <= 100: filtered_circles.append((x, y, r)) return filtered_circles def _average_pixel_intensity(self, image: np.ndarray, center: tuple, radius: float) -> float: """Calculate average pixel intensity within a circular region.""" # Convert color to grayscale if needed if len(image.shape) == 3 and image.shape[2] == 3: gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) else: gray_image = image x, y = int(center[0]), int(center[1]) mask = np.zeros(gray_image.shape, dtype=np.uint8) cv2.circle(mask, (x, y), int(radius), 255, -1) masked_pixels = gray_image[mask == 255] if masked_pixels.size == 0: return 0.0 return float(np.mean(masked_pixels)) def _find_best_circle(self, filtered_circles, min_radius): """Find the best circle from filtered candidates.""" x, y, r = None, None, None if len(filtered_circles) > 1: avg_x = np.mean([circle[0] for circle in filtered_circles]) avg_y = np.mean([circle[1] for circle in filtered_circles]) avg_center = (int(avg_x), int(avg_y)) distances = [ circle[2] - np.sqrt((circle[0] - avg_x) ** 2 + (circle[1] - avg_y) ** 2) for circle in filtered_circles ] intersection_radius = int(min(distances)) x, y = avg_center r = max(intersection_radius, min_radius) elif len(filtered_circles) > 0: x, y, r = filtered_circles[0] return x, y, r