Skip to content

Commit 56da6b2

Browse files
committed
Added par_spot_selection_mode
1 parent 626592a commit 56da6b2

13 files changed

Lines changed: 304 additions & 33 deletions

File tree

autoemx/config/runtime_configs.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"""
2727
import numpy as np
2828
import multiprocessing
29-
from typing import Any, ClassVar, Dict, List, Optional, Tuple
29+
from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple
3030

3131
from pydantic import BaseModel, ConfigDict, Field, model_validator
3232

@@ -345,7 +345,7 @@ class PowderMeasurementConfig(BaseModel):
345345
Configuration for powder measurement.
346346
347347
Attributes:
348-
is_manual_particle_selection (bool): Whether to manually navigate sample to select particles to analyse (Default = False).
348+
par_selection_mode (str): 'auto' for automatic particle navigation, 'manual' to prompt user to center each particle (default: 'auto').
349349
is_known_powder_mixture_meas (bool): Whether sample is a known binary mixture of powders. Used to characterize precursor extent of intermixing (Default = False).
350350
img_shift_tracking (bool): Whether to use image shift tracking during acquisition (Default = True).
351351
par_search_frame_width_um (float, optional): Frame width used when searching for particles, in um.
@@ -365,10 +365,13 @@ class PowderMeasurementConfig(BaseModel):
365365
Particle pixel intensities are scaled to 8-bit prior threhsolding, i.e., darkest pixel will be set to 0, and brightest to 255.
366366
par_feature_selection (str): 'random' for random selection of points within bright regions, 'peaks' for brightest peak spots (default: 'random').
367367
par_spot_spacing (str): 'random' for unbiased spot selecton, 'maximized' for maximized spot spacing over particle (default: 'random').
368+
par_spot_selection_mode (str): 'auto' for built-in spot selection, 'callback' to supply spots via xsp_spot_selector (default: 'auto').
368369
"""
369370
DEFAULT_PAR_SEGMENTATION_MODEL: ClassVar[str] = "threshold_bright"
371+
AVAILABLE_SPOT_SELECTION_MODES: ClassVar[Tuple[str, ...]] = ('auto', 'callback')
372+
AVAILABLE_PAR_SELECTION: ClassVar[Tuple[str, ...]] = ('auto', 'manual')
370373

371-
is_manual_particle_selection: bool = False
374+
par_selection_mode: Literal['auto', 'manual'] = 'auto'
372375
is_known_powder_mixture_meas: bool = False
373376
img_shift_tracking: bool = True
374377
par_search_frame_width_um: Optional[float] = None
@@ -378,7 +381,8 @@ class PowderMeasurementConfig(BaseModel):
378381
min_area_par: float = 10.0 # µm²
379382
par_mask_margin: float = 1.0 # µm
380383
xsp_spots_distance_um: float = 1.0 # µm
381-
par_segmentation_model: str = "threshold_bright"
384+
par_spot_selection_mode: Literal['auto', 'callback'] = 'auto'
385+
par_segmentation_model: str = DEFAULT_PAR_SEGMENTATION_MODEL # "threshold_bright"
382386
par_brightness_thresh: int = 100 # in 8-bit image
383387
par_xy_spots_thresh: int = 100
384388
par_feature_selection: str = 'random'
@@ -390,6 +394,17 @@ class PowderMeasurementConfig(BaseModel):
390394

391395
model_config = ConfigDict(extra="forbid")
392396

397+
@model_validator(mode="before")
398+
@classmethod
399+
def _normalize_legacy_fields(cls, data: Any) -> Any:
400+
if isinstance(data, dict):
401+
cleaned = dict(data)
402+
legacy_manual = cleaned.pop("is_manual_particle_selection", None)
403+
if legacy_manual is not None and "par_selection_mode" not in cleaned:
404+
cleaned["par_selection_mode"] = "manual" if legacy_manual else "auto"
405+
return cleaned
406+
return data
407+
393408
@model_validator(mode="after")
394409
def _validate(self) -> "PowderMeasurementConfig":
395410
if self.par_segmentation_model not in self.AVAILABLE_PAR_SEGMENTATION_MODELS:
@@ -407,6 +422,16 @@ def _validate(self) -> "PowderMeasurementConfig":
407422
f'Value of "par_spot_spacing" set to {self.par_spot_spacing} is invalid. '
408423
f'Must be one of {self.AVAILABLE_SPOT_SPACING_SELECTION}.'
409424
)
425+
if self.par_spot_selection_mode not in self.AVAILABLE_SPOT_SELECTION_MODES:
426+
raise ValueError(
427+
f'Value of "par_spot_selection_mode" set to {self.par_spot_selection_mode} is invalid. '
428+
f'Must be one of {self.AVAILABLE_SPOT_SELECTION_MODES}.'
429+
)
430+
if self.par_selection_mode not in self.AVAILABLE_PAR_SELECTION:
431+
raise ValueError(
432+
f'Value of "par_selection_mode" set to {self.par_selection_mode} is invalid. '
433+
f'Must be one of {self.AVAILABLE_PAR_SELECTION}.'
434+
)
410435
if self.min_area_par < 0 or self.max_area_par < 0:
411436
raise ValueError("Particle area thresholds must be non-negative.")
412437
if self.max_area_par < self.min_area_par:

