From c545910f350e47fef5a313105fc00327c49eda2c Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 16 Sep 2026 13:39:53 +0200 Subject: [PATCH] fix: Guillaume's fix for pin exclusion --- .../daq/operations/common/ml_bounding_box.py | 142 +++++++++++++++++- 1 file changed, 137 insertions(+), 5 deletions(-) diff --git a/src/aare/daq/operations/common/ml_bounding_box.py b/src/aare/daq/operations/common/ml_bounding_box.py index 77188e2d..11dcccae 100644 --- a/src/aare/daq/operations/common/ml_bounding_box.py +++ b/src/aare/daq/operations/common/ml_bounding_box.py @@ -31,6 +31,7 @@ class MLRasterPlan: loop_face_box: BoxTuple | None image_width: int | None image_height: int | None + pin_box: BoxTuple | None = None def _box_tuple(model) -> BoxTuple | None: @@ -47,6 +48,62 @@ def _box_union(a: BoxTuple, b: BoxTuple) -> BoxTuple: return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) +@dataclass(frozen=True) +class PinExclusion: + """Outcome of :func:`exclude_pin_x_overlap`. + + ``box`` is the grid box with the pin's x-range removed. ``limit_x_min`` / + ``limit_x_max`` is the pixel column the raster grid (padding included) must + not cross on the pin side. ``side`` is ``None`` when no exclusion was + applied, in which case ``reason`` says why. + """ + + box: BoxTuple + side: str | None + limit_x_min: float | None + limit_x_max: float | None + clipped_px: float + reason: str | None + + +def exclude_pin_x_overlap( + box: BoxTuple, pin: BoxTuple, *, max_overlap_fraction: float +) -> PinExclusion: + """Remove the x-range shared with the pin from a raster grid box. + + The pin is metal: rastering it gives strong scattering that perturbs the + diffraction analysis. The pin sits on one side of the loop along x (decided + from the box centres); the grid box is cut at the pin's near edge and that + edge becomes a hard limit for grid padding on that side. The y-range of the + grid box is kept as is. With no x-overlap the box is untouched, but the pin + edge still caps the padding. + + No exclusion is applied (``side`` is ``None``) when the pin's x-range covers + the whole box or the overlap exceeds ``max_overlap_fraction`` of the box + width: both point to a mis-detected pin rather than a stem running into it. + """ + x1, y1, x2, y2 = box + px1, _, px2, _ = pin + width = x2 - x1 + if width <= 0: + return PinExclusion(box, None, None, None, 0.0, "grid box has no width") + if px1 <= x1 and px2 >= x2: + return PinExclusion(box, None, None, None, 0.0, "pin x-range covers the whole grid box") + + pin_on_right = (px1 + px2) / 2.0 >= (x1 + x2) / 2.0 + if pin_on_right: + overlap = max(0.0, x2 - max(px1, x1)) + else: + overlap = max(0.0, min(px2, x2) - x1) + if overlap > max_overlap_fraction * width: + return PinExclusion( + box, None, None, None, 0.0, "pin x-overlap exceeds the allowed fraction of the grid box" + ) + if pin_on_right: + return PinExclusion((x1, y1, min(x2, px1), y2), "right", None, px1, overlap, None) + return PinExclusion((max(x1, px2), y1, x2, y2), "left", px2, None, overlap, None) + + def scale_auto_raster_grid( *, n_x: int, @@ -187,26 +244,53 @@ def _box_to_raster_request( min_cell_size_mm: float, skip_if_exceed_max_image_threshold: bool, grid_padding: bool = False, + pad_limit_x_min: float | None = None, + pad_limit_x_max: float | None = None, ) -> RasterGridRequest: + """Convert a pixel box into a raster grid request. + + ``pad_limit_x_min`` / ``pad_limit_x_max`` is a pixel column the grid must + not cross (the near edge of the pin, see :func:`exclude_pin_x_overlap`): + the cell-rounding overshoot is moved away from that edge and the grid + padding on that side is capped so no cell lands beyond it. + """ geom = sample_geometry grid_size = Coordinate(x=geom.beam_size_mm.x * 0.8, y=geom.beam_size_mm.y * 0.8) + cell_px_x = grid_size.x / geom.pixel_in_mm n_x = max(1, abs(ceil((x2 - x1) * geom.pixel_in_mm / grid_size.x))) n_y = max(1, abs(ceil((y2 - y1) * geom.pixel_in_mm / grid_size.y))) + if pad_limit_x_max is not None: + # ceil() lets the last cell reach up to one cell past x2; when the pin + # sits right after x2, shift the grid left so it ends on the pin edge. + # (A pin on the left needs no shift: the overshoot is on the far side.) + overshoot_px = x1 + n_x * cell_px_x - pad_limit_x_max + if overshoot_px > 0: + x1 = x1 - overshoot_px + if grid_padding: # Pad the grid by a fraction of its size on each side (at least one cell), # extending *before* cell 0 as well, so the top-left moves outward and # smargon_top_left shifts with it. Y is asymmetric: the bottom (far end - # of the n_y scan) can be padded more than the top. + # of the n_y scan) can be padded more than the top. X padding is capped + # on a side that has a pin limit. frac_x = float(cfg_get("daq.auto_raster.grid_padding_fraction_x", 0.15)) frac_y_top = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15)) frac_y_bottom = float(cfg_get("daq.auto_raster.grid_padding_fraction_y_bottom", frac_y_top)) pad_x = max(1, ceil(frac_x * n_x)) + pad_x_left = pad_x + pad_x_right = pad_x + if pad_limit_x_min is not None: + room_px = x1 - pad_limit_x_min + pad_x_left = min(pad_x_left, max(0, floor(room_px / cell_px_x))) + if pad_limit_x_max is not None: + room_px = pad_limit_x_max - (x1 + n_x * cell_px_x) + pad_x_right = min(pad_x_right, max(0, floor(room_px / cell_px_x))) pad_y_top = max(1, ceil(frac_y_top * n_y)) pad_y_bottom = max(1, ceil(frac_y_bottom * n_y)) - x1 = x1 - pad_x * grid_size.x / geom.pixel_in_mm + x1 = x1 - pad_x_left * cell_px_x y1 = y1 - pad_y_top * grid_size.y / geom.pixel_in_mm - n_x = n_x + 2 * pad_x + n_x = n_x + pad_x_left + pad_x_right n_y = n_y + pad_y_top + pad_y_bottom logger.info( "Padded auto-center raster grid", @@ -215,9 +299,12 @@ def _box_to_raster_request( { "sample_id": sample_id, "ml_image_name": filename, - "pad_cells_x": pad_x, + "pad_cells_x_left": pad_x_left, + "pad_cells_x_right": pad_x_right, "pad_cells_y_top": pad_y_top, "pad_cells_y_bottom": pad_y_bottom, + "pad_limit_x_min": pad_limit_x_min, + "pad_limit_x_max": pad_limit_x_max, "n_x": n_x, "n_y": n_y, }, @@ -319,7 +406,10 @@ def build_ml_raster_plan( zoom-to-fit). The grid box is loop_face (else loop_all); a loop_face box is padded by ``daq.auto_raster.loop_face_padding_fraction`` and, when ``daq.auto_raster.include_crystal`` is enabled, the box is extended to the - union of itself and any detected crystal boxes that lie beyond it. + union of itself and any detected crystal boxes that lie beyond it. When a + pin is detected and ``daq.auto_raster.exclude_pin_x_overlap`` is enabled + (default), the x-range shared with the pin is removed from the grid and no + padding cell is placed on the pin (see :func:`exclude_pin_x_overlap`). """ time.sleep(0.2) sample_id = getattr(sample, "db_id", None) @@ -339,6 +429,7 @@ def build_ml_raster_plan( loop_all = predictions.get_best_for_class(MLBoxType.LOOP_ALL) if predictions else None loop_face = predictions.get_best_for_class(MLBoxType.LOOP_FACE) if predictions else None + pin = predictions.get_best_for_class(MLBoxType.PIN) if predictions else None # Grid box: prefer loop_face, else loop_all (matches the legacy (3, 0) order). grid_model = loop_face if loop_face is not None else loop_all @@ -376,6 +467,44 @@ def build_ml_raster_plan( extra={"sample_id": sample_id, "ml_image_name": filename, "crystal_box": cbox}, ) + # Keep the grid off the pin: drop the x-range shared with the pin box and + # stop the padding at the pin edge (metal scattering perturbs spot finding). + pin_box = _box_tuple(pin) + pad_limit_x_min: float | None = None + pad_limit_x_max: float | None = None + if pin_box is not None and cfg_get("daq.auto_raster.exclude_pin_x_overlap", True): + exclusion = exclude_pin_x_overlap( + (x1, y1, x2, y2), + pin_box, + max_overlap_fraction=float( + cfg_get("daq.auto_raster.exclude_pin_max_overlap_fraction", 0.75) + ), + ) + pin_log = { + "sample_id": sample_id, + "ml_image_name": filename, + "grid_box": (x1, y1, x2, y2), + "pin_box": pin_box, + } + if exclusion.side is None: + logger.warning( + "Kept ML raster grid unchanged despite a detected pin", + extra={**pin_log, "reason": exclusion.reason}, + ) + else: + x1, y1, x2, y2 = exclusion.box + pad_limit_x_min = exclusion.limit_x_min + pad_limit_x_max = exclusion.limit_x_max + logger.info( + "Excluded pin x-range from ML raster grid", + extra={ + **pin_log, + "pin_side": exclusion.side, + "clipped_px": exclusion.clipped_px, + "clipped_grid_box": exclusion.box, + }, + ) + grid_request = _box_to_raster_request( x1=x1, y1=y1, @@ -390,6 +519,8 @@ def build_ml_raster_plan( min_cell_size_mm=min_cell_size_mm, skip_if_exceed_max_image_threshold=skip_if_exceed_max_image_threshold, grid_padding=True, + pad_limit_x_min=pad_limit_x_min, + pad_limit_x_max=pad_limit_x_max, ) image_height = int(bundle_image.shape[0]) if bundle_image is not None else None @@ -401,4 +532,5 @@ def build_ml_raster_plan( loop_face_box=_box_tuple(loop_face), image_width=image_width, image_height=image_height, + pin_box=pin_box, )