[Test Needed] add a mock grid simulation option at no_beam condition, default to be false #49

Closed
duan_j wants to merge 2 commits from find_crystal_from_gridscan into master
4 changed files with 540 additions and 1 deletions
+297 -1
View File
@@ -314,4 +314,300 @@ def has_sufficient_low_res_spots(
)
return False
return True
return True
def compute_crystal_score_array(
scan_results: List,
w_bkg: float = 0.25,
w_low_res: float = 0.55,
w_indexed: float = 0.20,
) -> np.ndarray:
"""Combine bkg (25%), spots_low_res (55%), and spots_indexed (20%) into a 0100 score.
Each field is min-max normalised to [0, 100] within the grid before weighting,
so the final score is the probability (0100) that a pixel belongs to a crystal.
"""
arr_bkg = rebuild_array_from_scan_results(scan_results, "bkg")
arr_low = rebuild_array_from_scan_results(scan_results, "spots_low_res")
arr_idx = rebuild_array_from_scan_results(scan_results, "spots_indexed")
def _norm(a: np.ndarray) -> np.ndarray:
mn, mx = float(a.min()), float(a.max())
if mx == mn:
return np.zeros_like(a, dtype=float)
return (a - mn) / (mx - mn) * 100.0
return w_bkg * _norm(arr_bkg) + w_low_res * _norm(arr_low) + w_indexed * _norm(arr_idx)
def _max_cell(arr: np.ndarray) -> tuple[int, int]:
"""Return (nx, ny) of the cell with the highest value."""
idx = np.unravel_index(np.argmax(arr), arr.shape)
return int(idx[0]), int(idx[1])
def _draw_panel(
ax,
arr: np.ndarray,
mask: np.ndarray,
label: str,
threshold: float,
grid_size_mm: Optional[tuple[float, float]] = None,
cbar_label: str = "spots_low_res",
) -> None:
"""Shared helper: heatmap + crystal contour + max-cell square on one Axes.
If grid_size_mm=(step_x_mm, step_y_mm) is provided, the crystal size in µm
is computed via get_xtal_size and shown in the panel title.
"""
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from aare.common.coordinate import Coordinate
n_nx, n_ny = arr.shape
max_nx, max_ny = _max_cell(arr)
n_cells = int(mask.sum())
im = ax.imshow(
arr.T,
origin="lower",
cmap="viridis",
aspect="equal",
extent=[-0.5, n_nx - 0.5, -0.5, n_ny - 0.5],
)
plt.colorbar(im, ax=ax, label=cbar_label, fraction=0.046, pad=0.04)
ax.contour(
np.arange(n_nx), np.arange(n_ny), mask.T.astype(float),
levels=[0.5],
colors="cyan",
linewidths=1.8,
)
rect = mpatches.Rectangle(
(max_nx - 0.5, max_ny - 0.5), 1, 1,
linewidth=2, edgecolor="red", facecolor="none",
)
ax.add_patch(rect)
size_line = ""
if grid_size_mm is not None and n_cells > 0:
r = RasterGridRequest(
exp_time_s=0.0,
n_x=n_nx,
n_y=n_ny,
grid_size_mm=Coordinate(x=grid_size_mm[0], y=grid_size_mm[1]),
smargon_top_left=None,
)
xtal_size = get_xtal_size(CrystalSize(x=0, y=0, z=0), mask.astype(float), r)
size_line = f"\n{xtal_size.x:.0f}×{xtal_size.y:.0f} µm"
ax.set_title(
f"{label}\nthresh≈{threshold:.1f} | {n_cells} cells{size_line}",
fontsize=8,
)
ax.set_xlabel("nx", fontsize=7)
ax.set_ylabel("ny", fontsize=7)
ax.tick_params(labelsize=6)
# ── Clustering / thresholding methods ─────────────────────────────────────────
def crystal_mask_corner_background(arr: np.ndarray) -> tuple[np.ndarray, float]:
"""Threshold = max(far-corner value, floor=10).
Simple, parameter-free. Returns too many cells when corners are zero
because any nonzero value passes.
"""
n_nx, n_ny = arr.shape
corners = [arr[0, 0], arr[n_nx - 1, 0], arr[0, n_ny - 1], arr[n_nx - 1, n_ny - 1]]
threshold = max(float(np.max(corners)), 10.0)
return arr > threshold, threshold
def crystal_mask_otsu_nonzero(arr: np.ndarray) -> tuple[np.ndarray, float]:
"""Otsu threshold computed only on the nonzero values.
Finds the natural gap in the signal distribution.
Ignores the large mass of background zeros so the threshold is
placed within the diffraction-signal population.
"""
from skimage.filters import threshold_otsu
nonzero = arr[arr > 0]
if nonzero.size == 0:
return np.zeros_like(arr, dtype=bool), 0.0
threshold = float(threshold_otsu(nonzero))
return arr > threshold, threshold
def crystal_mask_mean_sigma(arr: np.ndarray, n_sigma: float = 0.5) -> tuple[np.ndarray, float]:
"""Threshold = mean + n_sigma * std of nonzero values.
n_sigma=0.5 keeps cells within ~1/2 std above average signal.
Raise n_sigma to tighten the region around the strongest-diffracting core.
"""
nonzero = arr[arr > 0]
if nonzero.size == 0:
return np.zeros_like(arr, dtype=bool), 0.0
threshold = float(nonzero.mean() + n_sigma * nonzero.std())
return arr > threshold, threshold
def crystal_mask_signal_percentile(arr: np.ndarray, percentile: float = 60.0) -> tuple[np.ndarray, float]:
"""Threshold = given percentile of nonzero values.
percentile=60 keeps the top 40 % of signal cells; raise to sharpen.
Unlike mean+sigma this is robust to heavy-tailed distributions.
"""
nonzero = arr[arr > 0]
if nonzero.size == 0:
return np.zeros_like(arr, dtype=bool), 0.0
threshold = float(np.percentile(nonzero, percentile))
return arr > threshold, threshold
def crystal_mask_dbscan(arr: np.ndarray, min_signal: float = 10.0,
eps: float = 1.5, min_samples: int = 3) -> tuple[np.ndarray, float]:
"""DBSCAN spatial clustering on cells with signal > min_signal.
Groups adjacent diffracting cells into clusters; the largest cluster
(by total signal weight) is labelled as the crystal.
eps=1.5 connects cells that are one grid step apart (including diagonal).
"""
from sklearn.cluster import DBSCAN
ys, xs = np.where(arr > min_signal)
if len(ys) == 0:
return np.zeros_like(arr, dtype=bool), min_signal
coords = np.column_stack([ys, xs]).astype(float)
labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(coords)
best_label, best_weight = -1, -1.0
for lbl in set(labels):
if lbl == -1:
continue
weight = float(arr[ys[labels == lbl], xs[labels == lbl]].sum())
if weight > best_weight:
best_label, best_weight = lbl, weight
mask = np.zeros_like(arr, dtype=bool)
if best_label != -1:
sel = labels == best_label
mask[ys[sel], xs[sel]] = True
return mask, min_signal
def crystal_mask_kmeans(arr: np.ndarray, n_clusters: int = 3) -> tuple[np.ndarray, float]:
"""K-means on signal values (1-D feature).
Splits cells into n_clusters groups; the cluster with the highest
centroid is the crystal. n_clusters=3 separates background / fringe / crystal.
"""
from sklearn.cluster import KMeans
flat = arr.flatten().reshape(-1, 1)
km = KMeans(n_clusters=n_clusters, random_state=0, n_init="auto").fit(flat)
crystal_label = int(np.argmax(km.cluster_centers_.flatten()))
threshold = float(sorted(km.cluster_centers_.flatten())[-2])
mask = (km.labels_.reshape(arr.shape) == crystal_label)
return mask, threshold
CRYSTAL_METHOD_MAP: dict = {
"corner": ("Corner background", crystal_mask_corner_background),
"otsu": ("Otsu (nonzero)", crystal_mask_otsu_nonzero),
"mean_sigma": ("Mean + 0.5σ", lambda arr: crystal_mask_mean_sigma(arr, n_sigma=0.5)),
"percentile": ("Top-40% signal", lambda arr: crystal_mask_signal_percentile(arr, percentile=60.0)),
"dbscan": ("DBSCAN (spatial)", crystal_mask_dbscan),
"kmeans": ("K-means (k=3)", crystal_mask_kmeans),
}
def compare_crystal_methods(
results: List,
arr: Optional[np.ndarray] = None,
grid_size_mm: Optional[tuple[float, float]] = None,
) -> None:
"""Plot each crystal-detection method side-by-side for visual comparison.
Cyan contour = detected crystal region.
Red square = cell with maximum spots_low_res.
grid_size_mm = (step_x_mm, step_y_mm) — when provided, crystal size in µm
is calculated via get_xtal_size and shown in each panel title.
"""
import matplotlib.pyplot as plt
if arr is None:
arr = rebuild_array_from_scan_results(results, "spots_low_res")
methods = [
("Corner background", *crystal_mask_corner_background(arr)),
("Otsu (nonzero)", *crystal_mask_otsu_nonzero(arr)),
("Mean + 0.5σ", *crystal_mask_mean_sigma(arr, n_sigma=0.5)),
("Top-40% signal", *crystal_mask_signal_percentile(arr, percentile=60.0)),
("DBSCAN (spatial)", *crystal_mask_dbscan(arr)),
("K-means (k=3)", *crystal_mask_kmeans(arr)),
]
fig, axes = plt.subplots(2, 3, figsize=(14, 9))
for ax, (label, mask, threshold) in zip(axes.flat, methods):
_draw_panel(ax, arr, mask, label, threshold, grid_size_mm=grid_size_mm)
fig.suptitle("Crystal region detection — method comparison\n"
"cyan = crystal outline | red square = max spots_low_res cell",
fontsize=10)
plt.tight_layout()
plt.show()
def compare_crystal_methods_scored(
results: List,
score_arr: Optional[np.ndarray] = None,
method: Optional[str] = None,
grid_size_mm: Optional[tuple[float, float]] = None,
w_bkg: float = 0.20,
w_low_res: float = 0.60,
w_indexed: float = 0.20,
) -> None:
"""Plot each crystal-detection method applied to the composite 0100 crystal score.
The score combines bkg (w_bkg), spots_low_res (w_low_res), and spots_indexed
(w_indexed), each min-max normalised. All six methods are shown side-by-side.
Args:
results: scan result list (used to build score if score_arr is None)
score_arr: pre-computed score array; computed from results when None
method: if given (one of 'corner','otsu','mean_sigma','percentile',
'dbscan','kmeans'), that panel is highlighted with a yellow border
grid_size_mm: (step_x_mm, step_y_mm) for crystal-size annotation
w_bkg, w_low_res, w_indexed: composite score weights (must sum to 1.0)
"""
import matplotlib.pyplot as plt
if score_arr is None:
score_arr = compute_crystal_score_array(results, w_bkg=w_bkg, w_low_res=w_low_res, w_indexed=w_indexed)
method_entries = [
(name, label, fn)
for name, (label, fn) in CRYSTAL_METHOD_MAP.items()
]
fig, axes = plt.subplots(2, 3, figsize=(15, 9))
for ax, (name, label, fn) in zip(axes.flat, method_entries):
mask, threshold = fn(score_arr)
_draw_panel(ax, score_arr, mask, label, threshold,
grid_size_mm=grid_size_mm, cbar_label="crystal score (0100)")
if method and name == method:
for spine in ax.spines.values():
spine.set_edgecolor("yellow")
spine.set_linewidth(3)
weight_note = f"bkg×{w_bkg:.0%} + spots_low_res×{w_low_res:.0%} + spots_indexed×{w_indexed:.0%}"
fig.suptitle(
f"Crystal region detection on composite score [{weight_note}]\n"
"cyan = crystal outline | red square = max-score cell | score range 0100",
fontsize=10,
)
plt.tight_layout()
plt.show()
+4
View File
@@ -32,6 +32,10 @@ class RasterGridRequest(BaseModel):
visible: bool = True
# When True, skip all hardware and generate simulated diffraction data.
# The file_prefix will have "_SIMU" appended so the DB record is identifiable.
no_beam: bool = False
def get_image_number(self) -> int:
return self.n_x * self.n_y
+214
View File
@@ -0,0 +1,214 @@
"""
Synthetic raster scan data for no-beam / offline testing.
Entry point
-----------
generate_no_beam_scan_result(request, seed=None) -> ScanResult
The returned ScanResult is structurally identical to a real one, so all
find_xtal.py functions consume it without modification.
Pre-defined seeds
-----------------
Each seed fixes cluster geometry so results are reproducible. The six
scenarios below cover the main cases needed for unit-testing crystal-finding
algorithms.
Seed Scenario
---- --------
1 Single crystal, centred baseline positive detection
2 Single crystal, off-centre tests COM accuracy near an edge
3 Two well-separated crystals multi-crystal detection
4 Two overlapping crystals segmentation challenge
5 Two clusters at ~90 ° twinned / differently oriented crystal
6 Pure background only true negative (no crystal)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
import numpy as np
from jfjoch_client import ScanResult
from jfjoch_client.models.scan_result_images_inner import ScanResultImagesInner
from aare.common.raster_grid import RasterGridRequest
# ---------------------------------------------------------------------------
# Cluster geometry descriptor
# ---------------------------------------------------------------------------
@dataclass
class _ClusterParams:
"""Fractional coordinates and shape of one elliptical crystal cluster.
cx, cy cluster centre as a fraction of (n_x, n_y) [0..1]
ax, ay semi-axes as a fraction of (n_x, n_y)
theta rotation of the ellipse in radians
peak peak spots_low_res at cluster centre (integer)
"""
cx: float
cy: float
ax: float
ay: float
theta: float
peak: int
@dataclass
class _SeedConfig:
clusters: List[_ClusterParams] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Pre-defined seed catalogue
# ---------------------------------------------------------------------------
SEED_CATALOGUE: dict[int, _SeedConfig] = {
# --- 1: single crystal, centred, compact, strong signal ----------------
1: _SeedConfig(clusters=[
_ClusterParams(cx=0.50, cy=0.50, ax=0.15, ay=0.15, theta=0.0, peak=120),
]),
# --- 2: single crystal, off-centre, slightly elongated -----------------
2: _SeedConfig(clusters=[
_ClusterParams(cx=0.25, cy=0.70, ax=0.12, ay=0.18, theta=0.35, peak=90),
]),
# --- 3: two well-separated crystals ------------------------------------
3: _SeedConfig(clusters=[
_ClusterParams(cx=0.20, cy=0.20, ax=0.12, ay=0.12, theta=0.0, peak=100),
_ClusterParams(cx=0.75, cy=0.72, ax=0.15, ay=0.10, theta=0.2, peak=80),
]),
# --- 4: two overlapping crystals (centres ~1 sigma apart) --------------
4: _SeedConfig(clusters=[
_ClusterParams(cx=0.40, cy=0.45, ax=0.18, ay=0.15, theta=0.0, peak=110),
_ClusterParams(cx=0.58, cy=0.55, ax=0.16, ay=0.18, theta=0.5, peak=95),
]),
# --- 5: two clusters with ~90° different orientations (twinned) --------
5: _SeedConfig(clusters=[
_ClusterParams(cx=0.30, cy=0.40, ax=0.25, ay=0.08, theta=0.0, peak=105),
_ClusterParams(cx=0.68, cy=0.62, ax=0.08, ay=0.25, theta=0.0, peak=100),
]),
# --- 6: pure background, no crystal true negative --------------------
6: _SeedConfig(clusters=[]),
}
# ---------------------------------------------------------------------------
# Core generation
# ---------------------------------------------------------------------------
def _gaussian_cluster(
ix: np.ndarray,
iy: np.ndarray,
p: _ClusterParams,
n_x: int,
n_y: int,
) -> np.ndarray:
"""Return a 2-D Gaussian signal array for one cluster.
ix, iy are integer coordinate grids with shape (n_x, n_y).
"""
cx = p.cx * (n_x - 1)
cy = p.cy * (n_y - 1)
dx = (ix - cx) / max(p.ax * n_x, 0.5)
dy = (iy - cy) / max(p.ay * n_y, 0.5)
cos_t = np.cos(p.theta)
sin_t = np.sin(p.theta)
dx_r = dx * cos_t + dy * sin_t
dy_r = -dx * sin_t + dy * cos_t
return p.peak * np.exp(-2.0 * (dx_r ** 2 + dy_r ** 2))
def generate_no_beam_scan_result(
request: RasterGridRequest,
seed: int | None = None,
) -> ScanResult:
"""Build a synthetic ScanResult for no-beam / offline operation.
Parameters
----------
request:
The grid request whose n_x, n_y, and file_prefix are used.
seed:
Integer 1-6 selects a pre-defined scenario from SEED_CATALOGUE.
Any other value (or None) draws cluster parameters randomly using
the seed as an RNG seed (None → fully random).
Returns
-------
ScanResult
Populated with one ScanResultImagesInner per grid cell, with
realistic background noise and optional crystal cluster(s).
"""
n_x = max(request.n_x, 1)
n_y = max(request.n_y, 1)
rng = np.random.default_rng(seed)
# Coordinate grids
ix, iy = np.meshgrid(np.arange(n_x), np.arange(n_y), indexing="ij")
# Background: Poisson noise, mean ≈ 3 counts
bkg = rng.poisson(lam=3.0, size=(n_x, n_y)).astype(float)
# Crystal signal layer (spots_low_res)
signal = np.zeros((n_x, n_y), dtype=float)
if seed in SEED_CATALOGUE:
cfg = SEED_CATALOGUE[seed]
clusters = cfg.clusters
else:
# Random fallback: 1 or 2 clusters
n_clusters = rng.integers(1, 3)
clusters = [
_ClusterParams(
cx=rng.uniform(0.15, 0.85),
cy=rng.uniform(0.15, 0.85),
ax=rng.uniform(0.12, 0.30),
ay=rng.uniform(0.12, 0.30),
theta=rng.uniform(0, np.pi),
peak=int(rng.integers(50, 150)),
)
for _ in range(n_clusters)
]
for p in clusters:
signal += _gaussian_cluster(ix, iy, p, n_x, n_y)
# Add Poisson noise on top of the crystal signal
spots_low_res = rng.poisson(lam=np.maximum(signal, 0)).astype(int)
# Assemble image list one entry per grid cell in row-major order
images: list[ScanResultImagesInner] = []
for xi in range(n_x):
for yi in range(n_y):
img_number = xi * n_y + yi
images.append(ScanResultImagesInner(
number=img_number,
nx=xi,
ny=yi,
efficiency=1.0,
bkg=float(bkg[xi, yi]),
spots=int(spots_low_res[xi, yi]),
spots_low_res=int(spots_low_res[xi, yi]),
spots_indexed=0,
spots_ice=0,
index=0,
b=0.0,
res=None,
pixel_sum=None,
max=None,
sat=None,
err=None,
))
return ScanResult(file_prefix=request.file_prefix, images=images)
+25
View File
@@ -33,6 +33,7 @@ from aare.common.models import (
SimpleScanParameters, MLBoxModel, FluorescenceSpectrumParameterModel,
FluorescenceSpectrumOutputModel)
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid, CompletedRasterGridElem
from aare.common.simulate_raster import generate_no_beam_scan_result
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
from aare.common.sample_geometry import SampleGeometryModel
from aare.daq.spreadsheetupdater import beamline
@@ -917,6 +918,30 @@ class AareDAQ:
if self.sample is not None and self.sample.db_id is not None:
self.__aare.create_gridscan_run(self.sample, request, status)
if request.no_beam:
request = copy.deepcopy(request)
if request.file_prefix:
request.file_prefix = f"{request.file_prefix}_SIMU"
logger.info("No beam mode: skipping hardware, generating simulated raster result.")
scan_result = generate_no_beam_scan_result(request)
sample_id = self.sample.db_id if self.sample and self.sample.db_id is not None else None
if sample_id:
self.__set_state(BeamlineStateEnum.SampleAlignment)
self.save_screenshot_db(sample_id, f"{sample_id}_post_raster_{request.omega_deg}deg")
self.__aare.ingest_gridscan(
sample=self.sample,
raster_result=scan_result,
raster_request=request,
geom=self.sample_geometry,
com=None,
beam_mark_pxl=self.__cfg.get_beam_mark(self.zoom),
)
return CompletedRasterGridElem(
request=request,
result=scan_result,
centre_of_mass=None,
)
if not self.__cfg.simulated_detector:
logger.info("initialise detector")
self.__jfjoch.measure_raster(request, status)