autoemx/core/composition_analysis/analyser.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
# Project-specific imports
8080
from autoemx.core.quantifier import XSp_Quantifier
8181
from autoemx.core.em_runtime.controller import EM_Controller
82+
from autoemx.core.em_runtime.xsp_spot_selection import XSpSpotSelectorCallback
8283
from autoemx.core.em_runtime.sample_finder import EM_Sample_Finder
8384
import autoemx.calibrations as calibs
8485
import autoemx.utils.constants as cnst
@@ -522,6 +523,7 @@ def __init__(
522523
verbose: bool = True,
523524
results_dir: Optional[str] = None,
524525
sample_id: Optional[str] = None,
526+
xsp_spot_selector: Optional[XSpSpotSelectorCallback] = None,
525527
):
526528
"""
527529
Initialize the EMXSp_Composition_Analyzer with all configuration objects.
@@ -544,6 +546,7 @@ def __init__(
544546
# --- Define use of class instance
545547
self.is_acquisition = is_acquisition
546548
self.development_mode = development_mode
549+
self.xsp_spot_selector = xsp_spot_selector
547550

548551
measurement_cfg = measurement_cfg.model_copy(
549552
update={
@@ -806,6 +809,7 @@ def _initialise_SEM(self) -> None:
806809
sample_id=self.sample_id,
807810
results_dir=EM_images_dir,
808811
verbose=self.verbose,
812+
xsp_spot_selector=self.xsp_spot_selector,
809813
)
810814
self.EM_controller.initialise_SEM()
811815
self.EM_controller.initialise_sample_navigator(exclude_sample_margin=True)
@@ -1127,7 +1131,7 @@ def _save_analysis_config_summary(self) -> None:
11271131
with open(summary_path, "w", encoding="utf-8") as fh:
11281132
fh.write("\n".join(lines))
11291133
except OSError as e:
1130-
logging.warning(f"Could not write analysis config summary: {e}")
1134+
logger.warning(f"Could not write analysis config summary: {e}")
11311135

11321136

11331137
def _resolve_active_analysis_config_ids(self) -> Tuple[int, int]:

autoemx/core/em_runtime/controller.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from autoemx.core.em_runtime.frame_navigator import FrameNavigator
6060
from autoemx.core.em_runtime.spectrum_acquisition import SpectrumAcquisition
6161
from autoemx.core.em_runtime import image_utilities
62+
from autoemx.core.em_runtime.xsp_spot_selection import XSpSpotSelectorCallback
6263

6364
from autoemx._logging import get_logger
6465
logger = get_logger(__name__)
@@ -135,6 +136,7 @@ def __init__(
135136
results_dir: Optional[str] = None,
136137
verbose: bool = True,
137138
development_mode: bool = False,
139+
xsp_spot_selector: Optional[XSpSpotSelectorCallback] = None,
138140
):
139141
"""
140142
Initialize an EM_Controller object from a ``LedgerConfigs`` bundle.
@@ -245,7 +247,8 @@ def __init__(
245247
EM_driver_obj=EM_driver,
246248
results_dir=results_dir,
247249
verbose=verbose,
248-
development_mode=development_mode
250+
development_mode=development_mode,
251+
xsp_spot_selector=xsp_spot_selector,
249252
)
250253

