OSCR

Stable excitatory-inhibitory synapse balance despite dynamic turnover.

Code ↔ Paper

11 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 11 matches
  1. [1] § Methods › Experimental models and subject details › Live image analysis › Drift correction ↔ ConsensusDrift/registration.py, lines 1626–1691 · score 0.77 · tiled phase correlation, composite image, consensus cluster, DBSCAN, Otsu, shift
  2. [2] § Methods › Experimental models and subject details › Live image analysis › Puncta tracking analysis ↔ unisynapse/track/mincostflow.py, lines 2234–2286 · score 0.76 · sink node, min cost flow, source node, solver, bypass, MCF
  3. [3] § Methods › Experimental models and subject details › Live image analysis › Puncta tracking analysis ↔ unisynapse/track/mincostflow.py, lines 70–164 · score 0.69 · nearest neighbor, Candidate links, missed frame, gap, displacement, detections
  4. [4] § Methods › Experimental models and subject details › Live image analysis › Puncta track categorization ↔ scripts/analyze_tracks.py, lines 560–682 · score 0.65 · detection persistence, paired fraction, analyzed tracks, unpaired, duration, frame
  5. [5] § Methods › Experimental models and subject details › Live image analysis › Puncta segmentation ↔ unisynapse/image/masks.py, lines 1187–1262 · score 0.59 · local background statistics, seed, confidence, scores, weighted, segmented
  6. [6] § Methods › Experimental models and subject details › Live image analysis ↔ scripts/imagej_puncta_preprocess.py, lines 1–61 · score 0.59 · ImageJ, synaptic puncta, FIJI, subtracted, background
  7. [7] § Methods › Experimental models and subject details › Live image analysis › Pairing analysis ↔ unisynapse/pair/hungarian.py, lines 18–71 · score 0.58 · overlap score, segmented masks, intersection, union, distance, pre
  8. [8] § Methods › Experimental models and subject details › Live image analysis › Puncta tracking analysis ↔ unisynapse/config/schema.py, lines 468–528 · score 0.56 · motion smoothness weight, penalized, velocity, transit, displacement, intensity
  9. [9] § Methods › Experimental models and subject details › Live image analysis › Puncta tracking analysis ↔ unisynapse/track/mincostflow.py, lines 70–164 · score 0.56 · motion smoothness weight, penalized, velocity, transit, displacement, intensity
  10. [10] § Methods › Experimental models and subject details › Live image analysis › Puncta segmentation ↔ unisynapse/detect/segmentation.py, lines 209–282 · score 0.53 · low thresholds, seed, Touching, peak, blob, segmented
  11. [11] § Methods › Experimental models and subject details › Live image analysis › Puncta tracking analysis ↔ unisynapse/track/mincostflow.py, lines 1940–2003 · score 0.51 · death costs, Google, transit, Birth, MCF, flow

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 2,591 lines · 111 KB · MIT · 4 matches

  1. """
  2. Min-cost flow tracking using Google OR-Tools.
  3. Also includes implementations of jaqaman et al. 2008 style tracking algorithm
  4. and a hungarian tracking style algorithm for comparison purposes.
  5. Implements multi-object tracking with:
  6. - Distance-based linking
  7. - Gap closing
  8. - Birth/death handling
  9. """
  10. from __future__ import annotations
  11. from pathlib import Path
  12. from typing import Dict, List, Optional, Tuple, Union, Set
  13. import numpy as np
  14. import pandas as pd
  15. try:
  16. from scipy.spatial import cKDTree
  17. SCIPY_AVAILABLE = True
  18. except ImportError:
  19. SCIPY_AVAILABLE = False
  20. # Try to import OR-Tools
  21. try:
  22. from ortools.graph.python import min_cost_flow
  23. ORTOOLS_AVAILABLE = True
  24. except ImportError:
  25. ORTOOLS_AVAILABLE = False
  26. # Try to import tqdm for progress bars
  27. try:
  28. from tqdm import tqdm
  29. TQDM_AVAILABLE = True
  30. except ImportError:
  31. TQDM_AVAILABLE = False
  32. # Import visualization from separate module
  33. from .visualization import save_debug_visualizations
  34. class MinCostFlowTracker:
  35. """
  36. Multi-object tracker using min-cost flow optimization.
  37. Parameters
  38. ----------
  39. max_displacement_um : float
  40. Maximum displacement per frame in µm
  41. max_gap_frames : int
  42. Maximum gap to close (frames)
  43. cost_distance : float
  44. Weight for distance cost
  45. cost_intensity_diff : float
  46. Weight for intensity difference cost
  47. cost_size_diff : float
  48. Weight for size difference cost
  49. birth_cost : float
  50. Cost for track initiation
  51. death_cost : float
  52. Cost for track termination
  53. pixel_size_xy : float
  54. XY pixel size in µm
  55. pixel_size_z : float or None
  56. Z pixel size in µm
  57. """
  58. def __init__(
  59. self,
  60. greedy_max_displacement_um: float = 0.5,
  61. greedy_link_sigma_um: Optional[float] = None,
  62. greedy_max_gap_frames: int = 1,
  63. greedy_cost_distance: float = 1.0,
  64. greedy_cost_intensity_diff: float = 0.1,
  65. greedy_cost_size_diff: float = 0.05,
  66. greedy_motion_smoothness_weight: float = 0.0,
  67. greedy_max_neighbors: Optional[int] = 5,
  68. gap_close_penalty_per_missed_frame: float = 0.0,
  69. use_simple_displacement_extraction: bool = False,
  70. birth_cost: float = 10.0,
  71. death_cost: float = 10.0,
  72. pixel_size_xy: float = 1.0,
  73. pixel_size_z: Optional[float] = None,
  74. # MCF-specific parameters (optional overrides)
  75. mcf_birth_cost: Optional[float] = None,
  76. mcf_death_cost: Optional[float] = None,
  77. mcf_cost_distance: Optional[float] = None,
  78. mcf_cost_intensity_diff: Optional[float] = None,
  79. mcf_cost_size_diff: Optional[float] = None,
  80. mcf_max_displacement_um: Optional[float] = None,
  81. mcf_switch_penalty: Optional[float] = None,
  82. mcf_crowding_multiplier: Optional[float] = None,
  83. mcf_max_neighbors: Optional[int] = None,
  84. mcf_slack_cost_component: Optional[float] = None,
  85. mcf_quality_bonus_weight: Optional[float] = None,
  86. mcf_detection_bonus: Optional[float] = None,
  87. mcf_motion_smoothness_weight: Optional[float] = None,
  88. mcf_max_gap_frames: Optional[int] = None,
  89. mcf_use_gaussian_link_cost: bool = False,
  90. mcf_link_sigma_um: Optional[float] = None,
  91. # Verbosity
  92. verbosity_level: int = 0
  93. ):
  94. if not ORTOOLS_AVAILABLE:
  95. raise ImportError(
  96. "Google OR-Tools is required for tracking. "
  97. "Install with: pip install ortools"
  98. )
  99. # Greedy tracking parameters
  100. self.max_displacement_um = greedy_max_displacement_um # deprecated name kept for internal reuse
  101. self.max_gap_frames = greedy_max_gap_frames # deprecated name kept for internal reuse
  102. self.cost_distance = greedy_cost_distance # deprecated name kept for internal reuse
  103. self.cost_intensity_diff = greedy_cost_intensity_diff
  104. self.cost_size_diff = greedy_cost_size_diff
  105. self.greedy_link_sigma_um = greedy_link_sigma_um
  106. self.greedy_motion_smoothness_weight = greedy_motion_smoothness_weight
  107. self.greedy_max_neighbors = greedy_max_neighbors
  108. self.gap_close_penalty_per_missed_frame = gap_close_penalty_per_missed_frame
  109. self.use_simple_displacement_extraction = use_simple_displacement_extraction
  110. self.birth_cost = birth_cost
  111. self.death_cost = death_cost
  112. # Default pixel size to 1.0 µm/px if unspecified to avoid NaNs
  113. self.pixel_size_xy = pixel_size_xy if pixel_size_xy is not None else 1.0
  114. self.pixel_size_z = pixel_size_z
  115. # MCF-specific parameters (with intelligent defaults)
  116. # Birth/death costs, usually set higher than greedy tracking to discourage singletons
  117. # Otherwise flow can easily go from source->detecton->sink
  118. # Default MCF birth/death to greedy values when unspecified
  119. self.mcf_birth_cost = mcf_birth_cost if mcf_birth_cost is not None else self.birth_cost
  120. self.mcf_death_cost = mcf_death_cost if mcf_death_cost is not None else self.death_cost
  121. # Other MCF params default to same as greedy
  122. self.mcf_cost_distance = mcf_cost_distance if mcf_cost_distance is not None else greedy_cost_distance
  123. self.mcf_cost_intensity_diff = mcf_cost_intensity_diff if mcf_cost_intensity_diff is not None else greedy_cost_intensity_diff
  124. self.mcf_cost_size_diff = mcf_cost_size_diff if mcf_cost_size_diff is not None else greedy_cost_size_diff
  125. self.mcf_max_displacement_um = mcf_max_displacement_um if mcf_max_displacement_um is not None else greedy_max_displacement_um
  126. # Optional: use Gaussian negative log-likelihood distance costs for MCF transition edges
  127. # (off by default to preserve backwards-compatible behavior)
  128. self.mcf_use_gaussian_link_cost = bool(mcf_use_gaussian_link_cost)
  129. self.mcf_link_sigma_um = mcf_link_sigma_um
  130. self.mcf_switch_penalty = mcf_switch_penalty if mcf_switch_penalty is not None else 0.0
  131. self.mcf_crowding_multiplier = mcf_crowding_multiplier if mcf_crowding_multiplier is not None else 0.2
  132. # Limit candidate links to k nearest neighbors (per gap). None = unlimited
  133. self.mcf_max_neighbors = mcf_max_neighbors if mcf_max_neighbors is not None else 5
  134. self.mcf_slack_cost_component = mcf_slack_cost_component if mcf_slack_cost_component is not None else 0.0
  135. # Quality bonus for including detections (negative cost on transit edges)
  136. self.mcf_quality_bonus_weight = mcf_quality_bonus_weight if mcf_quality_bonus_weight is not None else 0.5
  137. # Flat detection bonus - incentivizes including any detection in tracks (negative cost)
  138. self.mcf_detection_bonus = mcf_detection_bonus if mcf_detection_bonus is not None else 0.0
  139. # Motion smoothness penalty - penalizes sudden changes in velocity (acceleration)
  140. self.mcf_motion_smoothness_weight = (
  141. mcf_motion_smoothness_weight
  142. if mcf_motion_smoothness_weight is not None
  143. else greedy_motion_smoothness_weight
  144. )
  145. # MCF-specific gap closing (independent from greedy tracking)
  146. self.mcf_max_gap_frames = mcf_max_gap_frames if mcf_max_gap_frames is not None else greedy_max_gap_frames
  147. # Verbosity level (propagated from config; 0=quiet, 1=normal, 2=verbose)
  148. self.verbosity_level: int = verbosity_level
  149. def _compute_link_cost(
  150. self,
  151. det1: pd.Series,
  152. det2: pd.Series,
  153. is_3d: bool,
  154. frame_gap: int = 1,
  155. use_mcf_costs: bool = False,
  156. prev_displacement: Optional[np.ndarray] = None,
  157. return_breakdown: bool = False
  158. ) -> Union[float, Tuple[float, dict]]:
  159. """
  160. Compute cost to link two detections.
  161. Parameters
  162. ----------
  163. det1, det2 : pd.Series
  164. Detections to link
  165. is_3d : bool
  166. Whether data is 3D
  167. frame_gap : int
  168. Number of frames between detections
  169. use_mcf_costs : bool
  170. If True, use MCF-specific cost parameters instead of greedy costs
  171. prev_displacement : np.ndarray, optional
  172. Previous displacement vector (µm) for motion smoothness calculation.
  173. If provided, penalizes sudden changes in velocity (acceleration).
  174. return_breakdown : bool
  175. If True, return tuple of (cost, breakdown_dict) with cost components
  176. Returns
  177. -------
  178. float or tuple
  179. Cost value, or (cost, breakdown_dict) if return_breakdown=True
  180. """
  181. # Basic finite checks
  182. if not (np.isfinite(det1.get('x')) and np.isfinite(det1.get('y')) and np.isfinite(det2.get('x')) and np.isfinite(det2.get('y'))):
  183. return np.inf
  184. use_z = (
  185. is_3d and
  186. self.pixel_size_z is not None and
  187. 'z' in det1 and 'z' in det2 and
  188. np.isfinite(det1.get('z')) and np.isfinite(det2.get('z'))
  189. )
  190. # Distance cost (in µm)
  191. if use_z:
  192. dist = np.sqrt(
  193. ((det1['z'] - det2['z']) * self.pixel_size_z)**2 +
  194. ((det1['y'] - det2['y']) * self.pixel_size_xy)**2 +
  195. ((det1['x'] - det2['x']) * self.pixel_size_xy)**2
  196. )
  197. else:
  198. dist = np.sqrt(
  199. ((det1['y'] - det2['y']) * self.pixel_size_xy)**2 +
  200. ((det1['x'] - det2['x']) * self.pixel_size_xy)**2
  201. )
  202. # If distance is non-finite, treat as invalid link
  203. if not np.isfinite(dist):
  204. return np.inf
  205. # Select cost parameters based on algorithm
  206. if use_mcf_costs:
  207. max_disp = self.mcf_max_displacement_um
  208. cost_dist = self.mcf_cost_distance
  209. cost_int = self.mcf_cost_intensity_diff
  210. cost_size = self.mcf_cost_size_diff
  211. switch_penalty = self.mcf_switch_penalty
  212. else:
  213. max_disp = self.max_displacement_um
  214. cost_dist = self.cost_distance
  215. cost_int = self.cost_intensity_diff
  216. cost_size = self.cost_size_diff
  217. switch_penalty = 0.0
  218. # Scale max displacement by sqrt(frame_gap) for multi-frame gaps
  219. # This reflects random walk scaling: displacement ~ sqrt(time)
  220. max_disp_scaled = max_disp * np.sqrt(frame_gap)
  221. if dist > max_disp_scaled:
  222. return np.inf
  223. # Distance cost
  224. # Greedy/Hungarian (Jaqaman-style): Gaussian negative log-likelihood with variance scaling ~ frame_gap
  225. # cost = (d^2) / (2*sigma^2), where sigma^2 = sigma_1frame^2 * frame_gap
  226. # MCF default: keep legacy linear distance cost unless you explicitly change it.
  227. distance_cost = 0.0
  228. if not use_mcf_costs:
  229. sigma_1 = getattr(self, 'greedy_link_sigma_um', None)
  230. if sigma_1 is None:
  231. # Interpret the max displacement gate as ~3-sigma by default
  232. sigma_1 = max_disp / 3.0 if max_disp > 0 else 1.0
  233. sigma2 = (sigma_1 * sigma_1) * float(frame_gap)
  234. # Avoid division by zero
  235. if sigma2 <= 0:
  236. return np.inf
  237. distance_cost = (dist * dist) / (2.0 * sigma2)
  238. cost = distance_cost * cost_dist
  239. else:
  240. if getattr(self, 'mcf_use_gaussian_link_cost', False):
  241. # MCF (optional): Gaussian negative log-likelihood with variance scaling ~ frame_gap
  242. # cost = (d^2) / (2*sigma^2), where sigma^2 = sigma_1frame^2 * frame_gap
  243. sigma_1 = getattr(self, 'mcf_link_sigma_um', None)
  244. if sigma_1 is None:
  245. # Interpret the max displacement gate as ~3-sigma by default
  246. sigma_1 = max_disp / 3.0 if max_disp > 0 else 1.0
  247. sigma2 = (sigma_1 * sigma_1) * float(frame_gap)
  248. if sigma2 <= 0:
  249. return np.inf
  250. distance_cost = (dist * dist) / (2.0 * sigma2)
  251. else:
  252. # Legacy linear scaling for min-cost flow (backwards compatible)
  253. distance_cost = dist * float(frame_gap)
  254. cost = distance_cost * cost_dist
  255. # Intensity difference cost
  256. intensity_cost = 0.0
  257. if 'signal_mean' in det1 and 'signal_mean' in det2:
  258. int1 = det1.get('signal_mean', 0)
  259. int2 = det2.get('signal_mean', 0)
  260. if int1 > 0 and int2 > 0:
  261. int_diff = abs(int1 - int2) / max(int1, int2)
  262. intensity_cost = int_diff * cost_int
  263. cost += intensity_cost
  264. # Size/area difference cost
  265. # Prefer physical size, fallback to pixel size, then sigma
  266. size_cost = 0.0
  267. if 'region_area_um2' in det1 and 'region_area_um2' in det2:
  268. area1 = det1.get('region_area_um2', 0)
  269. area2 = det2.get('region_area_um2', 0)
  270. if area1 > 0 and area2 > 0:
  271. area_diff = abs(area1 - area2) / max(area1, area2)
  272. size_cost = area_diff * cost_size
  273. cost += size_cost
  274. elif 'region_volume_um3' in det1 and 'region_volume_um3' in det2:
  275. vol1 = det1.get('region_volume_um3', 0)
  276. vol2 = det2.get('region_volume_um3', 0)
  277. if vol1 > 0 and vol2 > 0:
  278. vol_diff = abs(vol1 - vol2) / max(vol1, vol2)
  279. size_cost = vol_diff * cost_size
  280. cost += size_cost
  281. elif 'region_size_px' in det1 and 'region_size_px' in det2:
  282. area1 = det1.get('region_size_px', 0)
  283. area2 = det2.get('region_size_px', 0)
  284. if area1 > 0 and area2 > 0:
  285. area_diff = abs(area1 - area2) / max(area1, area2)
  286. size_cost = area_diff * cost_size
  287. cost += size_cost
  288. elif 'sigma' in det1 and 'sigma' in det2:
  289. s1 = det1.get('sigma', 1)
  290. s2 = det2.get('sigma', 1)
  291. if s1 > 0 and s2 > 0:
  292. size_diff = abs(s1 - s2) / max(s1, s2)
  293. size_cost = size_diff * cost_size
  294. cost += size_cost
  295. # Amplify cost based on ambiguity: more link candidates = more ambiguity = higher cost
  296. # This directly addresses identity switching in dense synapse fields
  297. ambig_col = f'ambiguity_gap{frame_gap}'
  298. ambig_factor = det1.get(ambig_col, 1.0)
  299. if ambig_factor < 0 or not np.isfinite(ambig_factor):
  300. ambig_factor = 1.0
  301. cost *= ambig_factor
  302. # Constant penalty to discourage switching between unrelated puncta
  303. cost += switch_penalty
  304. # Motion smoothness penalty: penalize sudden changes in velocity (acceleration)
  305. # Applies to both greedy (Hungarian) and MCF paths
  306. smoothness_penalty = 0.0
  307. smoothness_weight = self.mcf_motion_smoothness_weight if use_mcf_costs else getattr(self, 'greedy_motion_smoothness_weight', 0.0)
  308. if prev_displacement is not None and smoothness_weight and smoothness_weight > 0:
  309. # Current displacement vector (µm)
  310. if use_z:
  311. curr_displacement = np.array([
  312. (det2['x'] - det1['x']) * self.pixel_size_xy,
  313. (det2['y'] - det1['y']) * self.pixel_size_xy,
  314. (det2['z'] - det1['z']) * self.pixel_size_z
  315. ])
  316. else:
  317. curr_displacement = np.array([
  318. (det2['x'] - det1['x']) * self.pixel_size_xy,
  319. (det2['y'] - det1['y']) * self.pixel_size_xy
  320. ])
  321. # Compute change in velocity (acceleration vector)
  322. # For multi-frame gaps, normalize by frame_gap to get velocity change per frame
  323. acceleration = (curr_displacement - prev_displacement) / frame_gap
  324. acceleration_magnitude = np.linalg.norm(acceleration)
  325. # Penalty proportional to acceleration magnitude
  326. # Higher acceleration = less smooth motion = higher cost
  327. smoothness_penalty = smoothness_weight * acceleration_magnitude
  328. cost += smoothness_penalty
  329. if return_breakdown:
  330. # Return cost along with breakdown of components
  331. breakdown = {
  332. 'distance': dist,
  333. 'distance_cost': float(distance_cost) * float(cost_dist),
  334. 'intensity_cost': intensity_cost,
  335. 'size_cost': size_cost,
  336. 'total_cost': cost,
  337. 'ambiguity_factor': ambig_factor,
  338. 'smoothness_penalty': smoothness_penalty,
  339. 'switch_penalty': switch_penalty,
  340. 'max_disp_scaled': max_disp_scaled
  341. }
  342. return cost, breakdown
  343. return cost
  344. def _add_displacement_from_greedy_tracks(
  345. self,
  346. detections_by_frame: Dict[int, pd.DataFrame],
  347. greedy_tracks: pd.DataFrame,
  348. is_3d: bool
  349. ) -> Dict[int, pd.DataFrame]:
  350. """
  351. Add previous displacement vectors to detections based on greedy tracking results.
  352. This enables motion smoothness penalties in MCF by providing the expected
  353. velocity from Stage 1 greedy tracking.
  354. Notes
  355. -----
  356. We key greedy-track rows back to detections using (frame, det_id). If det_id is
  357. missing in greedy_tracks (older outputs), we fall back to using the greedy_tracks
  358. row index as det_id (best-effort), but the preferred/accurate path is to include
  359. det_id in greedy outputs.
  360. """
  361. if greedy_tracks is None or len(greedy_tracks) == 0:
  362. return detections_by_frame
  363. gt = greedy_tracks.copy()
  364. # Ensure we have a stable per-detection id
  365. if 'det_id' not in gt.columns:
  366. # Best-effort fallback: use row index
  367. gt = gt.reset_index(drop=False).rename(columns={'index': 'det_id'})
  368. # Compute per-row displacement from previous frame within the same greedy track
  369. gt = gt.sort_values(['track_id', 'frame']).reset_index(drop=True)
  370. gt['prev_frame'] = gt.groupby('track_id')['frame'].shift(1)
  371. gt['prev_x'] = gt.groupby('track_id')['x'].shift(1)
  372. gt['prev_y'] = gt.groupby('track_id')['y'].shift(1)
  373. if is_3d and self.pixel_size_z is not None and 'z' in gt.columns:
  374. gt['prev_z'] = gt.groupby('track_id')['z'].shift(1)
  375. valid_prev = gt['prev_frame'].notna() & (gt['prev_frame'] == (gt['frame'] - 1))
  376. gt['prev_disp_x_um'] = np.nan
  377. gt['prev_disp_y_um'] = np.nan
  378. if is_3d and self.pixel_size_z is not None and 'z' in gt.columns:
  379. gt['prev_disp_z_um'] = np.nan
  380. gt.loc[valid_prev, 'prev_disp_x_um'] = (gt.loc[valid_prev, 'x'] - gt.loc[valid_prev, 'prev_x']) * self.pixel_size_xy
  381. gt.loc[valid_prev, 'prev_disp_y_um'] = (gt.loc[valid_prev, 'y'] - gt.loc[valid_prev, 'prev_y']) * self.pixel_size_xy
  382. if is_3d and self.pixel_size_z is not None and 'z' in gt.columns:
  383. # Only assign Z displacement when both z and prev_z are finite
  384. z_ok = valid_prev & gt['z'].notna() & gt['prev_z'].notna()
  385. gt.loc[z_ok, 'prev_disp_z_um'] = (gt.loc[z_ok, 'z'] - gt.loc[z_ok, 'prev_z']) * self.pixel_size_z
  386. disp_cols = ['frame', 'det_id', 'prev_disp_x_um', 'prev_disp_y_um']
  387. if is_3d and self.pixel_size_z is not None and 'prev_disp_z_um' in gt.columns:
  388. disp_cols.append('prev_disp_z_um')
  389. disp = gt[disp_cols].copy()
  390. # Apply to detections_by_frame
  391. result: Dict[int, pd.DataFrame] = {}
  392. for frame, df in detections_by_frame.items():
  393. df_copy = df.copy()
  394. # Make sure detections have det_id (use original df index if absent)
  395. if 'det_id' not in df_copy.columns:
  396. df_copy['det_id'] = df_copy.index.astype(int)
  397. # Initialize columns
  398. df_copy['prev_disp_x_um'] = np.nan
  399. df_copy['prev_disp_y_um'] = np.nan
  400. if is_3d and self.pixel_size_z is not None:
  401. df_copy['prev_disp_z_um'] = np.nan
  402. dframe = disp[disp['frame'] == frame].drop(columns=['frame'], errors='ignore')
  403. if not dframe.empty:
  404. df_copy = df_copy.merge(dframe, on=['det_id'], how='left', suffixes=('', '_gt'))
  405. # Prefer merged values (from greedy) where available
  406. df_copy['prev_disp_x_um'] = df_copy['prev_disp_x_um_gt'].combine_first(df_copy['prev_disp_x_um'])
  407. df_copy['prev_disp_y_um'] = df_copy['prev_disp_y_um_gt'].combine_first(df_copy['prev_disp_y_um'])
  408. if is_3d and self.pixel_size_z is not None and 'prev_disp_z_um_gt' in df_copy.columns:
  409. df_copy['prev_disp_z_um'] = df_copy['prev_disp_z_um_gt'].combine_first(df_copy.get('prev_disp_z_um'))
  410. # Drop temp columns
  411. drop_cols = [c for c in df_copy.columns if c.endswith('_gt')]
  412. df_copy = df_copy.drop(columns=drop_cols)
  413. # Keep the original index ordering
  414. result[frame] = df_copy
  415. return result
  416. def _add_ambiguity_factors(
  417. self,
  418. detections_by_frame: Dict[int, pd.DataFrame],
  419. is_3d: bool,
  420. max_gap: int
  421. ) -> Dict[int, pd.DataFrame]:
  422. """
  423. Compute per-detection ambiguity factors based on number of link candidates.
  424. For each detection at frame t, counts how many potential link targets exist
  425. at frame t+dt within max_disp_scaled = mcf_max_displacement_um * sqrt(dt).
  426. This directly addresses identity switching: more candidates = more ambiguity = higher cost.
  427. Parameters
  428. ----------
  429. detections_by_frame : dict
  430. Detections by frame
  431. is_3d : bool
  432. Whether 3D data
  433. max_gap : int
  434. Maximum gap to consider (max_gap_frames + 1)
  435. Returns
  436. -------
  437. dict
  438. Detections with ambiguity_gap{dt} columns added
  439. """
  440. if not SCIPY_AVAILABLE:
  441. return detections_by_frame
  442. max_disp = self.mcf_max_displacement_um if self.mcf_max_displacement_um is not None else self.max_displacement_um
  443. if max_disp <= 0:
  444. return detections_by_frame
  445. frames = sorted(detections_by_frame.keys())
  446. result = {}
  447. for frame in frames:
  448. df = detections_by_frame[frame]
  449. if len(df) == 0:
  450. result[frame] = df
  451. continue
  452. df_copy = df.copy()
  453. # Get coords for current frame
  454. if is_3d and self.pixel_size_z is not None and 'z' in df.columns:
  455. curr_coords = np.stack([
  456. df['x'].to_numpy() * self.pixel_size_xy,
  457. df['y'].to_numpy() * self.pixel_size_xy,
  458. df['z'].to_numpy() * self.pixel_size_z
  459. ], axis=1)
  460. else:
  461. curr_coords = np.stack([
  462. df['x'].to_numpy() * self.pixel_size_xy,
  463. df['y'].to_numpy() * self.pixel_size_xy
  464. ], axis=1)
  465. # For each gap (1 to max_gap), count candidates at next_frame
  466. for gap in range(1, max_gap + 1):
  467. next_frame = frame + gap
  468. if next_frame not in detections_by_frame:
  469. # No next frame, set ambiguity to 0
  470. df_copy[f'ambiguity_gap{gap}'] = 0.0
  471. continue
  472. df_next = detections_by_frame[next_frame]
  473. if len(df_next) == 0:
  474. df_copy[f'ambiguity_gap{gap}'] = 0.0
  475. continue
  476. # Get coords for next frame
  477. if is_3d and self.pixel_size_z is not None and 'z' in df_next.columns:
  478. next_coords = np.stack([
  479. df_next['x'].to_numpy() * self.pixel_size_xy,
  480. df_next['y'].to_numpy() * self.pixel_size_xy,
  481. df_next['z'].to_numpy() * self.pixel_size_z
  482. ], axis=1)
  483. else:
  484. next_coords = np.stack([
  485. df_next['x'].to_numpy() * self.pixel_size_xy,
  486. df_next['y'].to_numpy() * self.pixel_size_xy
  487. ], axis=1)
  488. # Build tree for next frame
  489. next_tree = cKDTree(next_coords)
  490. # Scale max displacement by sqrt(gap) for multi-frame gaps
  491. max_disp_scaled = max_disp * np.sqrt(gap)
  492. # Count candidates for each detection in current frame
  493. candidate_counts = []
  494. for coord in curr_coords:
  495. candidates = next_tree.query_ball_point(coord, r=max_disp_scaled)
  496. candidate_counts.append(len(candidates))
  497. candidate_counts = np.array(candidate_counts, dtype=float)
  498. # Ambiguity factor: 1 + multiplier * min(max(candidates-1, 0), 10)
  499. # candidates-1 because 1 candidate = no ambiguity
  500. ambiguity = np.maximum(candidate_counts - 1, 0)
  501. ambig_factor = 1.0 + self.mcf_crowding_multiplier * np.minimum(ambiguity, 10.0)
  502. df_copy[f'ambiguity_gap{gap}'] = ambig_factor
  503. result[frame] = df_copy
  504. return result
  505. def track(
  506. self,
  507. detections_by_frame: Dict[int, pd.DataFrame],
  508. is_3d: bool = False,
  509. verbose: bool = False,
  510. use_min_cost_flow: bool = False,
  511. mcf_crowding_multiplier: Optional[float] = None,
  512. mcf_max_neighbors: Optional[int] = None,
  513. mcf_slack_cost_component: Optional[float] = None,
  514. output_dir: Optional[Path] = None,
  515. track_type: str = "",
  516. type_id: int = 0,
  517. debug_images: bool = False,
  518. precomputed_displacements: Optional[Dict[int, pd.DataFrame]] = None,
  519. debug_dir: Optional[Path] = None
  520. ) -> pd.DataFrame:
  521. """
  522. Track detections across frames.
  523. When use_min_cost_flow=True:
  524. 1. Extract frame-pair displacements
  525. 2. Run min-cost flow on all detections with motion-aware costs
  526. When use_min_cost_flow=False:
  527. 1. Extract frame-pair displacements
  528. 2. Run greedy tracking with Hungarian assignment and motion-aware costs
  529. Parameters
  530. ----------
  531. detections_by_frame : dict
  532. Mapping from frame number to DataFrame of detections
  533. is_3d : bool
  534. Whether data is 3D
  535. verbose : bool
  536. Print progress information
  537. use_min_cost_flow : bool
  538. Use min-cost flow optimization (with quality bonus filtering)
  539. output_dir : Path or None
  540. Directory to save track statistics CSV (for debugging)
  541. debug_dir : Path or None
  542. Override directory for tracking debug images
  543. Returns
  544. -------
  545. tracks : DataFrame
  546. Tracking results with track_id assigned
  547. """
  548. frames = sorted(detections_by_frame.keys())
  549. if len(frames) == 0:
  550. return pd.DataFrame()
  551. debug_output_dir = debug_dir if debug_dir is not None else output_dir
  552. has_debug_output = debug_output_dir is not None
  553. if use_min_cost_flow:
  554. if verbose:
  555. print(" [MCF] Using min-cost flow tracking")
  556. # Check if we need motion calculations (motion smoothness)
  557. motion_smoothness_enabled = (
  558. self.mcf_motion_smoothness_weight is not None and self.mcf_motion_smoothness_weight != 0
  559. )
  560. need_motion = motion_smoothness_enabled
  561. if need_motion:
  562. # Use pre-computed displacements if provided, otherwise compute them
  563. if precomputed_displacements is not None:
  564. if verbose:
  565. print(f" [MCF] Using pre-computed displacement vectors")
  566. detections_with_displacement = precomputed_displacements
  567. else:
  568. if verbose:
  569. print(f" [MCF] Stage 1: Frame-pair extraction for motion estimation")
  570. detections_with_displacement = self._extract_displacement_parallel(
  571. detections_by_frame,
  572. is_3d,
  573. verbose=verbose
  574. )
  575. # Add ambiguity factors to penalize high-ambiguity links (discourage identity switches)
  576. detections_with_ambiguity = self._add_ambiguity_factors(
  577. detections_with_displacement, is_3d, max_gap=self.mcf_max_gap_frames + 1
  578. )
  579. if verbose:
  580. total_dets = sum(len(df) for df in detections_with_ambiguity.values())
  581. print(f" [MCF] Stage 2: Running min-cost flow on all {total_dets} detections")
  582. print(f" [MCF] Using mcf_max_gap_frames={self.mcf_max_gap_frames} for gap closing")
  583. print(f" [MCF] Quality bonus will filter low-confidence detections")
  584. else:
  585. if verbose:
  586. print(f" [MCF] Skipping Stage 1 (motion calculations disabled)")
  587. print(f" [MCF] mcf_motion_smoothness_weight={self.mcf_motion_smoothness_weight}")
  588. # Skip Stage 1 entirely - only add ambiguity factors for crowding penalty
  589. detections_with_ambiguity = self._add_ambiguity_factors(
  590. detections_by_frame, is_3d, max_gap=self.mcf_max_gap_frames + 1
  591. )
  592. if verbose:
  593. total_dets = sum(len(df) for df in detections_with_ambiguity.values())
  594. print(f" [MCF] Running min-cost flow on all {total_dets} detections")
  595. print(f" [MCF] Using mcf_max_gap_frames={self.mcf_max_gap_frames} for gap closing")
  596. print(f" [MCF] Quality bonus will filter low-confidence detections")
  597. # Stage 2: Run min-cost flow on ALL detections
  598. mcf_tracks = self._min_cost_flow_track(
  599. detections_with_ambiguity,
  600. is_3d,
  601. verbose=verbose,
  602. mcf_max_neighbors=mcf_max_neighbors,
  603. mcf_slack_cost_component=mcf_slack_cost_component,
  604. output_dir=output_dir,
  605. debug_dir=debug_dir,
  606. track_type=track_type,
  607. type_id=type_id,
  608. debug_images=debug_images
  609. )
  610. if len(mcf_tracks) > 0:
  611. n_mcf_tracks = mcf_tracks['track_id'].nunique()
  612. if verbose:
  613. mcf_lengths = mcf_tracks.groupby('track_id').size()
  614. print(f" [MCF] Output: {n_mcf_tracks} tracks, lengths: min={mcf_lengths.min()}, mean={mcf_lengths.mean():.1f}, max={mcf_lengths.max()}")
  615. else:
  616. print(f" [MCF] WARNING: Min-cost flow returned NO tracks!")
  617. print(f" [MCF] Falling back to greedy tracking (full 2-pass)...")
  618. # Run full greedy tracking (motion_only=False to get actual tracks)
  619. return self._greedy_track(
  620. detections_by_frame,
  621. is_3d,
  622. verbose=verbose,
  623. motion_only=False,
  624. output_dir=output_dir,
  625. track_type=track_type,
  626. type_id=type_id,
  627. debug_images=debug_images
  628. )
  629. return mcf_tracks
  630. else:
  631. if verbose:
  632. print(" Using greedy tracking (fast, locally optimal)")
  633. return self._greedy_track(
  634. detections_by_frame,
  635. is_3d,
  636. verbose=verbose,
  637. motion_only=False,
  638. output_dir=output_dir,
  639. debug_dir=debug_dir,
  640. track_type=track_type,
  641. type_id=type_id,
  642. debug_images=debug_images
  643. )
  644. def _greedy_track(
  645. self,
  646. detections_by_frame: Dict[int, pd.DataFrame],
  647. is_3d: bool,
  648. verbose: bool = False,
  649. motion_only: bool = False,
  650. output_dir: Optional[Path] = None,
  651. debug_dir: Optional[Path] = None,
  652. track_type: str = "",
  653. type_id: int = 0,
  654. debug_images: bool = False
  655. ) -> pd.DataFrame:
  656. """
  657. Simple greedy tracking with gap closing.
  658. Two-stage flow (used for both motion-only pass and full greedy tracking):
  659. 1. Stage 1: Batch frame-pair displacement extraction.
  660. 2. Stage 2: Hungarian assignment with optional smoothness penalty.
  661. Parameters
  662. ----------
  663. motion_only : bool
  664. If True, only compute motion vectors (Pass 1) and return empty DataFrame.
  665. Used when MCF will do the actual tracking.
  666. """
  667. frames = sorted(detections_by_frame.keys())
  668. if len(frames) == 0:
  669. return pd.DataFrame()
  670. # Stage 1: displacement extraction (if motion smoothness enabled)
  671. need_motion = getattr(self, 'greedy_motion_smoothness_weight', 0.0) > 0
  672. detections_stage1 = detections_by_frame
  673. if need_motion:
  674. if verbose:
  675. print(f" [GREEDY] Stage 1: Extracting displacements")
  676. detections_stage1 = self._extract_displacement_parallel(
  677. detections_by_frame,
  678. is_3d,
  679. verbose=verbose
  680. )
  681. if motion_only:
  682. return pd.DataFrame()
  683. # Stage 2: Hungarian assignment with motion-aware costs
  684. try:
  685. from scipy.optimize import linear_sum_assignment # noqa: F401
  686. return self._greedy_track_hungarian(
  687. detections_stage1,
  688. is_3d,
  689. verbose=verbose,
  690. output_dir=output_dir,
  691. debug_dir=debug_dir,
  692. track_type=track_type,
  693. type_id=type_id,
  694. debug_images=debug_images
  695. )
  696. except Exception as e:
  697. if verbose and not getattr(self, "_hungarian_warned", False):
  698. print(f" [GREEDY] Warning: Hungarian assignment unavailable ({e}); falling back to simple greedy pass")
  699. print(f" (If this repeats, try increasing greedy_max_displacement_um or reducing smoothness)")
  700. self._hungarian_warned = True
  701. return self._greedy_track_single_pass(detections_by_frame, is_3d, verbose)
  702. def _greedy_track_hungarian(
  703. self,
  704. detections_by_frame: Dict[int, pd.DataFrame],
  705. is_3d: bool,
  706. verbose: bool = False,
  707. output_dir: Optional[Path] = None,
  708. debug_dir: Optional[Path] = None,
  709. track_type: str = "",
  710. type_id: int = 0,
  711. debug_images: bool = False
  712. ) -> pd.DataFrame:
  713. """
  714. Jaqaman-style 2-pass greedy tracking:
  715. Pass 1: frame-to-frame LAP (Hungarian), ONLY frame_gap==1 -> produces tracklets
  716. Pass 2: post hoc gap closing LAP between tracklet ends and starts
  717. """
  718. try:
  719. from scipy.optimize import linear_sum_assignment
  720. except ImportError:
  721. raise
  722. frames = sorted(detections_by_frame.keys())
  723. if not frames:
  724. return pd.DataFrame()
  725. debug_output_dir = debug_dir if debug_dir is not None else output_dir
  726. has_debug_output = debug_output_dir is not None
  727. all_results: List[dict] = []
  728. next_track_id = 0
  729. # Active track heads for frame-to-frame linking ONLY
  730. # list of (track_id, last_frame, last_det_series)
  731. active_tracks: List[Tuple[int, int, pd.Series]] = []
  732. # Debug bookkeeping (kept consistent with your current interface)
  733. start_detections: List[Tuple[int, int]] = []
  734. link_distances: List[float] = []
  735. death_to_nextframe_dists: List[float] = []
  736. track_costs: Dict[int, float] = {}
  737. cost_breakdowns: List[dict] = []
  738. ambiguity_factors: List[float] = []
  739. smoothness_penalties: List[float] = []
  740. BIG_M = 1e9
  741. # Setup progress bar if verbose
  742. if verbose and TQDM_AVAILABLE:
  743. frame_iter = tqdm(frames, desc=" [GREEDY-P1] Frame-to-frame linking", unit="frame")
  744. else:
  745. frame_iter = frames
  746. for frame in frame_iter:
  747. df = detections_by_frame[frame]
  748. if len(df) == 0:
  749. # In tracklet mode, if a frame has no detections, all active tracks terminate.
  750. active_tracks = []
  751. continue
  752. # Preserve original detection id for later mapping (e.g., displacement extraction)
  753. if 'det_id' not in df.columns:
  754. df = df.reset_index(drop=False).rename(columns={'index': 'det_id'})
  755. else:
  756. df = df.reset_index(drop=True)
  757. # If no active tracks, every detection starts a new tracklet
  758. if not active_tracks:
  759. for det_idx, det in df.iterrows():
  760. tid = next_track_id
  761. next_track_id += 1
  762. rec = det.to_dict()
  763. rec["track_id"] = tid
  764. rec["frame"] = frame
  765. all_results.append(rec)
  766. active_tracks.append((tid, frame, det))
  767. start_detections.append((frame, det_idx))
  768. continue
  769. num_tracks = len(active_tracks)
  770. num_dets = len(df)
  771. # Cost matrix tracks x detections (only for frame_gap==1)
  772. cost_matrix = np.full((num_tracks, num_dets), np.inf, dtype=float)
  773. # Build k-NN structure over active tracks from previous frame
  774. k_neighbors = self.greedy_max_neighbors
  775. use_knn = SCIPY_AVAILABLE and k_neighbors is not None and k_neighbors > 0
  776. track_coords = []
  777. track_indices = []
  778. if use_knn:
  779. for t_idx, (_, last_frame, last_det) in enumerate(active_tracks):
  780. if frame - last_frame != 1:
  781. continue
  782. if not (np.isfinite(last_det.get("x")) and np.isfinite(last_det.get("y"))):
  783. continue
  784. if (
  785. is_3d and self.pixel_size_z is not None
  786. and "z" in last_det.index and np.isfinite(last_det.get("z"))
  787. ):
  788. coord = [
  789. last_det["x"] * self.pixel_size_xy,
  790. last_det["y"] * self.pixel_size_xy,
  791. last_det["z"] * self.pixel_size_z
  792. ]
  793. else:
  794. coord = [
  795. last_det["x"] * self.pixel_size_xy,
  796. last_det["y"] * self.pixel_size_xy
  797. ]
  798. track_coords.append(coord)
  799. track_indices.append(t_idx)
  800. if not track_coords:
  801. use_knn = False
  802. else:
  803. tree = cKDTree(np.array(track_coords))
  804. # Fill costs: compute candidates per detection
  805. for det_idx, det in df.iterrows():
  806. if not (np.isfinite(det.get("x")) and np.isfinite(det.get("y"))):
  807. continue
  808. # Candidate tracks (all or k nearest)
  809. if k_neighbors is None or k_neighbors == 0:
  810. candidate_order = range(num_tracks)
  811. elif use_knn:
  812. if (
  813. is_3d and self.pixel_size_z is not None
  814. and "z" in det.index and np.isfinite(det.get("z"))
  815. ):
  816. det_coord = [
  817. det["x"] * self.pixel_size_xy,
  818. det["y"] * self.pixel_size_xy,
  819. det["z"] * self.pixel_size_z
  820. ]
  821. else:
  822. det_coord = [
  823. det["x"] * self.pixel_size_xy,
  824. det["y"] * self.pixel_size_xy
  825. ]
  826. k = min(int(k_neighbors), len(track_coords))
  827. dists, idxs = tree.query(det_coord, k=k)
  828. if np.isscalar(idxs):
  829. idxs = [int(idxs)]
  830. else:
  831. idxs = [int(i) for i in np.atleast_1d(idxs)]
  832. candidate_order = [track_indices[i] for i in idxs]
  833. else:
  834. dists = []
  835. for t_idx, (_, last_frame, last_det) in enumerate(active_tracks):
  836. if frame - last_frame != 1:
  837. continue
  838. if not (np.isfinite(last_det.get("x")) and np.isfinite(last_det.get("y"))):
  839. dist_val = np.inf
  840. else:
  841. if (
  842. is_3d and self.pixel_size_z is not None
  843. and "z" in det.index and "z" in last_det.index
  844. and np.isfinite(det.get("z")) and np.isfinite(last_det.get("z"))
  845. ):
  846. dx = (det["x"] - last_det["x"]) * self.pixel_size_xy
  847. dy = (det["y"] - last_det["y"]) * self.pixel_size_xy
  848. dz = (det["z"] - last_det["z"]) * self.pixel_size_z
  849. dist_val = float(np.sqrt(dx * dx + dy * dy + dz * dz))
  850. else:
  851. dx = (det["x"] - last_det["x"]) * self.pixel_size_xy
  852. dy = (det["y"] - last_det["y"]) * self.pixel_size_xy
  853. dist_val = float(np.sqrt(dx * dx + dy * dy))
  854. dists.append((dist_val, t_idx))
  855. dists.sort(key=lambda x: x[0])
  856. k = max(int(k_neighbors), 0)
  857. candidate_order = [idx for _, idx in dists[:k]] if k > 0 else []
  858. for t_idx in candidate_order:
  859. track_id, last_frame, last_det = active_tracks[t_idx]
  860. # Enforce frame-to-frame linking only
  861. if frame - last_frame != 1:
  862. continue
  863. prev_disp = None
  864. if "prev_disp_x_um" in last_det.index and pd.notna(last_det.get("prev_disp_x_um")):
  865. if is_3d and self.pixel_size_z is not None and "prev_disp_z_um" in last_det.index:
  866. prev_disp = np.array([
  867. last_det["prev_disp_x_um"],
  868. last_det["prev_disp_y_um"],
  869. last_det["prev_disp_z_um"],
  870. ])
  871. else:
  872. prev_disp = np.array([
  873. last_det["prev_disp_x_um"],
  874. last_det["prev_disp_y_um"],
  875. ])
  876. base_cost = self._compute_link_cost(
  877. last_det, det, is_3d, frame_gap=1,
  878. use_mcf_costs=False,
  879. prev_displacement=prev_disp,
  880. return_breakdown=bool(debug_images)
  881. )
  882. if isinstance(base_cost, tuple):
  883. cost_val, breakdown = base_cost
  884. else:
  885. cost_val, breakdown = base_cost, None
  886. if breakdown is not None:
  887. bd = breakdown.copy()
  888. bd['total_cost'] = cost_val
  889. cost_breakdowns.append(bd)
  890. if 'ambiguity_factor' in bd and np.isfinite(bd['ambiguity_factor']):
  891. ambiguity_factors.append(float(bd['ambiguity_factor']))
  892. if bd.get('smoothness_penalty', 0) > 0:
  893. smoothness_penalties.append(float(bd['smoothness_penalty']))
  894. cost = cost_val
  895. if np.isfinite(cost):
  896. cost_matrix[t_idx, det_idx] = cost
  897. # --- Pass 1 Hungarian with explicit birth/death costs (square assignment) ---
  898. # base is cost_matrix (T x D) with BIG_M where invalid
  899. base = cost_matrix.copy()
  900. base[~np.isfinite(base)] = BIG_M
  901. T, D = base.shape
  902. # Square matrix:
  903. # rows: 0..T-1 are tracks, T..T+D-1 are "birth rows" (one per detection)
  904. # cols: 0..D-1 are detections, D..D+T-1 are "termination cols" (one per track)
  905. M = np.full((T + D, D + T), BIG_M, dtype=float)
  906. # Track -> detection link costs
  907. M[:T, :D] = base
  908. # Track -> terminate (death) costs on diagonal
  909. # Each track row can choose its own termination column at cost death_cost
  910. term_block = M[:T, D:D+T]
  911. np.fill_diagonal(term_block, float(self.death_cost))
  912. # Birth -> detection costs on diagonal
  913. # Each detection can be "born" by its own birth row at cost birth_cost
  914. birth_block = M[T:T+D, :D]
  915. np.fill_diagonal(birth_block, float(self.birth_cost))
  916. # Birth rows -> termination cols are dummy-to-dummy; set 0 so unused births/terms don't matter
  917. M[T:T+D, D:D+T] = 0.0
  918. row_ind, col_ind = linear_sum_assignment(M)
  919. matched_dets = set()
  920. birth_dets = set()
  921. new_active_tracks = []
  922. # Apply assignments
  923. for r, c in zip(row_ind, col_ind):
  924. # Track rows
  925. if r < T:
  926. # Track -> detection (extend tracklet)
  927. if c < D and M[r, c] < BIG_M * 0.5:
  928. track_id, last_frame, prev_det = active_tracks[r]
  929. det = df.iloc[c]
  930. rec = det.to_dict()
  931. rec["track_id"] = track_id
  932. rec["frame"] = frame
  933. all_results.append(rec)
  934. new_active_tracks.append((track_id, frame, det))
  935. matched_dets.add(int(c))
  936. # Debug link distance bookkeeping (unchanged from your code)
  937. if (
  938. np.isfinite(det.get("x")) and np.isfinite(det.get("y"))
  939. and np.isfinite(prev_det.get("x")) and np.isfinite(prev_det.get("y"))
  940. ):
  941. if (
  942. is_3d and self.pixel_size_z is not None
  943. and "z" in det.index and "z" in prev_det.index
  944. and np.isfinite(det.get("z")) and np.isfinite(prev_det.get("z"))
  945. ):
  946. dx = (det["x"] - prev_det["x"]) * self.pixel_size_xy
  947. dy = (det["y"] - prev_det["y"]) * self.pixel_size_xy
  948. dz = (det["z"] - prev_det["z"]) * self.pixel_size_z
  949. link_distances.append(float(np.sqrt(dx * dx + dy * dy + dz * dz)))
  950. else:
  951. dx = (det["x"] - prev_det["x"]) * self.pixel_size_xy
  952. dy = (det["y"] - prev_det["y"]) * self.pixel_size_xy
  953. link_distances.append(float(np.sqrt(dx * dx + dy * dy)))
  954. # else: track terminated (death) -> do not carry forward
  955. # Birth rows
  956. else:
  957. # Birth row for detection j is row r = T + j, and only diag to col j is finite
  958. j = int(r - T)
  959. if c == j and c < D and M[r, c] < BIG_M * 0.5:
  960. birth_dets.add(j)
  961. # Create new tracks for detections chosen as births
  962. for det_idx in sorted(birth_dets):
  963. det = df.iloc[det_idx]
  964. tid = next_track_id
  965. next_track_id += 1
  966. rec = det.to_dict()
  967. rec["track_id"] = tid
  968. rec["frame"] = frame
  969. all_results.append(rec)
  970. new_active_tracks.append((tid, frame, det))
  971. start_detections.append((frame, det_idx))
  972. # IMPORTANT: detections assigned to tracks are already handled above.
  973. # Any detection not matched AND not explicitly birthed should not happen here
  974. # (it would imply the solver paid BIG_M), but keep a safety fallback:
  975. for det_idx, det in df.iterrows():
  976. if det_idx in matched_dets or det_idx in birth_dets:
  977. continue
  978. if verbose:
  979. print(f"[GREEDY] WARNING: detection {det_idx} forced into fallback birth (likely BIG_M assignment)")
  980. tid = next_track_id
  981. next_track_id += 1
  982. rec = det.to_dict()
  983. rec["track_id"] = tid
  984. rec["frame"] = frame
  985. all_results.append(rec)
  986. new_active_tracks.append((tid, frame, det))
  987. start_detections.append((frame, det_idx))
  988. active_tracks = new_active_tracks
  989. if not all_results:
  990. return pd.DataFrame()
  991. tracks_df = pd.DataFrame(all_results)
  992. # ---- Pass 2: post hoc gap closing (Jaqaman-style 2nd LAP) ----
  993. tracks_df = self._gap_close_jaqaman_lap(
  994. tracks_df=tracks_df,
  995. is_3d=is_3d,
  996. verbose=verbose
  997. )
  998. # Debug visualizations (greedy path)
  999. if debug_images and has_debug_output:
  1000. try:
  1001. save_debug_visualizations(
  1002. detections_by_frame=detections_by_frame,
  1003. all_results=tracks_df.to_dict("records"),
  1004. link_distances=link_distances,
  1005. start_detections=start_detections,
  1006. death_to_nextframe_dists=death_to_nextframe_dists,
  1007. track_costs=track_costs,
  1008. is_3d=is_3d,
  1009. output_dir=debug_output_dir,
  1010. track_type=track_type,
  1011. type_id=type_id,
  1012. verbose=verbose,
  1013. pixel_size_xy=self.pixel_size_xy,
  1014. max_displacement_um=self.max_displacement_um,
  1015. mcf_max_displacement_um=self.mcf_max_displacement_um,
  1016. mcf_cost_distance=self.mcf_cost_distance,
  1017. mcf_crowding_multiplier=self.mcf_crowding_multiplier,
  1018. cost_breakdowns=cost_breakdowns if cost_breakdowns else None,
  1019. ambiguity_factors=ambiguity_factors if ambiguity_factors else None,
  1020. quality_bonuses=None,
  1021. smoothness_penalties=smoothness_penalties if smoothness_penalties else None,
  1022. dpi=150,
  1023. debug_dir=debug_dir
  1024. )
  1025. except Exception as e:
  1026. print(f" [GREEDY] Warning: could not save debug visualizations: {e}")
  1027. return tracks_df
  1028. def _gap_close_jaqaman_lap(
  1029. self,
  1030. tracks_df: pd.DataFrame,
  1031. is_3d: bool,
  1032. verbose: bool = False
  1033. ) -> pd.DataFrame:
  1034. """
  1035. Pass 2 (Jaqaman-like): gap closing LAP between tracklet ends and starts.
  1036. We:
  1037. - compute each tracklet start (first detection) and end (last detection)
  1038. - allow end->start links for gaps in [2, max_gap_frames+1]
  1039. - cost = _compute_link_cost(end_det, start_det, frame_gap=gap) + optional gap penalty
  1040. - compare against terminate+initiate by using an effective cost:
  1041. eff_cost = link_cost - (death_cost + birth_cost)
  1042. and dummy no-link cost = 0.
  1043. This means we only link when it is cheaper than ending+starting.
  1044. Notes:
  1045. - This implements gap closing only (no merge/split LAP here).
  1046. - It produces one-in/one-out per tracklet -> chains, which we then relabel.
  1047. """
  1048. if tracks_df.empty:
  1049. return tracks_df
  1050. try:
  1051. from scipy.optimize import linear_sum_assignment
  1052. except ImportError:
  1053. return tracks_df
  1054. # If max_gap_frames==0, there is no post hoc gap closing to do.
  1055. max_gap = int(getattr(self, "max_gap_frames", 0)) + 1 # frame_gap allowed: 2..max_gap
  1056. if max_gap < 2:
  1057. return tracks_df
  1058. BIG_M = 1e9
  1059. # Optional per-missed-frame penalty (tuneable; default 0)
  1060. # This discourages long gaps even if distance is small.
  1061. gap_penalty_per_missed_frame = float(getattr(self, "gap_close_penalty_per_missed_frame", 0.0))
  1062. # Sort and get start/end rows per tracklet
  1063. tracks_sorted = tracks_df.sort_values(["track_id", "frame"]).reset_index(drop=True)
  1064. grp = tracks_sorted.groupby("track_id", sort=False)
  1065. starts = grp.head(1).copy()
  1066. ends = grp.tail(1).copy()
  1067. # Map from row index in starts/ends -> track_id
  1068. start_tids = starts["track_id"].to_numpy()
  1069. end_tids = ends["track_id"].to_numpy()
  1070. start_frames = starts["frame"].to_numpy().astype(int)
  1071. end_frames = ends["frame"].to_numpy().astype(int)
  1072. # Build lookup of starts by frame for fast candidate search
  1073. starts_by_frame: Dict[int, List[int]] = {}
  1074. for j, f in enumerate(start_frames):
  1075. starts_by_frame.setdefault(int(f), []).append(j)
  1076. n_ends = len(ends)
  1077. n_starts = len(starts)
  1078. if n_ends == 0 or n_starts == 0:
  1079. return tracks_df
  1080. baseline = float(self.birth_cost) + float(self.death_cost)
  1081. # Extract NumPy arrays before loop for vectorized operations
  1082. ends_x = ends['x'].to_numpy()
  1083. ends_y = ends['y'].to_numpy()
  1084. ends_frame = ends['frame'].to_numpy().astype(int)
  1085. starts_x = starts['x'].to_numpy()
  1086. starts_y = starts['y'].to_numpy()
  1087. # Handle 3D coordinates
  1088. use_z = is_3d and self.pixel_size_z is not None
  1089. if use_z and 'z' in ends.columns and 'z' in starts.columns:
  1090. ends_z = ends['z'].to_numpy()
  1091. starts_z = starts['z'].to_numpy()
  1092. else:
  1093. ends_z = None
  1094. starts_z = None
  1095. use_z = False
  1096. # Optional properties
  1097. has_signal = 'signal_mean' in ends.columns and 'signal_mean' in starts.columns
  1098. has_size = 'region_size_px' in ends.columns and 'region_size_px' in starts.columns
  1099. if has_signal:
  1100. ends_signal = ends['signal_mean'].fillna(0).to_numpy()
  1101. starts_signal = starts['signal_mean'].fillna(0).to_numpy()
  1102. if has_size:
  1103. ends_size = ends['region_size_px'].fillna(0).to_numpy()
  1104. starts_size = starts['region_size_px'].fillna(0).to_numpy()
  1105. # Cost parameters (greedy, not MCF)
  1106. max_disp = self.max_displacement_um
  1107. cost_dist = self.cost_distance
  1108. cost_int = self.cost_intensity_diff
  1109. cost_size = self.cost_size_diff
  1110. # Optional k-NN pruning for gap closing (defaults to greedy_max_neighbors)
  1111. gap_knn = getattr(self, "gap_close_max_neighbors", getattr(self, "greedy_max_neighbors", None))
  1112. # Collect sparse candidate costs to avoid huge dense matrices
  1113. candidates: List[Tuple[int, int, float]] = []
  1114. used_rows: set = set()
  1115. used_cols: set = set()
  1116. # Setup progress bar for gap closing
  1117. if verbose and TQDM_AVAILABLE:
  1118. end_iter = tqdm(range(n_ends), desc=" [GREEDY-P2] Gap closing", unit="end")
  1119. else:
  1120. end_iter = range(n_ends)
  1121. # Fill candidate costs with gating (vectorized inner loop)
  1122. for i in end_iter:
  1123. f_end = ends_frame[i]
  1124. # Consider gap frames 2..max_gap (i.e., missing 1..max_gap-1 frames)
  1125. for gap in range(2, max_gap + 1):
  1126. f_start = f_end + gap
  1127. js = starts_by_frame.get(f_start, [])
  1128. if not js:
  1129. continue
  1130. # Convert js list to numpy array for vectorized indexing
  1131. js_arr = np.array(js, dtype=int)
  1132. # Vectorized distance computation
  1133. dx = (starts_x[js_arr] - ends_x[i]) * self.pixel_size_xy
  1134. dy = (starts_y[js_arr] - ends_y[i]) * self.pixel_size_xy
  1135. if use_z:
  1136. dz = (starts_z[js_arr] - ends_z[i]) * self.pixel_size_z
  1137. distances = np.sqrt(dx*dx + dy*dy + dz*dz)
  1138. else:
  1139. distances = np.sqrt(dx*dx + dy*dy)
  1140. # Scale max displacement by sqrt(frame_gap) for multi-frame gaps
  1141. max_disp_scaled = max_disp * np.sqrt(gap)
  1142. # Filter by distance gating
  1143. valid_mask = distances <= max_disp_scaled
  1144. if not np.any(valid_mask):
  1145. continue
  1146. # Base costs (scale distance cost by frame gap)
  1147. link_costs = distances * cost_dist * gap
  1148. # Add intensity differences (vectorized)
  1149. if has_signal:
  1150. end_sig = ends_signal[i]
  1151. start_sigs = starts_signal[js_arr]
  1152. # Only add cost if both signals are positive
  1153. sig_valid = (end_sig > 0) & (start_sigs > 0)
  1154. if np.any(sig_valid):
  1155. int_diffs = np.abs(start_sigs - end_sig) / np.maximum(start_sigs, end_sig)
  1156. link_costs = np.where(sig_valid, link_costs + int_diffs * cost_int, link_costs)
  1157. # Add size differences (vectorized)
  1158. if has_size:
  1159. end_sz = ends_size[i]
  1160. start_szs = starts_size[js_arr]
  1161. # Only add cost if both sizes are positive
  1162. size_valid = (end_sz > 0) & (start_szs > 0)
  1163. if np.any(size_valid):
  1164. size_diffs = np.abs(start_szs - end_sz) / np.maximum(start_szs, end_sz)
  1165. link_costs = np.where(size_valid, link_costs + size_diffs * cost_size, link_costs)
  1166. # Apply gap penalty per missed frame
  1167. if gap_penalty_per_missed_frame != 0.0:
  1168. link_costs += gap_penalty_per_missed_frame * (gap - 1)
  1169. # Compute effective costs
  1170. eff_costs = link_costs - baseline
  1171. # Apply distance gating
  1172. eff_costs = np.where(valid_mask, eff_costs, np.inf)
  1173. # Optional k-NN pruning within this gap for this end
  1174. if gap_knn is not None and gap_knn > 0:
  1175. # Select top-k lowest finite costs
  1176. finite_idxs = np.where(np.isfinite(eff_costs))[0]
  1177. if len(finite_idxs) == 0:
  1178. continue
  1179. k = min(gap_knn, len(finite_idxs))
  1180. topk_local = finite_idxs[np.argpartition(eff_costs[finite_idxs], k-1)[:k]]
  1181. js_candidates = js_arr[topk_local]
  1182. eff_candidates = eff_costs[topk_local]
  1183. else:
  1184. js_candidates = js_arr
  1185. eff_candidates = eff_costs
  1186. # Store sparse candidates
  1187. for j_idx, eff in zip(js_candidates, eff_candidates):
  1188. if not np.isfinite(eff):
  1189. continue
  1190. candidates.append((i, int(j_idx), float(eff)))
  1191. used_rows.add(i)
  1192. used_cols.add(int(j_idx))
  1193. if not candidates:
  1194. return tracks_df
  1195. # Build compact cost matrix using only involved rows/cols
  1196. row_list = sorted(used_rows)
  1197. col_list = sorted(used_cols)
  1198. row_index = {r: idx for idx, r in enumerate(row_list)}
  1199. col_index = {c: idx for idx, c in enumerate(col_list)}
  1200. R = len(row_list)
  1201. C = len(col_list)
  1202. # Add dummy terminate columns (one per row)
  1203. aug = np.full((R, C + R), BIG_M, dtype=float)
  1204. # Initialize terminate columns to 0 (no-link baseline)
  1205. aug[:, C:] = 0.0
  1206. for r_orig, c_orig, eff in candidates:
  1207. r = row_index[r_orig]
  1208. c = col_index[c_orig]
  1209. if eff < aug[r, c]:
  1210. aug[r, c] = eff
  1211. # Solve on compact augmented matrix (only involved rows/cols)
  1212. row_ind, col_ind = linear_sum_assignment(aug)
  1213. # Build end->start stitching map (tracklet-level)
  1214. outgoing: Dict[int, int] = {}
  1215. incoming: Dict[int, int] = {}
  1216. n_links = 0
  1217. for r, c in zip(row_ind, col_ind):
  1218. if c < C:
  1219. eff = aug[r, c]
  1220. if not np.isfinite(eff) or eff >= BIG_M * 0.5:
  1221. continue
  1222. # Only accept links that actually beat baseline (negative eff_cost)
  1223. if eff >= 0:
  1224. continue
  1225. tid_end = int(end_tids[row_list[r]])
  1226. tid_start = int(start_tids[col_list[c]])
  1227. # Safety: enforce time ordering
  1228. if int(start_frames[col_list[c]]) <= int(end_frames[row_list[r]]):
  1229. continue
  1230. # Enforce one-in/one-out (Hungarian gives this, but keep safe)
  1231. if tid_end in outgoing:
  1232. continue
  1233. if tid_start in incoming:
  1234. continue
  1235. outgoing[tid_end] = tid_start
  1236. incoming[tid_start] = tid_end
  1237. n_links += 1
  1238. if verbose:
  1239. print(f" [GREEDY-P2] Gap closing: candidate ends={n_ends}, starts={n_starts}, stitched_links={n_links}")
  1240. if not outgoing:
  1241. return tracks_df
  1242. # Rebuild merged tracks by concatenating stitched tracklets
  1243. tracklets = {
  1244. int(tid): grp.sort_values("frame").reset_index(drop=True)
  1245. for tid, grp in tracks_sorted.groupby("track_id")
  1246. }
  1247. new_tracks: List[pd.DataFrame] = []
  1248. visited: Set[int] = set()
  1249. new_id = 0
  1250. def walk_chain(start_tid: int) -> None:
  1251. nonlocal new_id
  1252. chain: List[int] = []
  1253. cur = start_tid
  1254. while cur is not None and cur not in visited:
  1255. visited.add(cur)
  1256. chain.append(cur)
  1257. cur = outgoing.get(cur, None)
  1258. if not chain:
  1259. return
  1260. # Concatenate tracklets in temporal order
  1261. merged = pd.concat([tracklets[tid] for tid in chain], ignore_index=True)
  1262. merged = merged.sort_values("frame").reset_index(drop=True)
  1263. merged = merged.copy()
  1264. merged["track_id"] = new_id
  1265. new_tracks.append(merged)
  1266. new_id += 1
  1267. # Start with chain heads (no incoming links)
  1268. for tid in tracklets.keys():
  1269. if tid in incoming:
  1270. continue
  1271. walk_chain(tid)
  1272. # Handle any remaining tracklets (cycles or isolated)
  1273. for tid in tracklets.keys():
  1274. if tid not in visited:
  1275. walk_chain(tid)
  1276. if not new_tracks:
  1277. return tracks_df
  1278. out = pd.concat(new_tracks, ignore_index=True)
  1279. out = out.sort_values(["track_id", "frame"]).reset_index(drop=True)
  1280. return out
  1281. def _greedy_track_single_pass(
  1282. self,
  1283. detections_by_frame: Dict[int, pd.DataFrame],
  1284. is_3d: bool,
  1285. verbose: bool = False
  1286. ) -> pd.DataFrame:
  1287. """Single pass of greedy tracking."""
  1288. frames = sorted(detections_by_frame.keys())
  1289. if len(frames) == 0:
  1290. return pd.DataFrame()
  1291. # Estimate complexity
  1292. total_dets = sum(len(df) for df in detections_by_frame.values())
  1293. avg_dets_per_frame = total_dets / len(frames) if frames else 0
  1294. if verbose:
  1295. print(f" Total detections: {total_dets}")
  1296. print(f" Frames: {len(frames)}")
  1297. print(f" Avg detections/frame: {avg_dets_per_frame:.1f}")
  1298. # Complexity estimate
  1299. max_active = int(avg_dets_per_frame * (self.max_gap_frames + 1))
  1300. links_per_frame = avg_dets_per_frame * max_active
  1301. total_links = links_per_frame * len(frames)
  1302. print(f" Estimated link evaluations: {total_links:.0f}")
  1303. if total_links > 10_000_000:
  1304. print(f" WARNING: This may take a while (estimated {total_links/1e6:.1f}M link evaluations)")
  1305. all_results = []
  1306. next_track_id = 0
  1307. # Active tracks: list of (track_id, last_frame, last_detection)
  1308. active_tracks: List[Tuple[int, int, pd.Series]] = []
  1309. # Initialize progress bar
  1310. pbar = None
  1311. if TQDM_AVAILABLE and verbose:
  1312. pbar = tqdm(total=len(frames), desc=" Greedy tracking", unit="frame",
  1313. bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {postfix}]')
  1314. for frame in frames:
  1315. df = detections_by_frame[frame]
  1316. if len(df) == 0:
  1317. continue
  1318. # Preserve original detection id for later mapping (e.g., displacement extraction)
  1319. if 'det_id' not in df.columns:
  1320. df = df.reset_index(drop=False).rename(columns={'index': 'det_id'})
  1321. else:
  1322. df = df.reset_index(drop=True)
  1323. # Try to link each detection to existing tracks
  1324. linked_det_indices = set()
  1325. linked_track_indices = set()
  1326. # Compute all possible links
  1327. possible_links = []
  1328. for det_idx, det in df.iterrows():
  1329. for track_idx, (track_id, last_frame, last_det) in enumerate(active_tracks):
  1330. frame_gap = frame - last_frame
  1331. # Allow gaps up to max_gap_frames
  1332. # frame_gap=1 -> consecutive frames (0 skipped)
  1333. # frame_gap=2 -> 1 frame skipped (allowed if max_gap_frames >= 1)
  1334. # frame_gap=3 -> 2 frames skipped (allowed if max_gap_frames >= 2)
  1335. if frame_gap > self.max_gap_frames + 1:
  1336. continue
  1337. cost = self._compute_link_cost(last_det, det, is_3d, frame_gap)
  1338. if cost < np.inf:
  1339. possible_links.append((cost, det_idx, track_idx))
  1340. # Sort by cost and assign greedily
  1341. possible_links.sort(key=lambda x: x[0])
  1342. for cost, det_idx, track_idx in possible_links:
  1343. if det_idx in linked_det_indices or track_idx in linked_track_indices:
  1344. continue
  1345. # Link detection to track
  1346. track_id = active_tracks[track_idx][0]
  1347. det = df.iloc[det_idx]
  1348. result = det.to_dict()
  1349. result['track_id'] = track_id
  1350. result['frame'] = frame
  1351. all_results.append(result)
  1352. # Update active track
  1353. active_tracks[track_idx] = (track_id, frame, det)
  1354. linked_det_indices.add(det_idx)
  1355. linked_track_indices.add(track_idx)
  1356. # Start new tracks for unlinked detections
  1357. for det_idx, det in df.iterrows():
  1358. if det_idx not in linked_det_indices:
  1359. track_id = next_track_id
  1360. next_track_id += 1
  1361. result = det.to_dict()
  1362. result['track_id'] = track_id
  1363. result['frame'] = frame
  1364. all_results.append(result)
  1365. active_tracks.append((track_id, frame, det))
  1366. # Remove dead tracks (keep for max_gap_frames + 1 to allow proper gap closing)
  1367. active_tracks = [
  1368. t for t in active_tracks
  1369. if frame - t[1] <= self.max_gap_frames + 1
  1370. ]
  1371. # Update progress bar if available
  1372. if TQDM_AVAILABLE and pbar is not None:
  1373. pbar.update(1)
  1374. pbar.set_postfix({'active_tracks': len(active_tracks), 'assignments': len(all_results)})
  1375. # Close progress bar
  1376. if TQDM_AVAILABLE and pbar is not None:
  1377. pbar.close()
  1378. if not all_results:
  1379. return pd.DataFrame()
  1380. return pd.DataFrame(all_results)
  1381. @staticmethod
  1382. def _process_single_frame_pair(
  1383. t_curr: int,
  1384. t_next: int,
  1385. curr_df: pd.DataFrame,
  1386. next_df: pd.DataFrame,
  1387. tracker_params: dict,
  1388. is_3d: bool,
  1389. return_pairs: bool = False,
  1390. use_simple: bool = False,
  1391. ) -> Tuple[int, int, List[Tuple[int, int, float, float, float]], List[Tuple[pd.Series, pd.Series]]]:
  1392. """
  1393. Process a single frame pair for displacement extraction.
  1394. """
  1395. # Reset index to ensure we can use integer indexing
  1396. curr_df = curr_df.reset_index(drop=True)
  1397. next_df = next_df.reset_index(drop=True)
  1398. matches: List[Tuple[int, int, float, float, float]] = []
  1399. det_pairs: List[Tuple[pd.Series, pd.Series]] = []
  1400. # Extract parameters
  1401. pixel_size_xy = tracker_params["pixel_size_xy"]
  1402. pixel_size_z = tracker_params.get("pixel_size_z")
  1403. max_displacement_um = float(tracker_params["max_displacement_um"])
  1404. cost_distance = float(tracker_params["cost_distance"])
  1405. cost_intensity_diff = float(tracker_params["cost_intensity_diff"])
  1406. cost_size_diff = float(tracker_params["cost_size_diff"])
  1407. if "use_simple_displacement_extraction" in tracker_params:
  1408. use_simple = bool(tracker_params["use_simple_displacement_extraction"])
  1409. # Precompute coordinate arrays in um
  1410. has_z = bool(is_3d and pixel_size_z is not None and "z" in curr_df.columns and "z" in next_df.columns)
  1411. if has_z:
  1412. curr_coords = np.stack(
  1413. [
  1414. curr_df["x"].to_numpy() * pixel_size_xy,
  1415. curr_df["y"].to_numpy() * pixel_size_xy,
  1416. curr_df["z"].to_numpy() * pixel_size_z,
  1417. ],
  1418. axis=1,
  1419. ) if len(curr_df) else np.zeros((0, 3))
  1420. next_coords = np.stack(
  1421. [
  1422. next_df["x"].to_numpy() * pixel_size_xy,
  1423. next_df["y"].to_numpy() * pixel_size_xy,
  1424. next_df["z"].to_numpy() * pixel_size_z,
  1425. ],
  1426. axis=1,
  1427. ) if len(next_df) else np.zeros((0, 3))
  1428. else:
  1429. curr_coords = np.stack(
  1430. [
  1431. curr_df["x"].to_numpy() * pixel_size_xy,
  1432. curr_df["y"].to_numpy() * pixel_size_xy,
  1433. ],
  1434. axis=1,
  1435. ) if len(curr_df) else np.zeros((0, 2))
  1436. next_coords = np.stack(
  1437. [
  1438. next_df["x"].to_numpy() * pixel_size_xy,
  1439. next_df["y"].to_numpy() * pixel_size_xy,
  1440. ],
  1441. axis=1,
  1442. ) if len(next_df) else np.zeros((0, 2))
  1443. use_kdtree = SCIPY_AVAILABLE and len(curr_coords) > 0 and len(next_coords) > 0
  1444. tree = cKDTree(curr_coords) if use_kdtree else None
  1445. # Optional extra costs (only if columns exist)
  1446. has_signal = "signal" in curr_df.columns and "signal" in next_df.columns
  1447. has_size = "size" in curr_df.columns and "size" in next_df.columns
  1448. curr_signal = curr_df["signal"].to_numpy() if has_signal else None
  1449. curr_size = curr_df["size"].to_numpy() if has_size else None
  1450. if use_simple:
  1451. # Simple greedy matching (kept as you had it)
  1452. for next_idx in range(len(next_df)):
  1453. next_det = next_df.iloc[next_idx]
  1454. if use_kdtree:
  1455. candidate_idxs = tree.query_ball_point(next_coords[next_idx], r=max_displacement_um)
  1456. else:
  1457. candidate_idxs = np.arange(len(curr_df))
  1458. if len(candidate_idxs) == 0:
  1459. continue
  1460. candidate_idxs = np.asarray(candidate_idxs, dtype=int)
  1461. disp_vectors = next_coords[next_idx] - curr_coords[candidate_idxs]
  1462. distances = np.linalg.norm(disp_vectors, axis=1)
  1463. valid_mask = np.isfinite(distances) & (distances <= max_displacement_um)
  1464. if not np.any(valid_mask):
  1465. continue
  1466. disp_vectors = disp_vectors[valid_mask]
  1467. distances = distances[valid_mask]
  1468. candidate_idxs = candidate_idxs[valid_mask]
  1469. costs = distances * cost_distance
  1470. if has_signal:
  1471. next_signal = next_det["signal"]
  1472. costs += np.abs(curr_signal[candidate_idxs] - next_signal) * cost_intensity_diff
  1473. if has_size:
  1474. next_size = next_det["size"]
  1475. costs += np.abs(curr_size[candidate_idxs] - next_size) * cost_size_diff
  1476. best_rel_idx = int(np.argmin(costs))
  1477. best_cost = float(costs[best_rel_idx])
  1478. if not np.isfinite(best_cost):
  1479. continue
  1480. best_curr_idx = int(candidate_idxs[best_rel_idx])
  1481. disp_x, disp_y = float(disp_vectors[best_rel_idx][0]), float(disp_vectors[best_rel_idx][1])
  1482. disp_z = float(disp_vectors[best_rel_idx][2]) if has_z else 0.0
  1483. matches.append((best_curr_idx, next_idx, disp_x, disp_y, disp_z))
  1484. if return_pairs:
  1485. det_pairs.append((curr_df.iloc[best_curr_idx], next_det))
  1486. return (t_curr, t_next, matches, det_pairs if return_pairs else [])
  1487. # LAP-based matching
  1488. try:
  1489. from scipy.optimize import linear_sum_assignment
  1490. except ImportError:
  1491. return MinCostFlowTracker._process_single_frame_pair(
  1492. t_curr, t_next, curr_df, next_df, tracker_params, is_3d, return_pairs, use_simple=True
  1493. )
  1494. n_curr = len(curr_df)
  1495. n_next = len(next_df)
  1496. if n_curr == 0 or n_next == 0:
  1497. return (t_curr, t_next, matches, det_pairs if return_pairs else [])
  1498. BIG_M = 1e9
  1499. # Dummy cost: must be > any "good" gated link cost, so matching is preferred when possible.
  1500. # Gated links have distance <= max_displacement_um, so base distance cost <= max_displacement_um * cost_distance.
  1501. # Add a small margin plus some allowance for signal/size terms.
  1502. DUMMY_COST = (max_displacement_um * cost_distance) + (cost_intensity_diff + cost_size_diff) + 1e-3
  1503. cost_matrix = np.full((n_next, n_curr), BIG_M, dtype=float)
  1504. any_candidate = False
  1505. for next_idx in range(n_next):
  1506. if use_kdtree:
  1507. candidate_idxs = tree.query_ball_point(next_coords[next_idx], r=max_displacement_um)
  1508. else:
  1509. disp_all = next_coords[next_idx] - curr_coords
  1510. dist_all = np.linalg.norm(disp_all, axis=1)
  1511. candidate_idxs = np.where((np.isfinite(dist_all)) & (dist_all <= max_displacement_um))[0]
  1512. if len(candidate_idxs) == 0:
  1513. continue
  1514. candidate_idxs = np.asarray(candidate_idxs, dtype=int)
  1515. next_det = next_df.iloc[next_idx]
  1516. disp_vectors = next_coords[next_idx] - curr_coords[candidate_idxs]
  1517. distances = np.linalg.norm(disp_vectors, axis=1)
  1518. valid_mask = np.isfinite(distances) & (distances <= max_displacement_um)
  1519. if not np.any(valid_mask):
  1520. continue
  1521. candidate_idxs = candidate_idxs[valid_mask]
  1522. distances = distances[valid_mask]
  1523. costs = distances * cost_distance
  1524. if has_signal:
  1525. next_signal = next_det["signal"]
  1526. costs += np.abs(curr_signal[candidate_idxs] - next_signal) * cost_intensity_diff
  1527. if has_size:
  1528. next_size = next_det["size"]
  1529. costs += np.abs(curr_size[candidate_idxs] - next_size) * cost_size_diff
  1530. valid2 = np.isfinite(costs)
  1531. if not np.any(valid2):
  1532. continue
  1533. any_candidate = True
  1534. cost_matrix[next_idx, candidate_idxs[valid2]] = costs[valid2]
  1535. # If nothing was gated-in, bail early (no displacement to extract)
  1536. if not any_candidate:
  1537. return (t_curr, t_next, matches, det_pairs if return_pairs else [])
  1538. # Augment with dummy columns to allow unmatched next detections, but NOT for free.
  1539. aug_cost = np.full((n_next, n_curr + n_next), DUMMY_COST, dtype=float)
  1540. aug_cost[:, :n_curr] = cost_matrix
  1541. row_ind, col_ind = linear_sum_assignment(aug_cost)
  1542. for r, c in zip(row_ind, col_ind):
  1543. if c < n_curr:
  1544. if aug_cost[r, c] >= BIG_M * 0.5:
  1545. continue
  1546. next_idx = int(r)
  1547. curr_idx = int(c)
  1548. disp_vector = next_coords[next_idx] - curr_coords[curr_idx]
  1549. disp_x, disp_y = float(disp_vector[0]), float(disp_vector[1])
  1550. disp_z = float(disp_vector[2]) if has_z else 0.0
  1551. matches.append((curr_idx, next_idx, disp_x, disp_y, disp_z))
  1552. if return_pairs:
  1553. det_pairs.append((curr_df.iloc[curr_idx], next_df.iloc[next_idx]))
  1554. return (t_curr, t_next, matches, det_pairs if return_pairs else [])
  1555. def _extract_displacement_parallel(
  1556. self,
  1557. detections_by_frame: Dict[int, pd.DataFrame],
  1558. is_3d: bool,
  1559. verbose: bool = False
  1560. ) -> Dict[int, pd.DataFrame]:
  1561. """
  1562. Extract displacement vectors from consecutive frame pairs in parallel.
  1563. Parameters
  1564. ----------
  1565. detections_by_frame : dict
  1566. Dict mapping frame number to detection DataFrame
  1567. is_3d : bool
  1568. Whether tracking is 3D
  1569. verbose : bool
  1570. Print progress messages
  1571. Returns
  1572. -------
  1573. dict
  1574. Dict with same structure, but detections have prev_disp_x_um, prev_disp_y_um, prev_disp_z_um
  1575. """
  1576. # Get sorted frame numbers
  1577. frame_nums = sorted(detections_by_frame.keys())
  1578. if len(frame_nums) < 2:
  1579. return detections_by_frame
  1580. # Initialize displacement storage
  1581. # Key: (frame_t, det_index) -> (disp_x, disp_y, disp_z)
  1582. displacement_map = {}
  1583. if verbose:
  1584. print(f" [Stage 1] Extracting displacement from {len(frame_nums)-1} frame pairs")
  1585. # Create frame pairs: [(t0, t1), (t1, t2), ...]
  1586. frame_pairs = [(frame_nums[i], frame_nums[i+1]) for i in range(len(frame_nums)-1)]
  1587. tracker_params = {
  1588. 'pixel_size_xy': self.pixel_size_xy,
  1589. 'pixel_size_z': self.pixel_size_z if is_3d else None,
  1590. 'max_displacement_um': self.max_displacement_um,
  1591. 'cost_distance': self.cost_distance,
  1592. 'cost_intensity_diff': self.cost_intensity_diff,
  1593. 'cost_size_diff': self.cost_size_diff
  1594. }
  1595. # Execute frame-pair matching sequentially (channel-level parallelization is upstream)
  1596. for t_curr, t_next in frame_pairs:
  1597. _, _, matches, _ = self._process_single_frame_pair(
  1598. t_curr,
  1599. t_next,
  1600. detections_by_frame[t_curr],
  1601. detections_by_frame[t_next],
  1602. tracker_params,
  1603. is_3d,
  1604. return_pairs=False,
  1605. use_simple=self.use_simple_displacement_extraction
  1606. )
  1607. # Store displacements for frame t_next
  1608. for _, next_idx, disp_x, disp_y, disp_z in matches:
  1609. # Use DataFrame original index (before reset_index)
  1610. next_original_idx = detections_by_frame[t_next].index[next_idx]
  1611. displacement_map[(t_next, next_original_idx)] = (disp_x, disp_y, disp_z)
  1612. # Apply displacements to detection DataFrames
  1613. detections_with_disp = {}
  1614. for frame, df in detections_by_frame.items():
  1615. df_copy = df.copy()
  1616. # Initialize displacement columns
  1617. df_copy['prev_disp_x_um'] = np.nan
  1618. df_copy['prev_disp_y_um'] = np.nan
  1619. if is_3d:
  1620. df_copy['prev_disp_z_um'] = np.nan
  1621. # Apply stored displacements
  1622. for idx in df_copy.index:
  1623. key = (frame, idx)
  1624. if key in displacement_map:
  1625. disp_x, disp_y, disp_z = displacement_map[key]
  1626. df_copy.at[idx, 'prev_disp_x_um'] = disp_x
  1627. df_copy.at[idx, 'prev_disp_y_um'] = disp_y
  1628. if is_3d:
  1629. df_copy.at[idx, 'prev_disp_z_um'] = disp_z
  1630. detections_with_disp[frame] = df_copy
  1631. if verbose:
  1632. total_displacements = len(displacement_map)
  1633. print(f" [Stage 1] Extracted {total_displacements} displacement vectors")
  1634. return detections_with_disp
  1635. def _min_cost_flow_track(
  1636. self,
  1637. detections_by_frame: Dict[int, pd.DataFrame],
  1638. is_3d: bool,
  1639. verbose: bool = False,
  1640. mcf_max_neighbors: Optional[int] = None,
  1641. mcf_slack_cost_component: Optional[float] = None,
  1642. output_dir: Optional[Path] = None,
  1643. debug_dir: Optional[Path] = None,
  1644. track_type: str = "",
  1645. type_id: int = 0,
  1646. debug_images: bool = False
  1647. ) -> pd.DataFrame:
  1648. """
  1649. Min-cost flow tracking using Google OR-Tools.
  1650. Constructs a flow network where:
  1651. - Source connects to all detections (birth cost)
  1652. - Detections connect to detections in next frames (link cost)
  1653. - All detections connect to sink (death cost)
  1654. - Bypass arc allows unused flow to go source->sink at cost 0
  1655. Flow budget is set to total number of detections. The solver only creates
  1656. tracks when their total cost (birth + transits + links + death) is negative,
  1657. beating the bypass baseline of 0.
  1658. Parameters
  1659. ----------
  1660. detections_by_frame : dict
  1661. Detections organized by frame
  1662. is_3d : bool
  1663. Whether data is 3D
  1664. verbose : bool
  1665. Print progress information
  1666. mcf_max_neighbors : int or None
  1667. Maximum neighbors to consider for linking (k-NN constraint)
  1668. mcf_slack_cost_component : float or None
  1669. Deprecated (bypass cost is now always 0)
  1670. output_dir : Path or None
  1671. Directory for debug output
  1672. debug_dir : Path or None
  1673. Override directory for debug output
  1674. track_type : str
  1675. Label for debug output
  1676. type_id : int
  1677. ID for debug output
  1678. debug_images : bool
  1679. Generate debug visualizations
  1680. """
  1681. # Check if OR-Tools is available
  1682. if not ORTOOLS_AVAILABLE:
  1683. print(f" ERROR: OR-Tools is not installed!")
  1684. print(f" Min-cost flow tracking requires OR-Tools.")
  1685. print(f" Install with: pip install ortools")
  1686. print(f" Returning empty result (will trigger greedy fallback)")
  1687. return pd.DataFrame()
  1688. frames = sorted(detections_by_frame.keys())
  1689. if len(frames) == 0:
  1690. return pd.DataFrame()
  1691. debug_output_dir = debug_dir if debug_dir is not None else output_dir
  1692. has_debug_output = debug_output_dir is not None
  1693. # Build node mapping: each detection gets in/out nodes to allow merges/splits
  1694. # Node 0: source, Node 1: sink, Nodes 2+: detection_in/detection_out pairs
  1695. source_node = 0
  1696. sink_node = 1
  1697. next_node_id = 2
  1698. detection_to_nodes = {} # (frame, det_idx) -> (node_in, node_out)
  1699. node_in_to_detection = {}
  1700. node_out_to_detection = {}
  1701. for frame in frames:
  1702. df = detections_by_frame[frame].reset_index(drop=True)
  1703. for det_idx in range(len(df)):
  1704. node_in = next_node_id
  1705. node_out = next_node_id + 1
  1706. next_node_id += 2
  1707. detection_to_nodes[(frame, det_idx)] = (node_in, node_out)
  1708. node_in_to_detection[node_in] = (frame, det_idx)
  1709. node_out_to_detection[node_out] = (frame, det_idx)
  1710. total_nodes = next_node_id
  1711. if verbose:
  1712. print(f" Building flow network: {total_nodes} nodes ({len(detection_to_nodes)} detections)")
  1713. # Initialize min-cost flow solver
  1714. smcf = min_cost_flow.SimpleMinCostFlow()
  1715. # Scale costs to integers (OR-Tools requires integer costs)
  1716. COST_SCALE = 1000
  1717. # Add edges
  1718. edge_count = 0
  1719. link_count = 0
  1720. # 1. Source -> detection_in (birth cost), capacity 1 to prevent double births
  1721. for node_in in node_in_to_detection.keys():
  1722. smcf.add_arc_with_capacity_and_unit_cost(
  1723. source_node, node_in,
  1724. capacity=1,
  1725. unit_cost=int(self.mcf_birth_cost * COST_SCALE)
  1726. )
  1727. edge_count += 1
  1728. # 2. detection_in -> detection_out (allow two flows; second pays merge/split penalty)
  1729. # Apply quality bonus (negative cost) based on p_good to encourage including quality detections
  1730. quality_bonus_stats = {
  1731. 'with_p_good': 0,
  1732. 'applied': 0,
  1733. 'total_bonus': 0.0,
  1734. 'sum_p_good': 0.0,
  1735. 'min_p_good': None,
  1736. 'max_p_good': None,
  1737. }
  1738. for node_in, (frame, det_idx) in node_in_to_detection.items():
  1739. node_out = detection_to_nodes[(frame, det_idx)][1]
  1740. # Compute quality bonus based on p_good (0..1)
  1741. quality_bonus = 0.0
  1742. det = detections_by_frame[frame].iloc[det_idx]
  1743. p_good = None
  1744. if 'p_good' in det.index and pd.notna(det['p_good']):
  1745. p_good = float(det['p_good'])
  1746. else:
  1747. p_vals = []
  1748. if 'ch1_p_good' in det.index and pd.notna(det['ch1_p_good']):
  1749. p_vals.append(float(det['ch1_p_good']))
  1750. if 'ch2_p_good' in det.index and pd.notna(det['ch2_p_good']):
  1751. p_vals.append(float(det['ch2_p_good']))
  1752. if p_vals:
  1753. p_good = float(min(p_vals))
  1754. if p_good is not None:
  1755. p_good = float(np.clip(p_good, 0.0, 1.0))
  1756. quality_bonus_stats['with_p_good'] += 1
  1757. quality_bonus_stats['sum_p_good'] += p_good
  1758. if quality_bonus_stats['min_p_good'] is None:
  1759. quality_bonus_stats['min_p_good'] = p_good
  1760. quality_bonus_stats['max_p_good'] = p_good
  1761. else:
  1762. quality_bonus_stats['min_p_good'] = min(quality_bonus_stats['min_p_good'], p_good)
  1763. quality_bonus_stats['max_p_good'] = max(quality_bonus_stats['max_p_good'], p_good)
  1764. quality_bonus = -1.0 * self.mcf_quality_bonus_weight * p_good
  1765. if p_good > 0 and self.mcf_quality_bonus_weight > 0:
  1766. quality_bonus_stats['applied'] += 1
  1767. quality_bonus_stats['total_bonus'] += abs(quality_bonus)
  1768. # Single capacity transit arc with quality bonus + flat detection bonus (both negative costs = bonuses)
  1769. transit_cost = quality_bonus - self.mcf_detection_bonus
  1770. smcf.add_arc_with_capacity_and_unit_cost(
  1771. node_in, node_out,
  1772. capacity=1,
  1773. unit_cost=int(transit_cost * COST_SCALE)
  1774. )
  1775. edge_count += 1
  1776. # 3. detection_out -> sink (death cost), capacity 1 to prevent double deaths
  1777. for node_out in node_out_to_detection.keys():
  1778. smcf.add_arc_with_capacity_and_unit_cost(
  1779. node_out, sink_node,
  1780. capacity=1,
  1781. unit_cost=int(self.mcf_death_cost * COST_SCALE)
  1782. )
  1783. edge_count += 1
  1784. # 4. detection_out -> detection_in links (across frames)
  1785. for frame in frames:
  1786. df_curr = detections_by_frame[frame].reset_index(drop=True)
  1787. # Look ahead up to mcf_max_gap_frames + 1
  1788. for gap in range(1, self.mcf_max_gap_frames + 2):
  1789. next_frame = frame + gap
  1790. if next_frame not in detections_by_frame:
  1791. continue
  1792. df_next = detections_by_frame[next_frame].reset_index(drop=True)
  1793. # Build k-NN structure for next frame if limiting neighbors
  1794. max_k = mcf_max_neighbors if mcf_max_neighbors is not None else self.mcf_max_neighbors
  1795. use_knn = SCIPY_AVAILABLE and max_k is not None and max_k > 0
  1796. next_coords = None
  1797. next_tree = None
  1798. if use_knn:
  1799. if is_3d and self.pixel_size_z is not None and 'z' in df_next.columns:
  1800. next_coords = np.stack([
  1801. df_next['x'].to_numpy() * self.pixel_size_xy,
  1802. df_next['y'].to_numpy() * self.pixel_size_xy,
  1803. df_next['z'].to_numpy() * self.pixel_size_z
  1804. ], axis=1)
  1805. else:
  1806. next_coords = np.stack([
  1807. df_next['x'].to_numpy() * self.pixel_size_xy,
  1808. df_next['y'].to_numpy() * self.pixel_size_xy
  1809. ], axis=1)
  1810. next_tree = cKDTree(next_coords)
  1811. # Compute all possible links from current to next frame
  1812. for curr_idx, curr_det in df_curr.iterrows():
  1813. curr_node_out = detection_to_nodes[(frame, curr_idx)][1]
  1814. candidate_indices = range(len(df_next))
  1815. if use_knn:
  1816. if is_3d and self.pixel_size_z is not None and 'z' in df_curr.columns:
  1817. curr_coord = [
  1818. curr_det['x'] * self.pixel_size_xy,
  1819. curr_det['y'] * self.pixel_size_xy,
  1820. curr_det['z'] * self.pixel_size_z
  1821. ]
  1822. else:
  1823. curr_coord = [
  1824. curr_det['x'] * self.pixel_size_xy,
  1825. curr_det['y'] * self.pixel_size_xy
  1826. ]
  1827. dists, idxs = next_tree.query(curr_coord, k=min(max_k, len(df_next)))
  1828. if np.isscalar(idxs):
  1829. idxs = [int(idxs)]
  1830. else:
  1831. idxs = [int(i) for i in np.atleast_1d(idxs)]
  1832. candidate_indices = idxs
  1833. for next_idx in candidate_indices:
  1834. next_det = df_next.iloc[next_idx]
  1835. next_node_in = detection_to_nodes[(next_frame, next_idx)][0]
  1836. # Extract previous displacement from curr_det (if available)
  1837. prev_displacement = None
  1838. if 'prev_disp_x_um' in curr_det.index and pd.notna(curr_det.get('prev_disp_x_um')):
  1839. if is_3d and self.pixel_size_z is not None and 'prev_disp_z_um' in curr_det.index:
  1840. prev_displacement = np.array([
  1841. curr_det['prev_disp_x_um'],
  1842. curr_det['prev_disp_y_um'],
  1843. curr_det['prev_disp_z_um']
  1844. ])
  1845. else:
  1846. prev_displacement = np.array([
  1847. curr_det['prev_disp_x_um'],
  1848. curr_det['prev_disp_y_um']
  1849. ])
  1850. # Compute link cost using MCF-specific parameters (with motion smoothness if available)
  1851. cost = self._compute_link_cost(
  1852. curr_det, next_det, is_3d, gap,
  1853. use_mcf_costs=True,
  1854. prev_displacement=prev_displacement
  1855. )
  1856. if cost < np.inf:
  1857. smcf.add_arc_with_capacity_and_unit_cost(
  1858. curr_node_out, next_node_in,
  1859. capacity=1,
  1860. unit_cost=int(cost * COST_SCALE)
  1861. )
  1862. edge_count += 1
  1863. link_count += 1
  1864. # 5. Bypass arc: allow unused flow budget to go directly source->sink at cost 0
  1865. # This means tracks are only created when they beat the "do nothing" baseline
  1866. num_detections = len(detection_to_nodes)
  1867. bypass_cost = 0.0 # True bypass: no cost for unused flow
  1868. smcf.add_arc_with_capacity_and_unit_cost(
  1869. source_node, sink_node,
  1870. capacity=num_detections, # Upper bound on flow
  1871. unit_cost=int(bypass_cost * COST_SCALE)
  1872. )
  1873. edge_count += 1
  1874. # Always print link statistics
  1875. print(f" [MCF] Added {edge_count} total edges ({link_count} detection links, {edge_count - link_count} birth/death)")
  1876. print(f" [MCF] Birth/death cost: {self.mcf_birth_cost}/{self.mcf_death_cost}, max_displacement: {self.mcf_max_displacement_um} µm")
  1877. # Print quality bonus statistics
  1878. if quality_bonus_stats['with_p_good'] > 0:
  1879. mean_p_good = quality_bonus_stats['sum_p_good'] / quality_bonus_stats['with_p_good']
  1880. print(
  1881. f" [MCF] Quality bonus: weight={self.mcf_quality_bonus_weight:.2f}, "
  1882. f"applied to {quality_bonus_stats['applied']}/{quality_bonus_stats['with_p_good']} detections"
  1883. )
  1884. if self.verbosity_level >= 2:
  1885. print(
  1886. f" [MCF] p_good mean={mean_p_good:.3f}, "
  1887. f"min={quality_bonus_stats['min_p_good']:.3f}, max={quality_bonus_stats['max_p_good']:.3f}"
  1888. )
  1889. print(f" [MCF] Total bonus applied: {quality_bonus_stats['total_bonus']:.2f}")
  1890. elif self.mcf_quality_bonus_weight > 0:
  1891. print(f" [MCF] Quality bonus: weight={self.mcf_quality_bonus_weight:.2f}, but no p_good data found in detections")
  1892. # Print flat detection bonus
  1893. if self.mcf_detection_bonus > 0:
  1894. total_flat_bonus = self.mcf_detection_bonus * len(node_in_to_detection)
  1895. print(f" [MCF] Flat detection bonus: {self.mcf_detection_bonus:.2f} per detection, total={total_flat_bonus:.2f} across {len(node_in_to_detection)} detections")
  1896. # Flow budget: set to total number of detections (upper bound)
  1897. # The solver will only create tracks when they beat the bypass cost (0)
  1898. flow_budget = len(detection_to_nodes)
  1899. print(f" [MCF] Flow budget: {flow_budget} (detections: {flow_budget})")
  1900. print(f" [MCF] Bypass arc: cost={bypass_cost}, capacity={flow_budget}")
  1901. print(f" [MCF] Only tracks with negative total cost will be created (beat bypass)")
  1902. # Set supply/demand
  1903. # Source: supply = flow budget (upper bound on all flow)
  1904. # Sink: demand = -flow budget (must consume all supply)
  1905. # All detection nodes: supply/demand = 0 (flow through)
  1906. smcf.set_node_supply(source_node, flow_budget)
  1907. smcf.set_node_supply(sink_node, -flow_budget)
  1908. # Solve min-cost flow
  1909. if verbose:
  1910. print(f" Solving min-cost flow...")
  1911. try:
  1912. status = smcf.solve()
  1913. except Exception as e:
  1914. print(f" ERROR: Min-cost flow solver crashed: {e}")
  1915. import traceback
  1916. traceback.print_exc()
  1917. return pd.DataFrame()
  1918. # Status codes: OPTIMAL=0, NOT_SOLVED=1, FEASIBLE=2, INFEASIBLE=3, UNBALANCED=4, BAD_RESULT=5, BAD_COST_RANGE=6
  1919. status_names = {0: 'OPTIMAL', 1: 'NOT_SOLVED', 2: 'FEASIBLE', 3: 'INFEASIBLE', 4: 'UNBALANCED', 5: 'BAD_RESULT', 6: 'BAD_COST_RANGE'}
  1920. status_name = status_names.get(status, f'UNKNOWN({status})')
  1921. if status != smcf.OPTIMAL:
  1922. print(f" WARNING: Min-cost flow solver returned status: {status_name}")
  1923. print(f" This usually indicates:")
  1924. if status == 3: # INFEASIBLE
  1925. print(f" - Network is INFEASIBLE (no valid solution exists)")
  1926. print(f" - Check that supply ({flow_budget}) = demand (-{flow_budget})")
  1927. elif status == 4: # UNBALANCED
  1928. print(f" - Network is UNBALANCED (supply != demand)")
  1929. print(f" - Supply at source: {flow_budget}, Demand at sink: {flow_budget}")
  1930. elif status == 6: # BAD_COST_RANGE
  1931. print(f" - BAD_COST_RANGE (costs too large for integer solver)")
  1932. print(f" - Cost scale: {COST_SCALE}, birth/death costs: {self.birth_cost}/{self.death_cost}")
  1933. print(f" Returning empty result (will trigger greedy fallback in caller)")
  1934. return pd.DataFrame()
  1935. if verbose:
  1936. print(f" Optimal solution found! Total cost: {smcf.optimal_cost() / COST_SCALE:.2f}")
  1937. # Count bypass flow (source -> sink)
  1938. bypass_flow = 0
  1939. for i in range(smcf.num_arcs()):
  1940. if smcf.flow(i) > 0 and smcf.tail(i) == source_node and smcf.head(i) == sink_node:
  1941. bypass_flow = smcf.flow(i)
  1942. break
  1943. used_flow = flow_budget - bypass_flow
  1944. print(f" [MCF] Flow allocation: {used_flow}/{flow_budget} used for tracks, {bypass_flow} through bypass ({100*bypass_flow/flow_budget:.1f}%)")
  1945. # Extract tracks from flow solution
  1946. # Build adjacency from flow: detection -> [next_detection, ...]
  1947. # Use defaultdict to handle splits (one detection -> multiple next detections)
  1948. from collections import defaultdict
  1949. flow_graph = defaultdict(list) # (frame, idx) -> [(next_frame, next_idx), ...]
  1950. link_distances = []
  1951. # Death diagnostics: distance to nearest detection in next frame
  1952. death_to_nextframe_dists: List[float] = []
  1953. death_debug_records: List[dict] = []
  1954. # Cost component tracking for debug visualizations
  1955. cost_breakdowns = [] # List of {distance, distance_cost, total_cost, ambiguity_factor, smoothness_penalty}
  1956. ambiguity_factors = [] # Per-detection ambiguity factors
  1957. quality_bonuses = [] # Per-detection quality bonuses
  1958. smoothness_penalties = [] # Per-link smoothness penalties
  1959. for i in range(smcf.num_arcs()):
  1960. if smcf.flow(i) > 0:
  1961. tail = smcf.tail(i)
  1962. head = smcf.head(i)
  1963. if tail in node_out_to_detection and head in node_in_to_detection:
  1964. curr_det = node_out_to_detection[tail]
  1965. next_det = node_in_to_detection[head]
  1966. flow_graph[curr_det].append(next_det)
  1967. # Get detection data
  1968. f1, i1 = curr_det
  1969. f2, i2 = next_det
  1970. d1 = detections_by_frame[f1].iloc[i1]
  1971. d2 = detections_by_frame[f2].iloc[i2]
  1972. gap = f2 - f1
  1973. # Compute distance for diagnostics
  1974. if is_3d and self.pixel_size_z is not None and 'z' in d1 and 'z' in d2:
  1975. dist = np.sqrt(
  1976. ((d1['x'] - d2['x']) * self.pixel_size_xy) ** 2 +
  1977. ((d1['y'] - d2['y']) * self.pixel_size_xy) ** 2 +
  1978. ((d1['z'] - d2['z']) * self.pixel_size_z) ** 2
  1979. )
  1980. else:
  1981. dist = np.sqrt(
  1982. ((d1['x'] - d2['x']) * self.pixel_size_xy) ** 2 +
  1983. ((d1['y'] - d2['y']) * self.pixel_size_xy) ** 2
  1984. )
  1985. link_distances.append(dist)
  1986. # Collect cost breakdown for debug visualizations
  1987. if debug_images:
  1988. # Extract previous displacement from d1 (if available)
  1989. prev_displacement = None
  1990. if 'prev_disp_x_um' in d1.index and pd.notna(d1.get('prev_disp_x_um')):
  1991. if is_3d and self.pixel_size_z is not None and 'prev_disp_z_um' in d1.index:
  1992. prev_displacement = np.array([
  1993. d1['prev_disp_x_um'],
  1994. d1['prev_disp_y_um'],
  1995. d1['prev_disp_z_um']
  1996. ])
  1997. else:
  1998. prev_displacement = np.array([
  1999. d1['prev_disp_x_um'],
  2000. d1['prev_disp_y_um']
  2001. ])
  2002. # Compute cost with breakdown
  2003. _, breakdown = self._compute_link_cost(
  2004. d1, d2, is_3d, gap,
  2005. use_mcf_costs=True,
  2006. prev_displacement=prev_displacement,
  2007. return_breakdown=True
  2008. )
  2009. cost_breakdowns.append(breakdown)
  2010. # Collect smoothness penalties
  2011. if breakdown['smoothness_penalty'] > 0:
  2012. smoothness_penalties.append(breakdown['smoothness_penalty'])
  2013. # Identify track starts: source -> detection_in with flow > 0
  2014. start_detections = []
  2015. for i in range(smcf.num_arcs()):
  2016. if smcf.flow(i) > 0 and smcf.tail(i) == source_node and smcf.head(i) in node_in_to_detection:
  2017. start_detections.append(node_in_to_detection[smcf.head(i)])
  2018. # Analyze split/merge events and double-used detections
  2019. # Count flow through each detection's in/out nodes
  2020. flow_in = {} # detection -> total flow entering detection_in
  2021. flow_out = {} # detection -> total flow leaving detection_out
  2022. transit_flow = {} # detection -> total flow through detection_in->detection_out
  2023. for i in range(smcf.num_arcs()):
  2024. if smcf.flow(i) > 0:
  2025. tail = smcf.tail(i)
  2026. head = smcf.head(i)
  2027. flow = smcf.flow(i)
  2028. # Count flow entering detection_in nodes (from source or from previous detections)
  2029. if head in node_in_to_detection:
  2030. det = node_in_to_detection[head]
  2031. flow_in[det] = flow_in.get(det, 0) + flow
  2032. # Count flow leaving detection_out nodes (to sink or to next detections)
  2033. if tail in node_out_to_detection:
  2034. det = node_out_to_detection[tail]
  2035. flow_out[det] = flow_out.get(det, 0) + flow
  2036. # Count flow through transit edges (detection_in -> detection_out)
  2037. if tail in node_in_to_detection and head in node_out_to_detection:
  2038. det_tail = node_in_to_detection[tail]
  2039. det_head = node_out_to_detection[head]
  2040. if det_tail == det_head: # Same detection
  2041. transit_flow[det_tail] = transit_flow.get(det_tail, 0) + flow
  2042. # Note: Split/merge detection is now done post-hoc on the output tracks
  2043. # (see detect_split_merge_events function)
  2044. # Identify deaths: detections that participated in flow but have no outgoing link
  2045. # Flatten adjacency lists to get all destination detections
  2046. all_destinations = set()
  2047. for next_dets in flow_graph.values():
  2048. all_destinations.update(next_dets)
  2049. used_detections = set(flow_graph.keys()) | all_destinations | set(start_detections)
  2050. dead_detections = used_detections - set(flow_graph.keys())
  2051. for (frame, idx) in dead_detections:
  2052. # Look only at immediate next frame
  2053. next_frame = frame + 1
  2054. if next_frame not in detections_by_frame:
  2055. continue
  2056. df_next = detections_by_frame[next_frame]
  2057. if len(df_next) == 0:
  2058. continue
  2059. det = detections_by_frame[frame].iloc[idx]
  2060. # Compute distances to all detections in next frame
  2061. if is_3d and self.pixel_size_z is not None and 'z' in det and 'z' in df_next.columns:
  2062. dx = (df_next['x'].to_numpy() - det['x']) * self.pixel_size_xy
  2063. dy = (df_next['y'].to_numpy() - det['y']) * self.pixel_size_xy
  2064. dz = (df_next['z'].to_numpy() - det['z']) * self.pixel_size_z
  2065. dists = np.sqrt(dx * dx + dy * dy + dz * dz)
  2066. else:
  2067. dx = (df_next['x'].to_numpy() - det['x']) * self.pixel_size_xy
  2068. dy = (df_next['y'].to_numpy() - det['y']) * self.pixel_size_xy
  2069. dists = np.sqrt(dx * dx + dy * dy)
  2070. if len(dists) > 0 and np.isfinite(dists).any():
  2071. min_dist = float(np.nanmin(dists))
  2072. if np.isfinite(min_dist):
  2073. death_to_nextframe_dists.append(min_dist)
  2074. # Re-run gate/pruning to count link candidates and min cost
  2075. max_k = mcf_max_neighbors if mcf_max_neighbors is not None else self.mcf_max_neighbors
  2076. gate = (dists <= (self.mcf_max_displacement_um if self.mcf_max_displacement_um is not None else self.max_displacement_um))
  2077. candidate_idxs = [i for i, keep in enumerate(gate) if keep]
  2078. if max_k is not None and max_k > 0 and candidate_idxs:
  2079. candidate_idxs = sorted(candidate_idxs, key=lambda j: dists[j])[:max_k]
  2080. min_link_cost = np.inf
  2081. for j in candidate_idxs:
  2082. next_det = df_next.iloc[j]
  2083. cost = self._compute_link_cost(det, next_det, is_3d, frame_gap=1, use_mcf_costs=True)
  2084. if np.isfinite(cost) and cost < min_link_cost:
  2085. min_link_cost = cost
  2086. death_debug_records.append({
  2087. 'frame': frame,
  2088. 'candidates': len(candidate_idxs),
  2089. 'min_dist_um': float(np.nanmin(dists)) if len(dists) else np.nan,
  2090. 'min_link_cost': float(min_link_cost) if np.isfinite(min_link_cost) else np.inf
  2091. })
  2092. # Build tracks by following flow from each starting detection
  2093. # NOTE: With splits/merges, this creates one track per "branch"
  2094. # A detection with 2 outgoing links will spawn 2 separate tracks
  2095. all_results = []
  2096. next_track_id = 0
  2097. visited = set()
  2098. track_costs = {} # track_id -> total cost (not computed with adjacency list approach)
  2099. # For now, we'll extract simple linear tracks by taking first branch at splits
  2100. # A proper split/merge-aware extraction would require more complex logic
  2101. for start_det in start_detections:
  2102. if start_det in visited:
  2103. continue
  2104. track_id = next_track_id
  2105. next_track_id += 1
  2106. curr_det = start_det
  2107. while curr_det is not None:
  2108. visited.add(curr_det)
  2109. frame, det_idx = curr_det
  2110. det = detections_by_frame[frame].iloc[det_idx]
  2111. result = det.to_dict()
  2112. result['track_id'] = track_id
  2113. result['frame'] = frame
  2114. all_results.append(result)
  2115. # Collect ambiguity and quality data for debug visualizations
  2116. if debug_images:
  2117. # Collect ambiguity factors (gap 1 is most common)
  2118. if 'ambiguity_gap1' in det.index and pd.notna(det.get('ambiguity_gap1')):
  2119. ambiguity_factors.append(float(det['ambiguity_gap1']))
  2120. # Collect quality bonuses (same logic as main MCF graph)
  2121. p_good = None
  2122. if 'p_good' in det.index and pd.notna(det['p_good']):
  2123. p_good = float(det['p_good'])
  2124. else:
  2125. p_vals = []
  2126. if 'ch1_p_good' in det.index and pd.notna(det['ch1_p_good']):
  2127. p_vals.append(float(det['ch1_p_good']))
  2128. if 'ch2_p_good' in det.index and pd.notna(det['ch2_p_good']):
  2129. p_vals.append(float(det['ch2_p_good']))
  2130. if p_vals:
  2131. p_good = float(min(p_vals))
  2132. if p_good is not None and p_good > 0:
  2133. p_good = float(np.clip(p_good, 0.0, 1.0))
  2134. quality_bonus = -1.0 * self.mcf_quality_bonus_weight * p_good
  2135. quality_bonuses.append(quality_bonus)
  2136. # Get next detection(s) from adjacency list
  2137. next_dets = flow_graph.get(curr_det, [])
  2138. if len(next_dets) == 0:
  2139. # End of track
  2140. curr_det = None
  2141. elif len(next_dets) == 1:
  2142. # Linear continuation
  2143. curr_det = next_dets[0]
  2144. else:
  2145. # Split: take first unvisited branch, others will be separate tracks
  2146. curr_det = None
  2147. for next_det in next_dets:
  2148. if next_det not in visited:
  2149. curr_det = next_det
  2150. break
  2151. if verbose:
  2152. print(f" Extracted {next_track_id} tracks from flow solution")
  2153. if link_distances:
  2154. link_dists = np.array(link_distances)
  2155. print(f" Link distance stats (µm): min={link_dists.min():.3f}, mean={link_dists.mean():.3f}, max={link_dists.max():.3f}")
  2156. # Simple histogram bins
  2157. bins = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.5, 2.0]
  2158. hist, edges = np.histogram(link_dists, bins=bins)
  2159. print(" Link distance histogram (µm):")
  2160. for count, start, end in zip(hist, edges[:-1], edges[1:]):
  2161. print(f" {start:.1f}-{end:.1f}: {count}")
  2162. # Save comprehensive debug visualizations if debug_images is enabled
  2163. if debug_images and has_debug_output:
  2164. try:
  2165. save_debug_visualizations(
  2166. detections_by_frame=detections_by_frame,
  2167. all_results=all_results,
  2168. link_distances=link_distances,
  2169. start_detections=start_detections,
  2170. death_to_nextframe_dists=death_to_nextframe_dists,
  2171. track_costs=track_costs,
  2172. is_3d=is_3d,
  2173. output_dir=debug_output_dir,
  2174. track_type=track_type,
  2175. type_id=type_id,
  2176. verbose=verbose,
  2177. pixel_size_xy=self.pixel_size_xy,
  2178. max_displacement_um=self.max_displacement_um,
  2179. mcf_max_displacement_um=self.mcf_max_displacement_um,
  2180. mcf_cost_distance=self.mcf_cost_distance,
  2181. mcf_crowding_multiplier=self.mcf_crowding_multiplier,
  2182. cost_breakdowns=cost_breakdowns,
  2183. ambiguity_factors=ambiguity_factors,
  2184. quality_bonuses=quality_bonuses,
  2185. smoothness_penalties=smoothness_penalties,
  2186. debug_dir=debug_dir
  2187. )
  2188. except Exception as e:
  2189. print(f" Warning: Could not save debug visualizations: {e}")
  2190. import traceback
  2191. traceback.print_exc()
  2192. if verbose and death_debug_records:
  2193. total_deaths = len(death_debug_records)
  2194. zero_candidates = sum(1 for r in death_debug_records if r['candidates'] == 0)
  2195. finite_costs = [r['min_link_cost'] for r in death_debug_records if np.isfinite(r['min_link_cost'])]
  2196. med_cost = np.median(finite_costs) if finite_costs else None
  2197. print(f" [MCF] Death debug: deaths={total_deaths}, zero_candidates={zero_candidates}, finite_min_costs={len(finite_costs)}, median_min_cost={med_cost}")
  2198. if not all_results:
  2199. return pd.DataFrame()
  2200. return pd.DataFrame(all_results)

