DAQ: updated face_detection added operations/face_detection fodler including service and models, new tests of face_detection
Build and Publish / test (push) Failing after 1m4s
Build and Publish / Build and Deploy Docs (push) Has been skipped
Build and Publish / build (push) Has been skipped

This commit is contained in:
2026-04-30 12:44:55 +02:00
parent 2b7c634227
commit a00f0b3dff
6 changed files with 783 additions and 206 deletions
+214 -206
View File
@@ -12,7 +12,6 @@ from aareDB import SampleEventType
from jfjoch_client.exceptions import NotFoundException
from jfjoch_client import ScanResult, ScanResultImagesInner
import aare.common.face_detection as fd
from aare.daq import workflows
from aare.daq.aaredb import AareWrapper
@@ -39,7 +38,7 @@ from aare.common.models import (
PuckLoadedInfo,
SampleShortInfoList, AutofocusSettings,
DAQStatusModel, BeamlineStatus, SessionStatus, SampleCameraSettings, ZoomModeEnum,
SimpleScanParameters, MLBoxModel, FluorescenceSpectrumParameterModel,
SimpleScanParameters, FluorescenceSpectrumParameterModel,
FluorescenceSpectrumOutputModel, DAQOperation)
from aare.common.automation_models import (
AutomationProgress,
@@ -50,6 +49,7 @@ from aare.common.automation_models import (
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid, CompletedRasterGridElem, grid_to_image_id
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
from aare.common.sample_geometry import SampleGeometryModel
from aare.daq.operations.face_detection import FaceDetectionContext, FaceDetectionService
from aare.daq.operations.loop_centering import LoopCenteringService, LoopCenteringContext
from aare.daq.operations.loop_centering.models import LoopCenteringSettings
@@ -282,6 +282,95 @@ class AareDAQ:
progress.success = success
self._emit_automation_progress(progress)
#--------------------------------------------
# Operation Services
#--------------------------------------------
def _create_loop_centering_settings(self) -> LoopCenteringSettings:
return LoopCenteringSettings()
def _create_loop_centering_service(self) -> LoopCenteringService:
settings = self._create_loop_centering_settings()
return LoopCenteringService(
context=LoopCenteringContext(
cfg=self.__cfg,
devs=self.__devs,
mlbox=self.__mlbox,
settings=settings,
sample_geometry_provider=lambda: self.sample_geometry,
save_screenshot_db=self.save_screenshot_db,
append_smargon_trace=self._append_smargon_trace,
get_predictions=lambda: self.__mlbox.predict_all_best(
overlap_with_pin=settings.overlap_with_pin,
confidence_min=settings.confidence_min,
return_image=True,
return_bundle_meta=True,
),
),
logger=logger,
)
def _create_face_detection_service(self) -> FaceDetectionService:
return FaceDetectionService(
context=FaceDetectionContext(
cfg=self.__cfg,
devs=self.__devs,
mlbox=self.__mlbox,
sample_geometry_provider=lambda: self.sample_geometry,
emit_progress=self._emit_face_detection_progress,
),
logger=logger,
)
#
# def _create_mounting_service(self):
# return MountingService(
# context=MountingContext(
# cfg=self.__cfg,
# devs=self.__devs,
# mount_position=ABR_POS_MOUNT,
# ),
# logger=logger,
# )
#
# def _create_raster_service(self):
# return RasterService(
# context=RasterContext(
# cfg=self.__cfg,
# devs=self.__devs,
# mlbox=self.__mlbox,
# jfjoch=self.__jfjoch,
# aare=self.__aare,
# sample_provider=lambda: self.sample,
# sample_geometry_provider=lambda: self.sample_geometry,
# status_provider=lambda: self.status,
# set_state=self.__set_state,
# save_screenshot_db=self.save_screenshot_db,
# upload_raster_diffraction_preview=self._upload_raster_diffraction_preview,
# auto_center_line_scan_top_left=self._auto_center_line_scan_top_left,
# ml_bounding_box=self.__ml_bounding_box,
# ),
# logger=logger,
# )
#
# def _create_rotation_service(self):
# return RotationService(
# context=RotationContext(
# cfg=self.__cfg,
# devs=self.__devs,
# jfjoch=self.__jfjoch,
# aare=self.__aare,
# sample_provider=lambda: self.sample,
# sample_geometry_provider=lambda: self.sample_geometry,
# status_provider=lambda: self.status,
# set_state=self.__set_state,
# save_screenshot_db=self.save_screenshot_db,
# ),
# logger=logger,
# )
#--------------------------------------------
# Operation Handlers
#--------------------------------------------
@@ -355,6 +444,15 @@ class AareDAQ:
"""
try:
previous_sample = self.sample
if previous_sample is None or previous_sample.db_id is None:
try:
previous_sample = self.sync_current_sample_from_tell(
force=True,
clear_cached_on_empty=False,
)
except Exception as sync_error:
logger.warning(f"Failed to reconcile previous sample from TELL before mount: {sync_error}")
if previous_sample is not None and previous_sample.db_id is not None:
self.__aare.send_sample_event(previous_sample, SampleEventType.UNMOUNTING)
self.__set_state(BeamlineStateEnum.RobotSampleExchange)
@@ -383,31 +481,6 @@ class AareDAQ:
)
return False
def _create_loop_centering_settings(self) -> LoopCenteringSettings:
return LoopCenteringSettings()
def _create_loop_centering_service(self) -> LoopCenteringService:
settings = self._create_loop_centering_settings()
return LoopCenteringService(
context=LoopCenteringContext(
cfg=self.__cfg,
devs=self.__devs,
mlbox=self.__mlbox,
settings=settings,
sample_geometry_provider=lambda: self.sample_geometry,
save_screenshot_db=self.save_screenshot_db,
append_smargon_trace=self._append_smargon_trace,
get_predictions=lambda: self.__mlbox.predict_all_best(
overlap_with_pin=settings.overlap_with_pin,
confidence_min=settings.confidence_min,
return_image=True,
return_bundle_meta=True,
),
),
logger=logger,
)
def _execute_loop_centering(self, sample: SampleShortInfo) -> bool:
"""
Execute loop centering sequence.
@@ -458,6 +531,38 @@ class AareDAQ:
)
return False
def _execute_face_detection(
self,
*,
steps: int = 14,
step_size: int = 15,
face_min_ratio: float = 0.3,
report_error: bool = True,
):
"""
Execute face detection sequence through the face detection service.
Returns:
FaceDetectionResult
"""
self.__set_state(BeamlineStateEnum.SampleAlignment)
result = self._create_face_detection_service().run(
steps=steps,
step_size=step_size,
face_min_ratio=face_min_ratio,
)
if not result.success and report_error:
self._handle_operation_error(
operation=DAQOperation.FACE_CENTERING,
sample=self.sample,
error=result.error or Exception("Face detection failed"),
additional_comment=result.comment,
)
return result
def _execute_raster_sequence(self, grid_request: RasterGridRequest,
auto_center: bool = False) -> CompletedRasterGrid | None:
"""
@@ -521,9 +626,19 @@ class AareDAQ:
},
),
)
else:
if self.sample is not None and self.sample.db_id is not None:
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERINGFAILED)
logger.error(
"Raster sequence returned no result",
extra=merge_log_context(
sample_log_context(self.sample),
raster_request_log_context(grid_request),
),
)
return result
except JFJochCommunicationError as e:
logger.exception(
"Raster sequence failed due to JFJoch communication error",
@@ -676,7 +791,34 @@ class AareDAQ:
location=mounted_address.puck,
)
def sync_current_sample_from_tell(self, force: bool = False) -> SampleShortInfo | None:
def _find_sample_by_mounted_address(self, mounted_address) -> SampleShortInfo | None:
for sample in self.__cfg.spreadsheet.s:
if self._sample_matches_mounted_address(sample, mounted_address):
return sample
for sample in self.__cfg.reference_tools.s:
if self._sample_matches_mounted_address(sample, mounted_address):
return sample
return None
def _placeholder_sample_from_mounted_address(self, mounted_address) -> SampleShortInfo:
return SampleShortInfo(
db_id=-1,
puck_name="",
dewar_name="",
sample_name=f"Mounted sample {mounted_address.puck.segment}{mounted_address.puck.pos}-{mounted_address.pin}",
run_number=0,
user="",
pin=mounted_address.pin,
location=mounted_address.puck,
)
def sync_current_sample_from_tell(
self,
force: bool = False,
clear_cached_on_empty: bool = True,
) -> SampleShortInfo | None:
current_sample = self.__cfg.current_sample
if current_sample is not None and current_sample.location is None:
@@ -691,8 +833,13 @@ class AareDAQ:
if mounted_address is None:
if current_sample is not None and current_sample.location is not None:
logger.warning("TELL reports no mounted sample; clearing cached current_sample")
self.__cfg.current_sample = None
if clear_cached_on_empty:
logger.warning("TELL reports no mounted sample; clearing cached current_sample")
self.__cfg.current_sample = None
else:
logger.warning(
"TELL reports no mounted sample; keeping cached current_sample to avoid losing context"
)
return self.__cfg.current_sample
if self._sample_matches_mounted_address(current_sample, mounted_address):
@@ -1166,7 +1313,8 @@ class AareDAQ:
},
),
)
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERING,
comment=f"Raster at {geom.omega_deg:.1f} deg")
r = self.__ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg:.2f}deg")
if r is None:
@@ -1236,8 +1384,13 @@ class AareDAQ:
)
res1 = self.__raster(grid)
grid.omega_deg += 90
if res1 is None:
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERINGFAILED,
comment=f"No ML Box detected {geom.omega_deg:.1f} deg")
grid.omega_deg += 90
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERING,
comment=f"Raster at {geom.omega_deg:.1f} deg")
self.__devs.aerotech_omega = grid.omega_deg
grid.n_x = 1
@@ -1286,6 +1439,11 @@ class AareDAQ:
),
)
res2 = self.__raster(grid)
if res2 is None:
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERINGFAILED,
comment=f"No ML Box detected {geom.omega_deg:.1f} deg")
return CompletedRasterGrid(r=[res1, res2])
else:
logger.error(
@@ -1505,8 +1663,6 @@ class AareDAQ:
try:
if self.sample is not None and self.sample.db_id is not None:
self.__aare.send_sample_event(self.sample, SampleEventType.RASTERING,
comment=f"Raster at {request.omega_deg:.1f} deg")
self.__aare.create_gridscan_run(self.sample, request, status)
self.__jfjoch.wait_till_running(timeout=60.0)
@@ -1993,7 +2149,7 @@ class AareDAQ:
self.__cfg.state_busy = False
raise
def face_detection(self, steps: int = 14, step_size: int = 15, face_min_ratio: float =0.3) -> dict:
def face_detection(self, steps: int = 14, step_size: int = 15, face_min_ratio: float = 0.3) -> dict:
"""
Perform a face detection sequence by rotating the sample and using ML to find the flat face.
@@ -2001,180 +2157,21 @@ class AareDAQ:
steps: Number of rotation steps. Default is 14.
step_size: Size of each rotation step in degrees. Default is 15.
face_min_ratio: Minimum ratio of loopface count to loop_all count to use loop_face over loop_all.
i.e. if 10 loop_face vs 4 loop_all pick loop_face. if 2 loop_face and 12 loop_all use loop_all.
Default is 0.3.
Returns:
Dictionary containing face detection results, including found samples and fits.
"""
self.__cfg.try_set_busy(timeout=360)
try:
logger.info("running face detection sequence")
result = self.__face_detection_sequence(steps=steps, step_size=step_size,face_min_ratio=face_min_ratio)
except Exception as e:
logger.error(f"error in face detection sequence {e}")
result = {
"running": False,
"samples": [],
"height_fit": {},
"area_fit": {},
}
self._emit_face_detection_progress(result)
self.__cfg.state_busy = False
return result
def face_detection_centre_correction(self, m:MLBoxModel, tolerance: float = 0.2):
geom = self.sample_geometry
beam_y = geom.beam_location_pxl.y
beam_x = geom.beam_location_pxl.x
x1 = m.box.top_x
y1 = m.box.top_y
y2 = m.box.bottom_y
centre_y = y1 + (y2 - y1) / 2
centre_x = x1
if beam_y !=0 and abs(centre_y-beam_y)/abs(beam_y) > tolerance:
coord = geom.picture_to_smargon(Coordinate(x=beam_x, y=centre_y))
self.__devs.smargon_pos = SmargonCoordinate(sh_mm=coord)
self.__devs.smargon_wait(60)
return
#TODO operator function similar to mount, loop_center, raster and rotation
@log_timing(logger, "Face detection sequence")
def __face_detection_sequence(self, steps: int = 14, step_size: int = 15, face_min_ratio: float = 0.3) -> dict:
self.__set_state(BeamlineStateEnum.SampleAlignment)
self.__devs.lamp_light = 2.5
self.__cfg.zoom_mode = ZoomModeEnum.LoopCenter
zoom_value = self.__devs.zoom
logger.info('face detection sequence')
self.__devs.set_zoom(zoom_value, wait=True)
boxes_face: dict[int, tuple[float, float, float, float]] = {}
boxes_loop: dict[int, tuple[float, float, float, float]] = {}
curr_angle = int(self.__devs.aerotech_omega)
total_range = steps * step_size + 1
start_angle = curr_angle if curr_angle + total_range < 720 else 0
end_angle = curr_angle + total_range
for angle in range(start_angle, end_angle, step_size):
logger.debug(f'moving to angle: {angle}')
rotate_time = time.perf_counter()
self.__devs.aerotech_omega = angle
log_duration(
logger,
"Completed Aerotech move during face detection",
time.perf_counter() - rotate_time,
extra={"angle_deg": angle},
result = self._execute_face_detection(
steps=steps,
step_size=step_size,
face_min_ratio=face_min_ratio,
report_error=True,
)
prediction_result: MLBoxPredictionResult = self.__mlbox.predict(
preferred_class=(3, 0),
return_image=True,
return_bundle_meta=True
)
m = prediction_result.box
log_ml_bundle_meta(
logger,
f"face_detection_angle_{angle}",
target_point=prediction_result.target_point,
focus=prediction_result.focus,
)
if not m or not m.box:
logger.info(f"no box found for angle {angle}")
self._emit_face_detection_progress({
"running": True,
"current_angle_deg": angle,
"samples": fd.get_samples_out(boxes_face),
"height_fit": {},
"area_fit": {},
})
continue
cls_id = int(m.cls.value)
x1, y1, x2, y2 = m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y
self.face_detection_centre_correction(m, tolerance=0.2)
if cls_id == 3:
boxes_face[angle] = (x1, y1, x2, y2)
logger.info(f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}")
elif cls_id == 0:
boxes_loop[angle] = (x1, y1, x2, y2)
logger.info(f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}")
else:
logger.debug(f"ignoring class {cls_id} at angle {angle}")
self._emit_face_detection_progress({
"running": True,
"current_angle_deg": angle,
"samples": fd.get_samples_out(boxes_face),
"height_fit": {},
"area_fit": {},
})
if not boxes_face and not boxes_loop:
logger.info("no boxes found")
result = {"running": False, "samples": [], "height_fit": {}, "area_fit": {}}
self._emit_face_detection_progress(result)
return result
total_detections = len(boxes_face) + len(boxes_loop)
face_ratio = len(boxes_face) / total_detections if total_detections > 0 else 0.0
if boxes_face and face_ratio >= face_min_ratio:
boxes = boxes_face
logger.info(f"using loop_face boxes ({len(boxes_face)}/{total_detections}, ratio={face_ratio:.2f})")
elif boxes_loop:
boxes = boxes_loop
logger.info(
f"falling back to loop_all boxes ({len(boxes_loop)}/{total_detections}, ratio={1 - face_ratio:.2f})")
else:
boxes = boxes_face
logger.info(f"using loop_face boxes (only source, {len(boxes_face)} entries)")
best_fit_angle_area, area_params = fd.get_flat_face(boxes, start_angle, end_angle, True)
best_fit_angle_height, height_params = fd.get_flat_face(boxes, start_angle, end_angle, False)
fit_results = {
"Area": {"angle": best_fit_angle_area, "params": area_params},
"Height": {"angle": best_fit_angle_height, "params": height_params},
}
logger.info(f"best angle by area: {best_fit_angle_area}")
logger.info(f"best angle by height: {best_fit_angle_height}")
flat_face_angle, best_params, best_name = fd.choose_best_fit(fit_results)
logger.info(f"best params: {best_params}")
logger.info(f"chosen fit: {best_name}")
logger.info(f"best angle: {flat_face_angle}")
self.__devs.aerotech_omega = flat_face_angle
samples_out = fd.get_samples_out(boxes)
logger.info(f"face detection sequence done, samples: {samples_out}")
result = {
"running": False,
"samples": samples_out,
"height_fit": {
"A": height_params["A"],
"B": height_params["B"],
"phi_rad": height_params["phi_rad"],
"C": height_params["C"],
"best_angle_deg": best_fit_angle_height,
},
"area_fit": {
"A": area_params["A"],
"B": area_params["B"],
"phi_rad": area_params["phi_rad"],
"C": area_params["C"],
"best_angle_deg": best_fit_angle_area,
},
}
self._emit_face_detection_progress(result)
return result
return result.payload
finally:
self.__cfg.state_busy = False
def _auto_center_line_scan_top_left(
self,
@@ -2605,11 +2602,22 @@ class AareDAQ:
self._mark_progress_failed(progress, WorkflowStateKind.LOOP_CENTRE, "Centering failed")
return self._end_operation(start, DAQOperation.LOOP_CENTERING, error=True)
logger.info(f"Loop Centering done at {time.perf_counter() - start}")
result = self.__face_detection_sequence(steps=7, step_size=30)
self._emit_face_detection_progress(result)
face_detection_result = self._execute_face_detection(
steps=7,
step_size=30,
face_min_ratio=0.3,
report_error=True,
)
if not face_detection_result.success:
logger.warning(
"Face detection failed during automation; continuing with latest payload",
extra=merge_log_context(
sample_log_context(sample),
{"comment": face_detection_result.comment},
),
)
logger.info(f"Face Detection done at {time.perf_counter() - start}")
self._mark_progress_success(progress, WorkflowStateKind.LOOP_CENTRE, "Centering complete")
@@ -0,0 +1,11 @@
from aare.daq.operations.face_detection.models import (
FaceDetectionContext,
FaceDetectionResult,
)
from aare.daq.operations.face_detection.service import FaceDetectionService
__all__ = [
"FaceDetectionContext",
"FaceDetectionResult",
"FaceDetectionService",
]
@@ -0,0 +1,24 @@
from dataclasses import dataclass
from typing import Callable
from aare.common.sample_geometry import SampleGeometryModel
from aare.daq.config import BeamlineConfig
from aare.daq.devices import BeamlineDevices
from aare.daq.mlbox import MlBox
@dataclass
class FaceDetectionContext:
cfg: BeamlineConfig
devs: BeamlineDevices
mlbox: MlBox
sample_geometry_provider: Callable[[], SampleGeometryModel]
emit_progress: Callable[[dict], None]
@dataclass
class FaceDetectionResult:
success: bool
payload: dict
error: Exception | None = None
comment: str | None = None
@@ -0,0 +1,213 @@
import time
import aare.common.face_detection as fd
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.logger_events import log_duration, log_ml_bundle_meta
from aare.common.models import MLBoxModel, ZoomModeEnum
from aare.daq.config import BeamlineStateEnum
from aare.daq.mlbox import MLBoxPredictionResult
from aare.daq.operations.face_detection.models import (
FaceDetectionContext,
FaceDetectionResult,
)
class FaceDetectionService:
def __init__(self, *, context: FaceDetectionContext, logger):
self.ctx = context
self.logger = logger
def _emit_running_progress(
self,
*,
angle: int,
boxes_face: dict[int, tuple[float, float, float, float]],
) -> None:
self.ctx.emit_progress(
{
"running": True,
"current_angle_deg": angle,
"samples": fd.get_samples_out(boxes_face),
"height_fit": {},
"area_fit": {},
}
)
def _emit_empty_result(self) -> dict:
payload = {
"running": False,
"samples": [],
"height_fit": {},
"area_fit": {},
}
self.ctx.emit_progress(payload)
return payload
def _centre_correction(self, model: MLBoxModel, tolerance: float = 0.2) -> None:
geom = self.ctx.sample_geometry_provider()
beam_y = geom.beam_location_pxl.y
beam_x = geom.beam_location_pxl.x
x1 = model.box.top_x
y1 = model.box.top_y
y2 = model.box.bottom_y
centre_y = y1 + (y2 - y1) / 2
if beam_y != 0 and abs(centre_y - beam_y) / abs(beam_y) > tolerance:
coord = geom.picture_to_smargon(Coordinate(x=beam_x, y=centre_y))
self.ctx.devs.smargon_pos = SmargonCoordinate(sh_mm=coord)
self.ctx.devs.smargon_wait(60)
def run(
self,
*,
steps: int = 14,
step_size: int = 15,
face_min_ratio: float = 0.3,
) -> FaceDetectionResult:
try:
self.ctx.cfg.zoom_mode = ZoomModeEnum.LoopCenter
self.ctx.devs.lamp_light = 2.5
zoom_value = self.ctx.devs.zoom
self.logger.info("face detection sequence")
self.ctx.devs.set_zoom(zoom_value, wait=True)
boxes_face: dict[int, tuple[float, float, float, float]] = {}
boxes_loop: dict[int, tuple[float, float, float, float]] = {}
curr_angle = int(self.ctx.devs.aerotech_omega)
total_range = steps * step_size + 1
start_angle = curr_angle if curr_angle + total_range < 720 else 0
end_angle = curr_angle + total_range
for angle in range(start_angle, end_angle, step_size):
self.logger.debug(f"moving to angle: {angle}")
rotate_time = time.perf_counter()
self.ctx.devs.aerotech_omega = angle
log_duration(
self.logger,
"Completed Aerotech move during face detection",
time.perf_counter() - rotate_time,
extra={"angle_deg": angle},
)
prediction_result: MLBoxPredictionResult = self.ctx.mlbox.predict(
preferred_class=(3, 0),
return_image=True,
return_bundle_meta=True,
)
model = prediction_result.box
log_ml_bundle_meta(
self.logger,
f"face_detection_angle_{angle}",
target_point=prediction_result.target_point,
focus=prediction_result.focus,
)
if not model or not model.box:
self.logger.info(f"no box found for angle {angle}")
self._emit_running_progress(angle=angle, boxes_face=boxes_face)
continue
cls_id = int(model.cls.value)
x1, y1, x2, y2 = (
model.box.top_x,
model.box.top_y,
model.box.bottom_x,
model.box.bottom_y,
)
self._centre_correction(model, tolerance=0.2)
if cls_id == 3:
boxes_face[angle] = (x1, y1, x2, y2)
self.logger.info(
f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}"
)
elif cls_id == 0:
boxes_loop[angle] = (x1, y1, x2, y2)
self.logger.info(
f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}"
)
else:
self.logger.debug(f"ignoring class {cls_id} at angle {angle}")
self._emit_running_progress(angle=angle, boxes_face=boxes_face)
if not boxes_face and not boxes_loop:
self.logger.info("no boxes found")
payload = self._emit_empty_result()
return FaceDetectionResult(success=True, payload=payload)
total_detections = len(boxes_face) + len(boxes_loop)
face_ratio = len(boxes_face) / total_detections if total_detections > 0 else 0.0
if boxes_face and face_ratio >= face_min_ratio:
boxes = boxes_face
self.logger.info(
f"using loop_face boxes ({len(boxes_face)}/{total_detections}, ratio={face_ratio:.2f})"
)
elif boxes_loop:
boxes = boxes_loop
self.logger.info(
f"falling back to loop_all boxes ({len(boxes_loop)}/{total_detections}, ratio={1 - face_ratio:.2f})"
)
else:
boxes = boxes_face
self.logger.info(f"using loop_face boxes (only source, {len(boxes_face)} entries)")
best_fit_angle_area, area_params = fd.get_flat_face(boxes, start_angle, end_angle, True)
best_fit_angle_height, height_params = fd.get_flat_face(boxes, start_angle, end_angle, False)
fit_results = {
"Area": {"angle": best_fit_angle_area, "params": area_params},
"Height": {"angle": best_fit_angle_height, "params": height_params},
}
self.logger.info(f"best angle by area: {best_fit_angle_area}")
self.logger.info(f"best angle by height: {best_fit_angle_height}")
flat_face_angle, best_params, best_name = fd.choose_best_fit(fit_results)
self.logger.info(f"best params: {best_params}")
self.logger.info(f"chosen fit: {best_name}")
self.logger.info(f"best angle: {flat_face_angle}")
self.ctx.devs.aerotech_omega = flat_face_angle
samples_out = fd.get_samples_out(boxes)
self.logger.info(f"face detection sequence done, samples: {samples_out}")
payload = {
"running": False,
"samples": samples_out,
"height_fit": {
"A": height_params["A"],
"B": height_params["B"],
"phi_rad": height_params["phi_rad"],
"C": height_params["C"],
"best_angle_deg": best_fit_angle_height,
},
"area_fit": {
"A": area_params["A"],
"B": area_params["B"],
"phi_rad": area_params["phi_rad"],
"C": area_params["C"],
"best_angle_deg": best_fit_angle_area,
},
}
self.ctx.emit_progress(payload)
return FaceDetectionResult(success=True, payload=payload)
except Exception as e:
self.logger.error(f"error in face detection sequence {e}")
payload = self._emit_empty_result()
return FaceDetectionResult(
success=False,
payload=payload,
error=e,
comment="Face detection sequence failed",
)
finally:
self.ctx.cfg.zoom_mode = ZoomModeEnum.User
@@ -0,0 +1,216 @@
import types
import pytest
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.models import BoundingBoxModel, MLBoxModel, MLBoxType, ZoomModeEnum
from aare.daq.operations.face_detection.models import (
FaceDetectionContext,
FaceDetectionResult,
)
from aare.daq.operations.face_detection.service import FaceDetectionService
class DummyLogger:
def info(self, *args, **kwargs):
pass
def debug(self, *args, **kwargs):
pass
def error(self, *args, **kwargs):
pass
def log(self, *args, **kwargs):
pass
class DummyGeometry:
def __init__(self):
self.beam_location_pxl = Coordinate(x=100.0, y=100.0, z=0.0)
def picture_to_smargon(self, coord: Coordinate) -> Coordinate:
return Coordinate(x=coord.x / 100.0, y=coord.y / 100.0, z=0.0)
def _box(cls: MLBoxType, x1: float, y1: float, x2: float, y2: float) -> MLBoxModel:
return MLBoxModel(
cls=cls,
box=BoundingBoxModel(
top_x=x1,
top_y=y1,
bottom_x=x2,
bottom_y=y2,
),
conf=0.95,
)
@pytest.fixture
def context():
progress_events = []
devs = types.SimpleNamespace(
lamp_light=0.0,
zoom=200.0,
aerotech_omega=0.0,
smargon_pos=None,
set_zoom=lambda zoom_value, wait=True: None,
smargon_wait=lambda timeout=60: None,
)
cfg = types.SimpleNamespace(zoom_mode=ZoomModeEnum.User)
ctx = FaceDetectionContext(
cfg=cfg,
devs=devs,
mlbox=types.SimpleNamespace(
predict=lambda **kwargs: types.SimpleNamespace(
box=None,
target_point=None,
focus=None,
)
),
sample_geometry_provider=lambda: DummyGeometry(),
emit_progress=lambda payload: progress_events.append(payload),
)
ctx._progress_events = progress_events
return ctx
def test_service_returns_empty_payload_when_no_boxes(monkeypatch, context):
service = FaceDetectionService(context=context, logger=DummyLogger())
monkeypatch.setattr(
context.mlbox,
"predict",
lambda **kwargs: types.SimpleNamespace(
box=None,
target_point=None,
focus=None,
),
)
result = service.run(steps=2, step_size=30)
assert isinstance(result, FaceDetectionResult)
assert result.success is True
assert result.payload["running"] is False
assert result.payload["samples"] == []
assert result.payload["height_fit"] == {}
assert result.payload["area_fit"] == {}
assert context.cfg.zoom_mode == ZoomModeEnum.User
assert len(context._progress_events) >= 1
assert context._progress_events[-1]["running"] is False
def test_service_prefers_face_boxes_when_ratio_is_high(monkeypatch, context):
service = FaceDetectionService(context=context, logger=DummyLogger())
predictions = iter(
[
types.SimpleNamespace(box=_box(MLBoxType.LOOP_FACE, 10, 20, 30, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_FACE, 12, 20, 32, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_ALL, 14, 20, 34, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_FACE, 16, 20, 36, 40), target_point=None, focus=None),
]
)
monkeypatch.setattr(context.mlbox, "predict", lambda **kwargs: next(predictions))
monkeypatch.setattr(
"aare.daq.operations.face_detection.service.fd.get_flat_face",
lambda boxes, start_angle, end_angle, use_area: (
45 if use_area else 50,
{"A": 1.0, "B": 2.0, "phi_rad": 0.1, "C": 3.0},
),
)
monkeypatch.setattr(
"aare.daq.operations.face_detection.service.fd.choose_best_fit",
lambda fit_results: (47, {"A": 1.0}, "Area"),
)
monkeypatch.setattr(
"aare.daq.operations.face_detection.service.fd.get_samples_out",
lambda boxes: [{"angle": angle, "box": box} for angle, box in sorted(boxes.items())],
)
result = service.run(steps=3, step_size=30, face_min_ratio=0.5)
assert result.success is True
assert result.payload["running"] is False
assert result.payload["height_fit"]["best_angle_deg"] == 50
assert result.payload["area_fit"]["best_angle_deg"] == 45
assert context.devs.aerotech_omega == 47
assert [sample["angle"] for sample in result.payload["samples"]] == [0, 30, 90]
assert context._progress_events[-1]["running"] is False
def test_service_falls_back_to_loop_all_when_face_ratio_is_low(monkeypatch, context):
service = FaceDetectionService(context=context, logger=DummyLogger())
predictions = iter(
[
types.SimpleNamespace(box=_box(MLBoxType.LOOP_FACE, 10, 20, 30, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_ALL, 11, 20, 31, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_ALL, 12, 20, 32, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_ALL, 13, 20, 33, 40), target_point=None, focus=None),
types.SimpleNamespace(box=_box(MLBoxType.LOOP_ALL, 14, 20, 34, 40), target_point=None, focus=None),
]
)
monkeypatch.setattr(context.mlbox, "predict", lambda **kwargs: next(predictions))
captured = {}
def fake_get_flat_face(boxes, start_angle, end_angle, use_area):
captured["boxes_used"] = dict(boxes)
return 60, {"A": 1.0, "B": 2.0, "phi_rad": 0.2, "C": 4.0}
monkeypatch.setattr("aare.daq.operations.face_detection.service.fd.get_flat_face", fake_get_flat_face)
monkeypatch.setattr(
"aare.daq.operations.face_detection.service.fd.choose_best_fit",
lambda fit_results: (60, {"A": 1.0}, "Height"),
)
monkeypatch.setattr(
"aare.daq.operations.face_detection.service.fd.get_samples_out",
lambda boxes: [{"angle": angle} for angle in sorted(boxes.keys())],
)
result = service.run(steps=4, step_size=30, face_min_ratio=0.5)
assert result.success is True
assert len(captured["boxes_used"]) == 4
assert sorted(captured["boxes_used"].keys()) == [30, 60, 90, 120]
assert context.devs.aerotech_omega == 60
assert context._progress_events[-1]["running"] is False
def test_service_applies_centre_correction_when_target_is_far_from_beam(monkeypatch, context):
service = FaceDetectionService(context=context, logger=DummyLogger())
model = _box(MLBoxType.LOOP_FACE, 40, 160, 80, 200)
service._centre_correction(model, tolerance=0.2)
assert isinstance(context.devs.smargon_pos, SmargonCoordinate)
assert context.devs.smargon_pos.sh_mm.x == pytest.approx(1.0)
assert context.devs.smargon_pos.sh_mm.y == pytest.approx(1.8)
def test_service_returns_failed_result_when_prediction_raises(monkeypatch, context):
service = FaceDetectionService(context=context, logger=DummyLogger())
monkeypatch.setattr(
context.mlbox,
"predict",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("prediction failed")),
)
result = service.run(steps=1, step_size=30)
assert result.success is False
assert isinstance(result.error, RuntimeError)
assert result.comment == "Face detection sequence failed"
assert result.payload["running"] is False
assert context.cfg.zoom_mode == ZoomModeEnum.User
assert context._progress_events[-1]["running"] is False
+105
View File
@@ -0,0 +1,105 @@
import types
from aare.common.models import DAQOperation
from aare.daq.operations.face_detection.models import FaceDetectionResult
def test_execute_face_detection_reports_failure(monkeypatch):
from aare.daq.daq import AareDAQ
daq = object.__new__(AareDAQ)
calls = {"set_state": [], "handle_error": []}
monkeypatch.setattr(
daq,
"_AareDAQ__set_state",
lambda state: calls["set_state"].append(state),
)
monkeypatch.setattr(
daq,
"_create_face_detection_service",
lambda: types.SimpleNamespace(
run=lambda **kwargs: FaceDetectionResult(
success=False,
payload={"running": False, "samples": [], "height_fit": {}, "area_fit": {}},
error=RuntimeError("fd failed"),
comment="face detection sequence failed",
)
),
)
monkeypatch.setattr(
daq,
"_handle_operation_error",
lambda **kwargs: calls["handle_error"].append(kwargs),
)
monkeypatch.setattr(
type(daq),
"sample",
property(lambda self: types.SimpleNamespace(sample_name="sample-1", db_id=1)),
)
result = daq._execute_face_detection(steps=7, step_size=30, report_error=True)
assert result.success is False
assert len(calls["set_state"]) == 1
assert len(calls["handle_error"]) == 1
assert calls["handle_error"][0]["operation"] == DAQOperation.FACE_CENTERING
def test_execute_face_detection_can_skip_error_reporting(monkeypatch):
from aare.daq.daq import AareDAQ
daq = object.__new__(AareDAQ)
handle_error_calls = []
monkeypatch.setattr(daq, "_AareDAQ__set_state", lambda state: None)
monkeypatch.setattr(
daq,
"_create_face_detection_service",
lambda: types.SimpleNamespace(
run=lambda **kwargs: FaceDetectionResult(
success=False,
payload={"running": False, "samples": [], "height_fit": {}, "area_fit": {}},
error=RuntimeError("fd failed"),
comment="face detection sequence failed",
)
),
)
monkeypatch.setattr(
daq,
"_handle_operation_error",
lambda **kwargs: handle_error_calls.append(kwargs),
)
result = daq._execute_face_detection(report_error=False)
assert result.success is False
assert handle_error_calls == []
def test_public_face_detection_uses_execute_face_detection(monkeypatch):
from aare.daq.daq import AareDAQ
daq = object.__new__(AareDAQ)
cfg = types.SimpleNamespace(
try_set_busy=lambda timeout=360: None,
state_busy=False,
)
setattr(daq, "_AareDAQ__cfg", cfg)
monkeypatch.setattr(
daq,
"_execute_face_detection",
lambda **kwargs: FaceDetectionResult(
success=True,
payload={"running": False, "samples": [{"angle": 45}], "height_fit": {}, "area_fit": {}},
),
)
result = daq.face_detection(steps=7, step_size=30)
assert result["running"] is False
assert result["samples"] == [{"angle": 45}]
assert cfg.state_busy is False