251254
self.spectrum_acq = SpectrumAcquisition(
@@ -274,6 +277,7 @@ def from_configs(
274277
results_dir: Optional[str] = None,
275278
verbose: bool = True,
276279
development_mode: Optional[bool] = False,
280+
xsp_spot_selector: Optional[XSpSpotSelectorCallback] = None,
277281
) -> "EM_Controller":
278282
"""
279283
Construct an EM_Controller from individual config objects.
@@ -317,6 +321,7 @@ def from_configs(
317321
results_dir=results_dir,
318322
verbose=verbose,
319323
development_mode=development_mode,
324+
xsp_spot_selector=xsp_spot_selector,
320325
)
321326

322327
#%% Microscope initialization

autoemx/core/em_runtime/frame_navigator.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414
Created on 2026
1515
@author: Andrea
1616
"""
17+
from typing import Optional
18+
1719
import time
1820
import numpy as np
1921

22+
from autoemx.core.em_runtime.xsp_spot_selection import XSpSpotSelectorCallback
23+
2024
import autoemx.utils.constants as cnst
2125
from autoemx.utils.helper import AlphabetMapper, Prompt_User, print_single_separator
2226
from autoemx.core.em_runtime.particle_finder import EM_Particle_Finder
@@ -68,7 +72,8 @@ class FrameNavigator:
6872
def __init__(self, EM_controller, sample_cfg, sample_substrate_cfg, measurement_cfg,
6973
powder_meas_cfg, bulk_meas_cfg, center_pos, sample_hw_mm,
7074
im_width, im_height, EM_driver_obj, results_dir=None,
71-
verbose=True, development_mode=False):
75+
verbose=True, development_mode=False,
76+
xsp_spot_selector: Optional[XSpSpotSelectorCallback] = None):
7277
self.EM_controller = EM_controller
7378
self.sample_cfg = sample_cfg
7479
self.sample_substrate_cfg = sample_substrate_cfg
@@ -83,6 +88,7 @@ def __init__(self, EM_controller, sample_cfg, sample_substrate_cfg, measurement_
8388
self.results_dir = results_dir
8489
self.verbose = verbose
8590
self.development_mode = development_mode
91+
self.xsp_spot_selector = xsp_spot_selector
8692

8793
# Frame tracking
8894
self._frame_cntr = 0
@@ -143,7 +149,7 @@ def initialise_sample_navigator(self, grid_search_fw_mm, exclude_sample_margin=T
143149
self.particle_finder = EM_Particle_Finder(
144150
self.EM_controller,
145151
powder_meas_cfg=self.powder_meas_cfg,
146-
is_manual_particle_selection=self.powder_meas_cfg.is_manual_particle_selection,
152+
xsp_spot_selector=self.xsp_spot_selector,
147153
results_dir=self.results_dir,
148154
verbose=self.verbose,
149155
development_mode=self.development_mode

autoemx/core/em_runtime/particle_finder.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@
7272

7373
from typing import List, Optional
7474

75+
from autoemx.core.em_runtime.xsp_spot_selection import (
76+
XSpSpotSelectionContext,
77+
XSpSpotSelectorCallback,
78+
validate_xsp_spot_pixels,
79+
)
80+
7581
# Local project imports
7682
from autoemx.utils.helper import (
7783
Prompt_User,
@@ -134,8 +140,8 @@ class EM_Particle_Finder:
134140
Reference to the parent EM_Controller instance (must be initialised before use).
135141
powder_meas_cfg : PowderMeasurementConfig
136142
Configuration object for powder measurement (see dataclass for details).
137-
is_manual_particle_selection : bool
138-
If True, prompts user to center image around next particle to analyse.
143+
par_selection_mode : str
144+
Particle navigation mode from ``powder_meas_cfg`` ('auto' or 'manual').
139145
results_dir : str or None
140146
Directory for saving result images and data.
141147
verbose : bool
@@ -167,7 +173,7 @@ def __init__(
167173
self,
168174
EM_controller,
169175
powder_meas_cfg: PowderMeasurementConfig,
170-
is_manual_particle_selection: bool = False,
176+
xsp_spot_selector: Optional[XSpSpotSelectorCallback] = None,
171177
results_dir: Optional[str] = None,
172178
verbose: bool = True,
173179
development_mode: bool = True
@@ -181,10 +187,10 @@ def __init__(
181187
----------
182188
EM_controller : EM_Controller
183189
Reference to the parent EM_Controller instance (must be initialised before use).
184-
powder_meas_cfg : PowderMeasurementConfig, optional
190+
powder_meas_cfg : PowderMeasurementConfig
185191
Configuration object for powder measurement (see dataclass for details).
186-
is_manual_particle_selection : bool, optional
187-
If True, enables manual particle selection mode (default: False).
192+
xsp_spot_selector : callable, optional
193+
Required when ``powder_meas_cfg.par_spot_selection_mode='callback'``.
188194
results_dir : str, optional
189195
Directory to save result images and data (default: None).
190196
verbose : bool, optional
@@ -213,7 +219,11 @@ def __init__(
213219
self._im_width = EM_controller.im_width
214220
self._im_height = EM_controller.im_height
215221

216-
self.is_manual_particle_selection = is_manual_particle_selection
222+
self.xsp_spot_selector = xsp_spot_selector
223+
if self.powder_meas_cfg.par_spot_selection_mode == 'callback' and self.xsp_spot_selector is None:
224+
raise ValueError(
225+
"powder_meas_cfg.par_spot_selection_mode='callback' requires xsp_spot_selector."
226+
)
217227
# NOTE: self.tot_par_cntr is initialized below, so _select_par_prompt_title will be set on first use
218228
if self.is_manual_particle_selection:
219229
self._select_par_prompt_title = f"Select position for particle #{self.tot_par_cntr}"
@@ -234,7 +244,16 @@ def __init__(
234244
self._num_par_in_frame = 0
235245
self.analyzed_pars: List[tuple(float, str)] = []
236246

247+
@property
248+
def is_manual_particle_selection(self) -> bool:
249+
"""Whether particle navigation uses manual centering prompts."""
250+
return self.powder_meas_cfg.par_selection_mode == 'manual'
237251

252+
@property
253+
def par_selection_mode(self) -> str:
254+
return self.powder_meas_cfg.par_selection_mode
255+
256+
238257
def _check_EM_controller_initialization(self) -> None:
239258
"""
240259
Check whether the associated EM_Controller instance is initialized.
@@ -987,6 +1006,14 @@ def get_XS_acquisition_spots_coord_list(
9871006
# --- 2. Erode the particle mask to avoid edge effects ---
9881007
margin = max(10, int(self.powder_meas_cfg.par_mask_margin / self.EM.pixel_size_um))
9891008
final_mask = self._erode_particle_mask(par_mask, margin)
1009+
1010+
if self.powder_meas_cfg.par_spot_selection_mode == 'callback':
1011+
return self._get_callback_xsp_spots(
1012+
n_tot_sp_collected=n_tot_sp_collected,
1013+
par_image=par_image,
1014+
par_mask=par_mask,
1015+
usable_mask=final_mask,
1016+
)
9901017

9911018
# --- 3. Find bright points in image, which indicate highest regions on particle---
9921019
thresholded_image, min_area_pixels = self._find_particle_bright_regions(final_mask, par_image)
@@ -1027,6 +1054,48 @@ def get_XS_acquisition_spots_coord_list(
10271054
self.ref_image = par_image
10281055
return [tuple(map(int, pt)) for pt in selected_points]
10291056

1057+
def _get_callback_xsp_spots(
1058+
self,
1059+
n_tot_sp_collected: int,
1060+
par_image,
1061+
par_mask,
1062+
usable_mask,
1063+
) -> List[tuple]:
1064+
"""Invoke xsp_spot_selector and validate returned pixel coordinates."""
1065+
frame_label = getattr(self.EM, 'current_frame_label', '')
1066+
if frame_label is None:
1067+
frame_label = ''
1068+
1069+
context = XSpSpotSelectionContext(
1070+
particle_id=self.tot_par_cntr,
1071+
n_tot_sp_collected=n_tot_sp_collected,
1072+
par_image=par_image,
1073+
par_mask=par_mask,
1074+
usable_mask=usable_mask,
1075+
pixel_size_um=float(self.EM.pixel_size_um),
1076+
frame_label=str(frame_label),
1077+
im_width=int(self._im_width),
1078+
im_height=int(self._im_height),
1079+
particle_finder=self,
1080+
)
1081+
try:
1082+
raw_spots = self.xsp_spot_selector(context)
1083+
except Exception as exc:
1084+
logger.error(f"❌ xsp_spot_selector callback failed: {exc}")
1085+
raise
1086+
1087+
if not raw_spots:
1088+
self.ref_image = par_image
1089+
return []
1090+
1091+
selected_points = validate_xsp_spot_pixels(
1092+
raw_spots,
1093+
im_width=int(self._im_width),
1094+
im_height=int(self._im_height),
1095+
)
1096+
self.ref_image = par_image
1097+
return selected_points
1098+
10301099

10311100
def _collect_candidate_points(self, thresholded_image, par_image, feature_selection, min_area_pixels):
10321101
"""

0 commit comments

Comments
 (0)