mincostflow.py at commit 08be6e0, under MIT · at the source

Overview

  1. Department of Pharmacology, Vanderbilt Brain Institute, Vanderbilt University Nashville United States
Institutions: Vanderbilt University (United States)
Journal: eLife, volume 14, article RP107635
Dates: published online 27 July 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.107635 · PMID 42507759 · PMCID PMC13405638 · OpenAlex W4413760595
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), cellular / molecular (subfield)
Methods: Statistics, Evoked potentials, fMRI & imaging, Smoothing, state filtering, decompositions
Keywords: synapse, synapse formation, E/I balance, time-lapse imaging, live imaging, Mouse
MeSH: Synapses*, Animals, Mice, Neurodevelopment (* major topic)
Journal subjects: Cell Biology, Neuroscience
Topic: Neuroscience and Neuropharmacology Research (Cellular and Molecular Neuroscience, Neuroscience), according to OpenAlex
Funding: National Institute of Mental Health (R00MH117235, DP2MH140134); Alfred P. Sloan Foundation (FG-2022-19037)
Citations: not cited yet (Europe PMC); 48 references in the paper
Research resources: Goat anti-mouse Alexa Fluor 546 RRID:AB_141370, HA-Tag (C29F4) rabbit mAb RRID:AB_1549585, RRID:AB_2138173, GFP polyclonal antibody rabbit RRID:AB_221569, Polyclonal anti-GABARα1 rabbit RRID:AB_2232180, RRID:AB_2301751, Goat anti-mouse Alexa Fluor 488 RRID:AB_2534069, Goat anti-rabbit Alexa Fluor 546 RRID:AB_2534077, Goat anti-mouse Alexa Fluor 647 RRID:AB_2535805, Goat anti-rabbit Alexa Fluor 647 RRID:AB_2535813, Goat anti-chicken Alexa Fluor 647 RRID:AB_2535866, Goat anti-guinea pig Alexa Fluor 647 RRID:AB_2535867, Goat anti-rabbit Alexa Fluor 488 RRID:AB_2576217, Monoclonal anti-Gephyrin mouse RRID:AB_2619837, Polyclonal anti-Shank 2 guinea pig RRID:AB_2619861, RRID:AB_2716712, Polyclonal anti-Homer 1 rabbit RRID:AB_887730, Polyclonal anti-Syn1/2 rabbit RRID:AB_887804, pAAV SEP-GluA2 TKIT (plasmid) RRID:Addgene_169442, HEK293T RRID:CVCL_1926, RRID:IMSR_JAX:000664, GraphPad Prism 8.0, 9.0 RRID:SCR_002798, ImageJ RRID:SCR_003070, Adobe Illustrator RRID:SCR_010279, Adobe Photoshop RRID:SCR_014199, SnapGene RRID:SCR_015052, NIS-Elements AR RRID:SCR_027181

