Skip to content

Commit 8401464

Browse files
committed
feat: enhance cell matcher functionality with centroid calculations and improved status updates
1 parent a0380db commit 8401464

1 file changed

Lines changed: 78 additions & 14 deletions

File tree

src/xenium_analysis_tools/alignment/format_for_napari.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,32 +1197,46 @@ def _maybe_float(v):
11971197
# ------------------------------------------------------------------
11981198
import napari as _napari
11991199

1200+
def _label_centroid_world(layer, label_val):
1201+
import numpy as _np
1202+
data = layer.data
1203+
if hasattr(data, 'compute'):
1204+
data = data.compute()
1205+
data = _np.asarray(data)
1206+
coords = _np.argwhere(data == int(label_val))
1207+
if len(coords) == 0:
1208+
return None
1209+
centroid_idx = coords.mean(axis=0)
1210+
return tuple(layer.data_to_world(centroid_idx))
1211+
12001212
def _gcamp_label_at_pos(pos):
12011213
for layer in viewer.layers:
12021214
if isinstance(layer, _napari.layers.Labels) and layer.name.startswith(gcamp_layer_prefix):
12031215
val = layer.get_value(pos, world=True)
1204-
return val, None
1205-
return None, f"No Labels layer with prefix '{gcamp_layer_prefix}' found."
1216+
return val, layer, None
1217+
return None, None, f"No Labels layer with prefix '{gcamp_layer_prefix}' found."
12061218

