DAQ/GUI: raster score as its own metric + score-based targeting
Build and Publish / test (pull_request) Successful in 2m7s
Build and Publish / build (pull_request) Successful in 17s
Build and Publish / Build and Deploy Docs (pull_request) Successful in 41s
Build and Publish / build (pull_request_target) Cancelled after 0s
Build and Publish / Build and Deploy Docs (pull_request_target) Cancelled after 0s
Build and Publish / test (pull_request_target) Cancelled after 16s

- promote crystal score to its own RasterGridMetric.RASTER_SCORE; first and
  default metric in the GUI dropdown; restore PR to original "Profile Radius"
- send per-cell raster score to AareDB via RasterPayloadModel
- split raster_highest_score (compute_crystal_score_array) from
  raster_centre_of_mass (restored to ndimage.center_of_mass); use score-based
  targeting for where-to-collect in both simulation and real mode
- add tests covering compute_crystal_score_array and raster_highest_score

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
duan_jandClaude Opus 4.8 committed 2026-06-25 18:05:29 +02:00
1 parent ec45bf3729
commit f860e47914
7 files changed
+100 -52

No files matched your search

+25 -22
View File
@@ -280,32 +280,35 @@ def get_com_image_number(com, images):
return image.number
return None
def raster_centre_of_mass(result_array, images) -> CenterOfMassModel | None:
# grid_mm_x and grid_mm_y are relative to the top left corner of raster grid
# com = ndimage.center_of_mass(result_array)
# if com and not np.isnan(com[0]) and not np.isnan(com[1]):
# logger.info(f"Center of mass: {com}")
# max_image = get_com_image_number(com, images)
# return CenterOfMassModel(n_x=com[0], n_y=com[1], max_image=max_image)
# elif com and np.isnan(com[0]) and np.isnan(com[1]):
# logger.warning(f"Center of mass is nan: {com[0]}, {com[1]}")
# return None
# else:
# logger.warning("No valid center of mass found")
# return None
score_array = compute_crystal_score_array(images)
com = _max_cell(score_array)
if com and not np.isnan(com[0]) and not np.isnan(com[1]):
logger.info(f"Highest score: {com}")
max_image = get_com_image_number(com, images)
return CenterOfMassModel(n_x=com[0], n_y=com[1], max_image=max_image)
elif com and np.isnan(com[0]) and np.isnan(com[1]):
logger.warning(f"Highest score is nan: {com[0]}, {com[1]}")
def _to_com_model(coords, images, label: str) -> CenterOfMassModel | None:
"""Validate (n_x, n_y) grid coords and wrap them in a CenterOfMassModel.
Shared by raster_centre_of_mass and raster_highest_score, which differ only
in how they pick the target cell.
"""
if coords and not np.isnan(coords[0]) and not np.isnan(coords[1]):
logger.info(f"{label}: {coords}")
max_image = get_com_image_number(coords, images)
return CenterOfMassModel(n_x=coords[0], n_y=coords[1], max_image=max_image)
elif coords and np.isnan(coords[0]) and np.isnan(coords[1]):
logger.warning(f"{label} is nan: {coords[0]}, {coords[1]}")
return None
else:
logger.warning("No valid score found")
logger.warning(f"No valid {label} found")
return None
def raster_centre_of_mass(result_array, images) -> CenterOfMassModel | None:
# grid_mm_x and grid_mm_y are relative to the top left corner of raster grid
com = ndimage.center_of_mass(result_array)
return _to_com_model(com, images, "Center of mass")
def raster_highest_score(images) -> CenterOfMassModel | None:
"""Target the grid cell with the highest crystal score (compute_crystal_score_array)."""
score_array = compute_crystal_score_array(images)
return _to_com_model(_max_cell(score_array), images, "Highest score")
def has_sufficient_low_res_spots(
result_array: np.ndarray,
min_spots_low_res: float,
+1
View File
@@ -82,6 +82,7 @@ class RasterPayloadModel(BaseModel):
sample_id: int
attach_image: bool = True
centre_of_mass: CenterOfMassModel | None = None
raster_score: List[float | None] | None = None
center_pxl: Coordinate | None
start_pxl: Coordinate
cell_size_pxl: Annotated[Coordinate, AfterValidator(positive_coords)]
+10
View File
@@ -25,6 +25,7 @@ from aareDB import (
from pydantic import StrictInt
from aare.common.coordinate import Coordinate
from aare.common.find_xtal import compute_crystal_score_array
from aare.common.logger_config import setup_logger
from aare.common.logger_events import log_timing
from aare.common.models import (
@@ -358,12 +359,21 @@ class AareWrapper:
else:
center_pxl = None
try:
score_arr = compute_crystal_score_array(raster_result.images)
score = [float(score_arr[img.nx, img.ny]) if img.nx is not None and img.ny is not None else None
for img in raster_result.images]
except Exception as e:
logger.warning(f"raster score computation failed, sending null score: {e}")
score = None
payload = RasterPayloadModel(
request = raster_request,
result = raster_result,
sample_id = sample.db_id,
attach_image = True,
centre_of_mass = com,
raster_score = score, # TODO: Check whether AareDB needs to change for taking this input
start_pxl = start_pxl,
center_pxl = center_pxl,
cell_size_pxl = cell_size_pxl,
+3 -20
View File
@@ -8,7 +8,7 @@ from jfjoch_client.exceptions import NotFoundException
from aare.common.coordinate import AerotechCoordinate, Coordinate, SmargonCoordinate
from aare.common.exception_handler import AutoRasterSampleSkipped, RasterScanException
from aare.common.find_xtal import create_quality_filtered_array, raster_centre_of_mass
from aare.common.find_xtal import raster_highest_score
from aare.common.logger_events import (
geom_log_context,
log_ml_bundle_meta,
@@ -351,16 +351,7 @@ class RasterService:
if self.ctx.deps.cfg.simulated_detector:
self.logger.info("Simulated detector mode enabled; using fake raster result.")
scan_result = generate_no_beam_scan_result(request)
result_array = create_quality_filtered_array(
scan_result.images,
"spots_low_res",
min_spots=None,
min_efficiency=1.0,
min_background=None,
min_low_res_spots=None,
)
com = raster_centre_of_mass(result_array, scan_result.images)
com = raster_highest_score(scan_result.images)
target_coor = com.get_com_mm(request)
target_coor_offset = self.ctx.sample_geometry.smargon_nudge(target_coor)
target_smargon = SmargonCoordinate(
@@ -381,15 +372,7 @@ class RasterService:
)
raise RasterScanException("JFJoch returned no ScanResult for raster")
result_array = create_quality_filtered_array(
scan_result.images,
"spots_low_res",
min_spots=None,
min_efficiency=1.0,
min_background=None,
min_low_res_spots=None,
)
com = raster_centre_of_mass(result_array, scan_result.images)
com = raster_highest_score(scan_result.images)
if com is None:
self.logger.info("Calcualted COM is None using centre image")
if request.n_x == 1:
@@ -86,6 +86,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.metric_combo = QComboBox()
self._layout.addWidget(QLabel("Metric", parent=self), 7, 0)
self.metric_combo.addItem("Raster score", RasterGridMetric.RASTER_SCORE)
self.metric_combo.addItem("Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES)
self.metric_combo.addItem("Spot count", RasterGridMetric.SPOTS)
self.metric_combo.addItem("Spot count (indexed)", RasterGridMetric.SPOTS_INDEXED)
@@ -93,11 +94,11 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.metric_combo.addItem("Spot ratio (ice/low res.)", RasterGridMetric.SPOTS_ICE_LOW_RES)
self.metric_combo.addItem("Background estimate", RasterGridMetric.BKG)
self.metric_combo.addItem("Indexing result", RasterGridMetric.INDEXING)
self.metric_combo.addItem("Profile Radius (Score)", RasterGridMetric.PR) # JD: repurposing PR for testing scoring
self.metric_combo.addItem("Profile Radius", RasterGridMetric.PR)
self.metric_combo.addItem("B-factor", RasterGridMetric.BFACTOR)
self.metric_combo.addItem("Resolution (ML est.)", RasterGridMetric.RES)
self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.PR)) # JD: repurposing PR for testing scoring
self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.RASTER_SCORE))
self.metric_combo.currentIndexChanged.connect(self.metric_changed)
self._layout.addWidget(self.metric_combo, 7, 1, 1, 3)
@@ -30,6 +30,7 @@ class RasterGridMetric(Enum):
SPOTS_ICE = 7
SPOTS_INDEXED = 8
SPOTS_ICE_LOW_RES = 9
RASTER_SCORE = 10
# Viridis colormap colors (from matplotlib), dark purple -> yellow
_VIRIDIS_COLORS = [
@@ -122,8 +123,7 @@ class RasterGridManager(QObject):
self.__beam_size_mm = geom.beam_size_mm
self.__geom = geom
# self.__metric = RasterGridMetric.SPOTS_LOW_RES
self.__metric = RasterGridMetric.PR # JD: repurposing PR for testing scoring
self.__metric = RasterGridMetric.RASTER_SCORE
self.__loaded_image_prefix = None
self.__loaded_image_index = None
@@ -375,11 +375,11 @@ class RasterGridManager(QObject):
if grid.result.images[cell].b is not None and grid.result.images[cell].b >= 0:
txt += f"B-factor {grid.result.images[cell].b:.2f}<br/>"
# if grid.result.images[cell].pr is not None and grid.result.images[cell].pr >= 0: # JD: repurposing PR for testing scoring
# txt += f"Profile Radius {grid.result.images[cell].pr:.2f}"
if grid.result.images[cell].pr is not None and grid.result.images[cell].pr >= 0:
txt += f"Profile Radius {grid.result.images[cell].pr:.2f}<br/>"
if grid.result.images[cell].nx is not None and grid.result.images[cell].ny is not None:
score = compute_crystal_score_array(grid.result.images)
txt += f"Profile Radius (Score) {score[grid.result.images[cell].nx, grid.result.images[cell].ny]:.2f}"
txt += f"Raster score {score[grid.result.images[cell].nx, grid.result.images[cell].ny]:.2f}"
return txt
return None
@@ -466,8 +466,9 @@ class RasterGridManager(QObject):
v = [obj.bkg for obj in i.result.images]
case RasterGridMetric.INDEXING:
v = [obj.index for obj in i.result.images]
case RasterGridMetric.PR: # JD: repurposing PR for testing scoring
# v = [obj.index / max(obj.spots_low_res, 1) for obj in i.result.images]
case RasterGridMetric.PR:
v = [obj.index / max(obj.spots_low_res, 1) for obj in i.result.images]
case RasterGridMetric.RASTER_SCORE:
score = compute_crystal_score_array(i.result.images)
v = [score[obj.nx, obj.ny] if obj.nx is not None and obj.ny is not None else None for obj in i.result.images]
case RasterGridMetric.BFACTOR:
+50 -1
View File
@@ -4,7 +4,8 @@ from unittest.mock import MagicMock
from aare.common.find_xtal import (
identify_crystal_raster, rebuild_array_from_scan_results,
create_quality_filtered_array, get_xtal_size, get_best_b_factor,
get_best_res, raster_centre_of_mass, has_sufficient_low_res_spots
get_best_res, raster_centre_of_mass, has_sufficient_low_res_spots,
compute_crystal_score_array, raster_highest_score
)
from aare.common.raster_grid import RasterGridRequest, CenterOfMassModel
from aare.common.models import CrystalSize, Coordinate
@@ -95,3 +96,51 @@ def test_has_sufficient_low_res_spots():
assert has_sufficient_low_res_spots(arr, 3.0) is True
assert has_sufficient_low_res_spots(arr, 5.0) is False
assert has_sufficient_low_res_spots(None, 1.0) is False
@pytest.fixture
def mock_score_results():
# 2x2 grid; cell (1, 1) is the strongest crystal across bkg, spots_low_res
# and spots_indexed (the three inputs to compute_crystal_score_array).
data = [
# nx, ny, bkg, spots_low_res, spots_indexed
(0, 0, 1.0, 1.0, 0.0),
(1, 0, 2.0, 2.0, 0.0),
(0, 1, 2.0, 2.0, 0.0),
(1, 1, 10.0, 20.0, 5.0),
]
results = []
for i, (nx, ny, bkg, low, idx) in enumerate(data):
res = MagicMock()
res.nx, res.ny, res.number = nx, ny, i
res.bkg, res.spots_low_res, res.spots_indexed = bkg, low, idx
results.append(res)
return results
def test_compute_crystal_score_array(mock_score_results):
score = compute_crystal_score_array(mock_score_results)
assert score.shape == (2, 2)
# weights sum to 1.0 and each input is min-max normalised to [0, 100]
assert score.min() >= 0.0 and score.max() <= 100.0
# (1, 1) is max in all three inputs -> 100; weak corner (0, 0) is min in all -> 0
assert score[1, 1] == pytest.approx(100.0)
assert score[0, 0] == pytest.approx(0.0)
assert np.unravel_index(np.argmax(score), score.shape) == (1, 1)
def test_compute_crystal_score_array_weights():
# Cell A is the sole max in spots_low_res (weight 0.55); cell B is the sole
# max in spots_indexed (weight 0.20). A must outscore B.
def _cell(nx, n, low, idx):
r = MagicMock()
r.nx, r.ny, r.number = nx, 0, n
r.bkg, r.spots_low_res, r.spots_indexed = 0.0, low, idx
return r
score = compute_crystal_score_array([_cell(0, 0, 10.0, 0.0), _cell(1, 1, 0.0, 10.0)])
assert score[0, 0] > score[1, 0]
assert score[0, 0] == pytest.approx(55.0)
assert score[1, 0] == pytest.approx(20.0)
def test_raster_highest_score(mock_score_results):
com = raster_highest_score(mock_score_results)
assert com.n_x == 1.0
assert com.n_y == 1.0
assert com.max_image == 3