Abstract

Diverse synaptic connections self-organize into neural circuits during brain development. A balance between excitatory and inhibitory synaptic function is required for information processing by these neural circuits. Despite the importance of this balance, the interplay between excitatory and inhibitory synaptic assembly during circuit establishment remains unclear due to a lack of means to monitor both processes simultaneously. Here, we develop imaging and analysis methods to visualize and track excitatory and inhibitory synapses. By applying these approaches, we find that despite continual dynamics, excitatory and inhibitory synaptic density remain at steady-state levels during synapse maturation. These results indicate balanced excitatory and inhibitory synapse assembly, despite continual synaptic turnover.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 11 matches between paragraphs and lines of code.

datajamesallen/Garbett_2026_eLife

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 08be6e0626df4dd4c9a9869b350dce35b416808a, 10 July 2026
Languages: Python (67)
Size: 76 files, 67 scripts
Software Heritage: not archived
Found in: “Data availability”
Holds: README, license file, environment (requirements.txt, setup.py, ConsensusDrift/setup.py)
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (45 files), pandas (23 files), SciPy (23 files), tifffile (16 files), Matplotlib (13 files), scikit-image (12 files), scikit-learn (4 files), ImageJ / Fiji (3 files), Pillow (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
69 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 67 scripts, each with its path and the digest of its content;
  • 11 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data availability

Code generated in this study is available at the following deposit: https://github.com/datajamesallen/Garbett_2026_eLife (copy archived at Allen, 2026). Live imaging data generated in this study is available at Dryad: https://doi.org/10.5061/dryad.5qfttdzp5. All materials generated in this study will be shared by the lead contact upon request.

The following dataset was generated:

GarbettKA AllenJP LopezJM SmithCM SandoRC Dryad Digital Repository10.5061/dryad.5qfttdzp52026Data from: Stable excitatory-inhibitory synapse balance despite dynamic turnoverPMC1340563842507759

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 5 authors, 6 keywords, 4 MeSH terms, 2 funders, 47 references, 27 RRIDs.

Cite

This paper

Garbett, K. A., Allen, J. P., Lopez, J. M., Smith, C. M., & Sando, R. C. (2026). Stable excitatory-inhibitory synapse balance despite dynamic turnover. eLife, 14, RP107635. https://doi.org/10.7554/elife.107635

BibTeX

@article{garbett2026stable,
author = {Garbett, Krassimira A and Allen, James P and Lopez, Jaybree M and Smith, Cassandra M and Sando, Richard C},
title = {{Stable excitatory-inhibitory synapse balance despite dynamic turnover}},
journal = {eLife},
year = {2026},
month = jul,
volume = {14},
pages = {RP107635},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.107635},
url = {https://doi.org/10.7554/elife.107635},
pmid = {42507759},
pmcid = {PMC13405638}
}

RIS

TY - JOUR
AU - Garbett, Krassimira A
AU - Allen, James P
AU - Lopez, Jaybree M
AU - Smith, Cassandra M
AU - Sando, Richard C
TI - Stable excitatory-inhibitory synapse balance despite dynamic turnover
T2 - eLife
J2 - eLife
PY - 2026
DA - 2026/07/27
VL - 14
SP - RP107635
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.107635
UR - https://doi.org/10.7554/elife.107635
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.107635",
"type": "article-journal",
"title": "Stable excitatory-inhibitory synapse balance despite dynamic turnover",
"container-title": "eLife",
"author": [
{
"family": "Garbett",
"given": "Krassimira A"
},
{
"family": "Allen",
"given": "James P"
},
{
"family": "Lopez",
"given": "Jaybree M"
},
{
"family": "Smith",
"given": "Cassandra M"
},
{
"family": "Sando",
"given": "Richard C"
}
],
"container-title-short": "eLife",
"volume": "14",
"page": "RP107635",
"DOI": "10.7554/elife.107635",
"PMID": "42507759",
"PMCID": "PMC13405638",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.107635",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
27
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1371/journal.pcbi.1014571 [code]
SynAPSeg: A novel dataset and image analysis framework for deep learning-based synapse detection and quantification.
Journal: PLoS computational biology
In common: tifffile, scikit-image, Pillow, 5 other tools, 2 references
[2] doi:10.3390/neurosci7040079 [code]
Effect of Caffeine on Cell Death, Oxidative Stress, and Microglial Morphology in a Ferret Organotypic Brain Slice Model of Hypoxia-Ischemia.
Journal: NeuroSci
In common: ImageJ / Fiji, scikit-image, Pillow, 5 other tools, cellular / molecular
[3] doi:10.1038/s42003-026-10063-9 [code]
Conserved Kir channel mechanisms governing intrinsic excitability in human and rodent parvalbumin neurons.
Journal: Communications biology
In common: tifffile, scikit-image, Pillow, 5 other tools, mouse, cellular / molecular
[4] doi:10.1038/s41586-026-10679-1 [code]
Cortical development dynamics across autism spectrum disorder mouse models.
Journal: Nature
In common: tifffile, scikit-image, scikit-learn, 4 other tools, mouse, cellular / molecular, 1 reference
[5] doi:10.1016/j.stemcr.2026.103015 [code]
Brain injury reactivates a developmental program driving genesis and integration of transient LGE-class interneurons.
Journal: Stem cell reports
In common: ImageJ / Fiji, tifffile, scikit-learn, 4 other tools, mouse
[6] doi:10.7554/elife.111876 [code]
Distinct sensorimotor encoding in tuft dendrites and somata associated with action, correction, and learning.
Journal: eLife
In common: tifffile, Pillow, scikit-learn, 4 other tools, mouse, 1 reference
[7] doi:10.1016/j.isci.2026.117010 [code]
Deep learning-assisted mapping of dendritic spines using sequential 2D two-photon calcium imaging.
Journal: iScience
In common: tifffile, scikit-image, scikit-learn, 4 other tools, cellular / molecular, 1 reference
[8] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: tifffile, scikit-image, Pillow, 5 other tools, mouse
[9] doi:10.1364/boe.605322 [code]
Generalized plaque digitization framework for multi-dimensional mesoscopic images.
Journal: Biomedical optics express
In common: tifffile, scikit-image, Pillow, 5 other tools, mouse
[10] doi:10.7554/elife.109717 [code]
Retrosplenial cortex enables context-dependent goal-directed sensorimotor transformation.
Journal: eLife
In common: tifffile, scikit-image, Pillow, 5 other tools, mouse

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.