12071219
def _xenium_label_at_pos(pos):
1208-
"""Returns (hits, warning) where hits is a list of (cell_id, layer_name).
1220+
"""Returns (hits, hit_layers, warning) where hits is a list of (cell_id, layer_name).
12091221
If multiple layers overlap, all are returned with a warning string."""
12101222
hits = []
1223+
hit_layers = []
12111224
found_any = False
12121225
for layer in viewer.layers:
12131226
if isinstance(layer, _napari.layers.Labels) and layer.name.startswith(xenium_layer_prefix):
12141227
found_any = True
12151228
val = layer.get_value(pos, world=True)
12161229
if val is not None and val != 0:
12171230
hits.append((int(val), layer.name))
1231+
hit_layers.append(layer)
12181232
if not found_any:
1219-
return [], f"No Labels layer with prefix '{xenium_layer_prefix}' found."
1233+
return [], [], f"No Labels layer with prefix '{xenium_layer_prefix}' found."
12201234
if not hits:
1221-
return [], "Clicked background in all Xenium layers. Try again."
1235+
return [], [], "Clicked background in all Xenium layers. Try again."
12221236
if len(hits) > 1:
12231237
names = ', '.join(f"{n} (id={v})" for v, n in hits)
1224-
return hits, f"Warning: overlapping sections — will save {len(hits)} rows: {names}"
1225-
return hits, None
1238+
return hits, hit_layers, f"Warning: overlapping sections — will save {len(hits)} rows: {names}"
1239+
return hits, hit_layers, None
12261240

12271241
# ------------------------------------------------------------------
12281242
# UI
@@ -1272,7 +1286,7 @@ def _xenium_label_at_pos(pos):
12721286
btn_row.addWidget(b)
12731287
layout.addLayout(btn_row)
12741288

1275-
status = QLabel("Click 'Start Matching' to begin.")
1289+
status = QLabel("")
12761290
status.setWordWrap(True)
12771291
layout.addWidget(status)
12781292

@@ -1430,8 +1444,33 @@ def _navigate_to(row, col):
14301444
if any(p is None for p in pos):
14311445
status.setText("No coordinates stored for this row (loaded from older CSV).")
14321446
return
1433-
viewer.camera.center = pos
1434-
status.setText(f"Navigated to {'GCaMP' if col == 0 else 'Xenium'} cell position.")
1447+
z, y, x = pos
1448+
# Move z-slider to the cell's plane
1449+
viewer.dims.set_point(0, z)
1450+
viewer.camera.center = (z, y, x)
1451+
label = 'GCaMP' if col == 0 else 'Xenium'
1452+
status.setText(f"Navigated to {label} cell — z={z:.2f}, y={y:.2f}, x={x:.2f}")
1453+
1454+
def _show_row_coords():
1455+
selected = table.selectedItems()
1456+
if not selected:
1457+
return
1458+
row = selected[0].row()
1459+
item = table.item(row, 0)
1460+
if item is None:
1461+
return
1462+
orig_idx = item.data(Qt.UserRole)
1463+
if orig_idx is None or orig_idx >= len(matches):
1464+
return
1465+
m = matches[orig_idx]
1466+
gz, gy, gx = m[3], m[4], m[5]
1467+
xz, xy, xx = m[6], m[7], m[8]
1468+
def _fmt(v):
1469+
return f"{v:.2f}" if v is not None else "?"
1470+
status.setText(
1471+
f"GCaMP {m[0]}: z={_fmt(gz)}, y={_fmt(gy)}, x={_fmt(gx)} | "
1472+
f"Xenium {m[1]}: z={_fmt(xz)}, y={_fmt(xy)}, x={_fmt(xx)}"
1473+
)
14351474

14361475
# Canvas click handler — fires on every left-click while armed.
14371476
# napari mouse_drag_callbacks must be generator functions.
@@ -1442,24 +1481,25 @@ def _on_canvas_click(viewer_obj, event):
14421481
state['armed'] = None
14431482
_refresh_armed()
14441483
if armed == 'gcamp':
1445-
val, err = _gcamp_label_at_pos(pos)
1484+
val, layer, err = _gcamp_label_at_pos(pos)
14461485
if err:
14471486
status.setText(err)
14481487
elif val is None or val == 0:
14491488
status.setText("Clicked background in GCaMP layer. Try again.")
14501489
else:
14511490
state['gcamp_id'] = int(val)
1452-
state['gcamp_pos'] = tuple(pos)
1491+
state['gcamp_pos'] = _label_centroid_world(layer, val)
14531492
_refresh_pending()
14541493
_check_dupes()
14551494
status.setText(f"GCaMP cell {val} picked.")
14561495
else:
1457-
hits, warn = _xenium_label_at_pos(pos)
1496+
hits, hit_layers, warn = _xenium_label_at_pos(pos)
14581497
if not hits:
14591498
status.setText(warn)
14601499
else:
14611500
state['xenium_hits'] = hits
1462-
state['xenium_pos'] = tuple(pos)
1501+
# Use centroid of first hit layer for navigation (representative position)
1502+
state['xenium_pos'] = _label_centroid_world(hit_layers[0], hits[0][0])
14631503
_refresh_pending()
14641504
_check_dupes()
14651505
if warn:
@@ -1472,7 +1512,31 @@ def _on_canvas_click(viewer_obj, event):
14721512

14731513
viewer.mouse_drag_callbacks.append(_on_canvas_click)
14741514

1515+
# # ------------------------------------------------------------------
1516+
# # Z-slider section boundary notifications
1517+
# # ------------------------------------------------------------------
1518+
# _z_section_state = {'last_sections': set()}
1519+
1520+
# def _on_z_change():
1521+
# current_z = viewer.dims.point[0]
1522+
# active_now = set()
1523+
# for layer in viewer.layers:
1524+
# if isinstance(layer, _napari.layers.Labels) and layer.name.startswith(xenium_layer_prefix):
1525+
# zmin, zmax = layer.extent.world[0][0], layer.extent.world[1][0]
1526+
# if zmin <= current_z <= zmax:
1527+
# active_now.add(layer.name)
1528+
# entered = active_now - _z_section_state['last_sections']
1529+
# exited = _z_section_state['last_sections'] - active_now
1530+
# if entered or exited:
1531+
# msgs = [f"Entering {n}" for n in sorted(entered)] + \
1532+
# [f"Leaving {n}" for n in sorted(exited)]
1533+
# status.setText(" | ".join(msgs))
1534+
# _z_section_state['last_sections'] = active_now
1535+
1536+
# viewer.dims.events.current_step.connect(_on_z_change)
1537+
14751538
table.cellDoubleClicked.connect(_navigate_to)
1539+
table.itemSelectionChanged.connect(_show_row_coords)
14761540

14771541
pick_gcamp_btn.clicked.connect(lambda: _arm('gcamp'))
14781542
pick_xenium_btn.clicked.connect(lambda: _arm('xenium'))

0 commit comments

Comments
 (0)