From 5377e7d4897aa557f5c9b4aed1c88536cf86cc79 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 10:43:12 +0200 Subject: [PATCH] raster auto-center: unified first-grid padding in x and y (shifts top-left) Replace the loop_face-only box padding with a single grid padding applied to the first auto-center grid scan for any loop box (loop_all or loop_face). It pads each side by a fraction of the grid size (daq.auto_raster.grid_padding_ fraction_x / _y, default 0.15), with a minimum of one cell per side, and extends *before* cell 0 too so smargon_top_left shifts outward. Manual grid (get_ml_bounding_box) is unchanged (grid_padding defaults off). Co-Authored-By: Claude Opus 4.8 --- src/aare/common/config/x06da.yaml | 3 +- .../daq/operations/common/ml_bounding_box.py | 34 +++++++++---- .../daq/operations/test_ml_raster_plan.py | 51 +++++++++++++++---- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index fb8136e2..54c20d77 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -54,6 +54,7 @@ daq: maximum_flux: 4e11 auto_raster: - loop_face_padding_fraction: 0.15 # pad loop_face raster boxes by 15% per side + grid_padding_fraction_x: 0.15 # pad the 1st grid scan by this fraction of its size per side in x (min 1 cell) + grid_padding_fraction_y: 0.15 # ... and in y; shifts smargon_top_left outward (adds cells before cell 0) include_crystal: false # extend the grid to cover crystals outside the loop box line_scan_y_padding_fraction: 0.15 # pad the 2nd-stage vertical line scan height by this per side (10-20%) diff --git a/src/aare/daq/operations/common/ml_bounding_box.py b/src/aare/daq/operations/common/ml_bounding_box.py index 0428f225..41923fc0 100644 --- a/src/aare/daq/operations/common/ml_bounding_box.py +++ b/src/aare/daq/operations/common/ml_bounding_box.py @@ -188,13 +188,36 @@ def _box_to_raster_request( max_images: int, min_cell_size_mm: float, skip_if_exceed_max_image_threshold: bool, + grid_padding: bool = False, ) -> RasterGridRequest: geom = sample_geometry - start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1)) grid_size = Coordinate(x=geom.beam_size_mm.x * 0.8, y=geom.beam_size_mm.y * 0.8) 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 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. + frac_x = float(cfg_get("daq.auto_raster.grid_padding_fraction_x", 0.15)) + frac_y = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15)) + pad_x = max(1, int(ceil(frac_x * n_x))) + pad_y = max(1, int(ceil(frac_y * n_y))) + x1 = x1 - pad_x * grid_size.x / geom.pixel_in_mm + y1 = y1 - pad_y * grid_size.y / geom.pixel_in_mm + n_x = n_x + 2 * pad_x + n_y = n_y + 2 * pad_y + logger.info( + "Padded auto-center raster grid", + extra=merge_log_context( + sample_log_context(sample), + {"sample_id": sample_id, "ml_image_name": filename, + "pad_cells_x": pad_x, "pad_cells_y": pad_y, "n_x": n_x, "n_y": n_y}, + ), + ) + + start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1)) + original_n_x = n_x original_n_y = n_y original_grid_size = grid_size @@ -326,14 +349,6 @@ def build_ml_raster_plan( x1, y1, x2, y2 = (grid_model.box.top_x, grid_model.box.top_y, grid_model.box.bottom_x, grid_model.box.bottom_y) - is_loop_face = grid_model is loop_face - - # Pad loop_face boxes a little so the grid isn't cut tight to the face. - if is_loop_face: - frac = float(cfg_get("daq.auto_raster.loop_face_padding_fraction", 0.15)) - pad_x = (x2 - x1) * frac - pad_y = (y2 - y1) * frac - x1, y1, x2, y2 = x1 - pad_x, y1 - pad_y, x2 + pad_x, y2 + pad_y # Optionally extend the grid to cover crystals detected outside the loop box. if cfg_get("daq.auto_raster.include_crystal", False) and predictions is not None: @@ -362,6 +377,7 @@ def build_ml_raster_plan( max_images=max_images, min_cell_size_mm=min_cell_size_mm, skip_if_exceed_max_image_threshold=skip_if_exceed_max_image_threshold, + grid_padding=True, ) image_height = int(bundle_image.shape[0]) if bundle_image is not None else None diff --git a/tests/unit/daq/operations/test_ml_raster_plan.py b/tests/unit/daq/operations/test_ml_raster_plan.py index 72a53ec1..eeef603c 100644 --- a/tests/unit/daq/operations/test_ml_raster_plan.py +++ b/tests/unit/daq/operations/test_ml_raster_plan.py @@ -11,6 +11,7 @@ from aare.daq.mlbox import MLBoxPredictionsResult from aare.daq.operations.common import ml_bounding_box as mlb from aare.daq.operations.common.ml_bounding_box import ( _box_extends_beyond, + _box_to_raster_request, _box_union, build_ml_raster_plan, ) @@ -81,27 +82,55 @@ def test_plan_none_when_no_loop(): assert _plan(_fake_mlbox(crystals=[(300, 300, 350, 350)])) is None -def test_loop_face_padding_grows_grid(monkeypatch): - box = (150, 150, 300, 300) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.0 if "padding" in key else default) - unpadded = _plan(_fake_mlbox(loop_face=box)).grid_request - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.5 if "padding" in key else default) - padded = _plan(_fake_mlbox(loop_face=box)).grid_request - assert padded.n_x > unpadded.n_x and padded.n_y > unpadded.n_y +def _grid(box, *, grid_padding): + x1, y1, x2, y2 = box + return _box_to_raster_request( + x1=x1, y1=y1, x2=x2, y2=y2, + sample=None, sample_geometry=_geom(), logger=logger, filename=None, + sample_id=None, max_images=100000, min_cell_size_mm=0.0001, + skip_if_exceed_max_image_threshold=False, grid_padding=grid_padding, + ) + + +def test_grid_padding_grows_and_shifts_top_left(monkeypatch): + # fraction 0 -> minimum one cell of padding per side + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.0 if "grid_padding_fraction" in k else d) + box = (150, 150, 400, 300) + nopad = _grid(box, grid_padding=False) + pad = _grid(box, grid_padding=True) + assert pad.n_x == nopad.n_x + 2 # 1 cell each side in x + assert pad.n_y == nopad.n_y + 2 # 1 cell each side in y + # top-left shifted outward (cells added before cell 0) + assert pad.smargon_top_left.sh_mm.x != nopad.smargon_top_left.sh_mm.x + assert pad.smargon_top_left.sh_mm.z != nopad.smargon_top_left.sh_mm.z + + +def test_grid_padding_fraction_scales(monkeypatch): + box = (150, 150, 520, 420) + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.0 if "grid_padding_fraction" in k else d) + small = _grid(box, grid_padding=True) + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.5 if "grid_padding_fraction" in k else d) + big = _grid(box, grid_padding=True) + assert big.n_x > small.n_x and big.n_y > small.n_y def test_crystal_union_extends_grid_only_when_enabled(monkeypatch): # crystal extends well beyond the loop_face box on +x mlbox = lambda: _fake_mlbox(loop_face=(150, 150, 300, 300), crystals=[(350, 150, 520, 300)]) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: False if "include_crystal" in key else (0.0 if "padding" in key else default)) - off = _plan(mlbox()).grid_request + def cfg(enabled): + return lambda k, d=None: ( + enabled if "include_crystal" in k + else (0.0 if "grid_padding_fraction" in k else d) + ) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: True if "include_crystal" in key else (0.0 if "padding" in key else default)) + monkeypatch.setattr(mlb, "cfg_get", cfg(False)) + off = _plan(mlbox()).grid_request + monkeypatch.setattr(mlb, "cfg_get", cfg(True)) on = _plan(mlbox()).grid_request assert on.n_x > off.n_x # grid widened to reach the crystal - assert on.n_y == off.n_y # crystal is within the loop's y-range + assert on.n_y == off.n_y # crystal is within the loop's y-range (same padding both) def test_zoom_box_uses_loop_all_unless_clipped():