OSCR

How Neuromorphic Microstructures Control In Vitro Early-Stage Neuronal Outgrowth.

Code ↔ Paper

3 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 3 matches
  1. [1] § Experimental Section › Image Processing and Analysis › Analysis of Neurites Outgrowth ↔ preprocessing_bulk_vids.py, lines 881–941 · score 0.70 · distance matrix, custom distance, pairwise, DBSCAN, vector, pillar
  2. [2] § Experimental Section › Image Processing and Analysis › Analysis of Neurites Outgrowth ↔ post_processing_data_analysis_singlecsv.py, lines 398–483 · score 0.66 · movement dynamics, DataFrame, conversion factor, median, displacements, min
  3. [3] § Experimental Section › Image Processing and Analysis › Analysis of Neurites Outgrowth ↔ preprocessing_bulk_vids.py, lines 480–521 · score 0.56 · instantaneous velocities, vector, interval, displacements, min, Centroid

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 · 1,596 lines · 74 KB · no license · 2 matches

  1. import os
  2. import cv2
  3. import glob
  4. import numpy as np
  5. import pandas as pd
  6. import matplotlib.pyplot as plt
  7. import math
  8. from pathlib import Path
  9. from sklearn.cluster import DBSCAN
  10. import warnings
  11. warnings.filterwarnings('ignore')
  12. from preprocessing_helpers import *
  13. from sklearn.metrics import pairwise_distances
  14. import traceback
  15. from functools import partial
  16. # Use the custom metric
  17. class NeuritePreprocessor:
  18. """
  19. Improved neurite preprocessing pipeline with better tracking and temporal consistency.
  20. """
  21. def __init__(self, base_folder, outputs_folder="outputs", pixel_to_micron=4.9231): # pixxel per micron metadate resolution
  22. self.base_folder = Path(base_folder)
  23. self.outputs_folder = Path(outputs_folder)
  24. self.pixel_to_micron = pixel_to_micron
  25. self.outputs_folder.mkdir(exist_ok=True, parents=True)
  26. self.all_frame_tracings = {} # Add this to store tracing data
  27. def get_folder_name(self, path):
  28. """Get clean folder name from path."""
  29. return Path(path).name
  30. def rename_files(self, frames_folder):
  31. files = os.listdir(frames_folder)
  32. ndf_files = [f for f in files if f.endswith('.ndf')]
  33. sorted_ndf_files = sorted(ndf_files, key=extract_number)
  34. for i, filename in enumerate(sorted_ndf_files, start=1):
  35. new_name = f"frame_{i:04d}.ndf"
  36. os.rename(os.path.join(frames_folder, filename), os.path.join(frames_folder, new_name))
  37. tif_filename = filename.replace('.ndf', '.tif')
  38. if os.path.exists(os.path.join(frames_folder, tif_filename)):
  39. new_tif_name = new_name.replace('.ndf', '.tif')
  40. os.rename(os.path.join(frames_folder, tif_filename), os.path.join(frames_folder, new_tif_name))
  41. def convert_tif_to_png(self, source_folder, target_folder):
  42. # Ensure target folder exists
  43. if not os.path.exists(target_folder):
  44. os.makedirs(target_folder)
  45. # Loop through all files in the source folder
  46. for filename in os.listdir(source_folder):
  47. if filename.endswith(".tif"): #or filename.endswith(".tiff"):
  48. # Path to the current file
  49. file_path = os.path.join(source_folder, filename)
  50. # Open the TIFF image
  51. with Image.open(file_path) as img:
  52. # Convert the file name to PNG
  53. img = img.convert("RGB")
  54. target_file = os.path.splitext(filename)[0] + ".png"
  55. target_path = os.path.join(target_folder, target_file)
  56. # Save the image as PNG
  57. img.save(target_path, "PNG")
  58. def frames_ndf_to_txt(self, frames_folder, ndf_folder):
  59. for frame_file in os.listdir(frames_folder):
  60. if frame_file.endswith(".tif"): # Adjust based on your image file type
  61. base_filename = os.path.splitext(frame_file)[0]
  62. ndf_path = os.path.join(ndf_folder, base_filename + '.ndf')
  63. txt_path = os.path.join(ndf_folder, base_filename + '.txt')
  64. # Convert NDF to TXT
  65. ndf_to_txt(ndf_path, txt_path)
  66. def load_tracing_data(self, frames_folder):
  67. tracings_data = {}
  68. for frame_file in sorted(os.listdir(frames_folder)):
  69. if frame_file.endswith(".txt"):
  70. frame_index = int(frame_file.split('_')[1].split('.')[0])
  71. txt_path = os.path.join(frames_folder, frame_file)
  72. tracings_data[frame_index] = parse_ndf_content(txt_path)
  73. return tracings_data
  74. def process_frames_and_ndf(self, frames_folder, ndf_folder, output_folder):
  75. """
  76. Process frames and NDF files.
  77. """
  78. # Implement file conversion and data loading based on your format
  79. self.rename_files(frames_folder)
  80. self.convert_tif_to_png(frames_folder, output_folder)
  81. self.frames_ndf_to_txt(frames_folder, ndf_folder)
  82. tracing_data = self.load_tracing_data(ndf_folder)
  83. return tracing_data
  84. def load_grid_mask(self, dataset_folder):
  85. """
  86. Load the grid mask for a specific dataset.
  87. Args:
  88. dataset_folder (Path): Path to the dataset folder (e.g., 'mushroom_p4')
  89. Returns:
  90. numpy.ndarray: Binary mask where 1 indicates pillar areas, 0 indicates flat areas
  91. """
  92. mask_path = self.base_folder / dataset_folder.name / "mask" / "grid_mask.png"
  93. if not mask_path.exists():
  94. print(f"Warning: Grid mask not found at {mask_path}")
  95. return None
  96. try:
  97. mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE)
  98. if mask is None:
  99. print(f"Warning: Could not load grid mask from {mask_path}")
  100. return None
  101. # Normalize mask to binary (0 and 1)
  102. mask_binary = (mask > 0).astype(np.uint8)
  103. print(f"Loaded grid mask from {mask_path}, shape: {mask_binary.shape}")
  104. return mask_binary
  105. except Exception as e:
  106. print(f"Error loading grid mask from {mask_path}: {e}")
  107. return None
  108. def classify_neurites_by_location(self, frame_data, grid_mask, frame_shape=None, dataset_name=None):
  109. """
  110. Classify neurites as being on pillars (inside grid) or control (flat areas).
  111. Special handling for thin_p4_p10 dataset - split into thin_p4 and thin_p10.
  112. For all other datasets, pillar_subtype is the dataset name.
  113. Args:
  114. frame_data (pd.DataFrame): DataFrame containing neurite data with 'centroid_x', 'centroid_y' coordinates
  115. grid_mask (numpy.ndarray): Binary grid mask
  116. frame_shape (tuple): Shape of the original frame (height, width)
  117. dataset_name (str): Name of the dataset to identify p4_p10 cases
  118. Returns:
  119. pd.DataFrame: Updated DataFrame with 'location_type' and 'pillar_subtype' columns
  120. """
  121. if grid_mask is None:
  122. print("No grid mask provided, all neurites classified as 'unknown'")
  123. frame_data['location_type'] = 'unknown'
  124. frame_data['pillar_subtype'] = 'unknown'
  125. return frame_data
  126. if frame_data.empty:
  127. return frame_data
  128. # Ensure coordinates are within mask bounds
  129. if frame_shape is not None:
  130. mask_resized = cv2.resize(grid_mask, (frame_shape[1], frame_shape[0]))
  131. else:
  132. mask_resized = grid_mask
  133. # Special case: only thin_p4_p10 needs spatial splitting
  134. is_thin_p4_p10 = dataset_name and dataset_name.lower() == 'thin_p4_p10'
  135. # For thin_p4_p10, find the split point (approximately half, but upper part is p10)
  136. if is_thin_p4_p10:
  137. split_y = mask_resized.shape[0] // 2
  138. print(f"thin_p4_p10 dataset detected: split at y={split_y} (upper: thin_p10, lower: thin_p4)")
  139. # Classify each neurite based on its position
  140. location_types = []
  141. pillar_subtypes = []
  142. for idx, row in frame_data.iterrows():
  143. x, y = int(row['centroid_x']), int(row['centroid_y'])
  144. # Check if coordinates are within mask bounds
  145. if (0 <= y < mask_resized.shape[0] and
  146. 0 <= x < mask_resized.shape[1]):
  147. if mask_resized[y, x] == 1:
  148. location_types.append('pillar')
  149. # Special handling for thin_p4_p10 - split into thin_p4 and thin_p10
  150. if is_thin_p4_p10:
  151. if y < split_y: # Upper part - thin_p10
  152. pillar_subtypes.append('thin_p10')
  153. else: # Lower part - thin_p4
  154. pillar_subtypes.append('thin_p4')
  155. else:
  156. # For all other datasets, use the dataset name as pillar_subtype
  157. pillar_subtypes.append(dataset_name if dataset_name else 'unknown')
  158. else:
  159. location_types.append('control')
  160. pillar_subtypes.append('control')
  161. else:
  162. location_types.append('out_of_bounds')
  163. pillar_subtypes.append('out_of_bounds')
  164. frame_data['location_type'] = location_types
  165. frame_data['pillar_subtype'] = pillar_subtypes
  166. # Print classification summary
  167. if 'pillar' in location_types:
  168. pillar_count = location_types.count('pillar')
  169. control_count = location_types.count('control')
  170. print(f"Location classification: pillar={pillar_count}, control={control_count}")
  171. if is_thin_p4_p10:
  172. p4_count = pillar_subtypes.count('thin_p4')
  173. p10_count = pillar_subtypes.count('thin_p10')
  174. print(f"Pillar subtype classification: thin_p4={p4_count}, thin_p10={p10_count}")
  175. else:
  176. # Print unique pillar subtypes
  177. unique_pillar_types = set([pt for pt, lt in zip(pillar_subtypes, location_types) if lt == 'pillar'])
  178. print(f"Pillar subtypes: {list(unique_pillar_types)}")
  179. return frame_data
  180. def prepare_tracing_features_for_tracking(self, all_frame_tracings):
  181. """
  182. Prepare tracing features for cross-frame tracking.
  183. Each tracing becomes a data point with its features.
  184. """
  185. all_features = []
  186. for frame_index, frame_data in all_frame_tracings.items():
  187. tracings = frame_data.get('Tracings', {})
  188. for tracing_id, points in tracings.items():
  189. features = self.calculate_neurite_features(points, tracing_id)
  190. if features is not None:
  191. features['frame_index'] = frame_index
  192. features['unique_tracing_id'] = f"f{frame_index}_t{tracing_id}"
  193. # store the original points for later use
  194. features['tracing_points'] = points
  195. all_features.append(features)
  196. return pd.DataFrame(all_features)
  197. def calculate_path_length(self, path):
  198. """Calculate the length of a path given a list of coordinates."""
  199. if len(path) < 2:
  200. return 0.0
  201. length = 0
  202. for i in range(1, len(path)):
  203. dx = path[i][0] - path[i-1][0]
  204. dy = path[i][1] - path[i-1][1]
  205. length += math.sqrt(dx*dx + dy*dy)
  206. return length / self.pixel_to_micron # Convert to microns
  207. def calculate_centroid(self, points):
  208. """Calculate the centroid from a list of points."""
  209. if len(points) == 0:
  210. return np.array([0, 0])
  211. return np.mean(points, axis=0)
  212. def calculate_neurite_features(self, points, tracing_id):
  213. """Calculate comprehensive features for a neurite tracing."""
  214. if len(points) < 2:
  215. return None
  216. points = np.array(points)
  217. # Calculate centroid
  218. centroid = self.calculate_centroid(points)
  219. # Calculate path length
  220. path_length = self.calculate_path_length(points)
  221. # Calculate orientation vector (from start to end)
  222. start_point = points[0]
  223. end_point = points[-1]
  224. direction_vector = end_point - start_point
  225. direction_magnitude = np.linalg.norm(direction_vector)
  226. # Normalize direction vector
  227. if direction_magnitude > 0:
  228. direction_vector = direction_vector / direction_magnitude
  229. # Calculate straightness and tortuosity
  230. if path_length > 0:
  231. endpoint_distance = np.linalg.norm(points[-1] - points[0]) / self.pixel_to_micron
  232. straightness = endpoint_distance / path_length
  233. tortuosity = path_length / endpoint_distance if endpoint_distance > 0 else float('inf')
  234. else:
  235. endpoint_distance = 0.0
  236. straightness = 0.0
  237. tortuosity = 0.0
  238. return {
  239. 'tracing_id': tracing_id,
  240. 'centroid_x': float(centroid[0]),
  241. 'centroid_y': float(centroid[1]),
  242. 'start_x': float(start_point[0]),
  243. 'start_y': float(start_point[1]),
  244. 'end_x': float(end_point[0]),
  245. 'end_y': float(end_point[1]),
  246. 'direction_x': float(direction_vector[0]),
  247. 'direction_y': float(direction_vector[1]),
  248. 'length_microns': path_length,
  249. 'straightness': straightness,
  250. 'tortuosity': tortuosity,
  251. 'endpoint_distance': endpoint_distance,
  252. 'n_points': len(points)
  253. }
  254. def create_neurite_video(self, cluster_id, cluster_data, frames_folder, output_path, movement_metrics=None, total_time_hours=24):
  255. """
  256. Create separate videos for length tracking and centroid dynamics.
  257. """
  258. # Get frame paths
  259. frame_files = sorted(glob.glob(str(Path(frames_folder) / "*.png")))
  260. if not frame_files:
  261. print(f"No frame files found in {frames_folder}")
  262. return
  263. # Get video properties from first frame
  264. first_frame = cv2.imread(frame_files[0])
  265. if first_frame is None:
  266. print(f"Could not read first frame: {frame_files[0]}")
  267. return
  268. height, width = first_frame.shape[:2]
  269. fps = 5 # Frames per second
  270. # Calculate time per frame in minutes
  271. frames = sorted(self.all_frame_tracings.keys())
  272. time_per_frame_minutes = total_time_hours * 60 / len(frames) if frames else 0
  273. # Colors for visualization
  274. colors = {
  275. 'advancing': (0, 255, 0), # Green
  276. 'retracting': (0, 0, 255), # Red
  277. 'stable': (255, 255, 0), # Cyan
  278. 'text': (255, 255, 255), # White
  279. 'trail': (255, 165, 0), # Orange for movement trail
  280. 'length_indicator': (0, 255, 255), # Yellow for length
  281. 'centroid': (255, 0, 255), # Magenta for centroid
  282. 'displacement': (0, 255, 0), # Green for displacement vector
  283. 'velocity_positive': (0, 255, 0), # Green for positive velocity
  284. 'velocity_negative': (0, 0, 255), # Red for negative velocity
  285. 'pillar': (255, 0, 0), # Blue for pillar location
  286. 'control': (0, 255, 0), # Green for control location
  287. 'edge': (255, 255, 0), # Yellow for edge location
  288. }
  289. # Initialize video writers for separate videos
  290. fourcc = cv2.VideoWriter_fourcc(*'mp4v')
  291. # Video 1: Length tracking
  292. length_video_path = output_path / f"cluster_{cluster_id}_length_tracking.mp4"
  293. length_out = cv2.VideoWriter(str(length_video_path), fourcc, fps, (width, height))
  294. # Video 2: Centroid dynamics
  295. dynamics_video_path = output_path / f"cluster_{cluster_id}_centroid_dynamics.mp4"
  296. dynamics_out = cv2.VideoWriter(str(dynamics_video_path), fourcc, fps, (width, height))
  297. # Track data for both videos
  298. centroid_positions = []
  299. length_history = []
  300. frame_history = []
  301. location_history = []
  302. # Store previous frame data for velocity calculation
  303. prev_frame_data = None
  304. for frame_idx, frame_file in enumerate(frame_files, start=1):
  305. frame = cv2.imread(frame_file)
  306. if frame is None:
  307. continue
  308. # Initialize frames for both videos
  309. length_frame = frame.copy() # For length tracking video
  310. dynamics_frame = frame.copy() # For centroid dynamics video
  311. # Get current frame data
  312. frame_data = cluster_data[cluster_data['frame_index'] == frame_idx]
  313. # Initialize current_time_minutes with default value
  314. current_time_minutes = frame_idx * time_per_frame_minutes
  315. if not frame_data.empty:
  316. neurite_row = frame_data.iloc[0]
  317. current_length = neurite_row['length_microns']
  318. current_centroid = (int(neurite_row['centroid_x']), int(neurite_row['centroid_y']))
  319. current_location = neurite_row.get('location_type', 'unknown')
  320. current_time_minutes = neurite_row.get('time_minutes', frame_idx * time_per_frame_minutes)
  321. # Get tracing points from the stored data
  322. tracing_points = []
  323. has_tracing_column = 'tracing_points' in cluster_data.columns
  324. if has_tracing_column:
  325. tracing_points = neurite_row['tracing_points']
  326. # VIDEO 1: LENGTH TRACKING
  327. if tracing_points and len(tracing_points) >= 2:
  328. try:
  329. # Create zoomed view focused on the neurite tracing
  330. zoomed_frame = self.zoom_into_tracing(frame, tracing_points, zoom_padding=100)
  331. print(f"Zoomed view created for frame {frame_idx} with shape {zoomed_frame.shape}")
  332. # Use the zoomed frame directly for length tracking video
  333. length_frame = zoomed_frame
  334. # Draw the neurite tracing on zoomed view
  335. for i in range(1, len(tracing_points)):
  336. pt1 = (int(tracing_points[i-1][0]), int(tracing_points[i-1][1]))
  337. pt2 = (int(tracing_points[i][0]), int(tracing_points[i][1]))
  338. cv2.line(length_frame, pt1, pt2, colors['length_indicator'], 3, cv2.LINE_AA)
  339. # Calculate optimal position for length annotation (on the neurite)
  340. if tracing_points:
  341. # Use midpoint of tracing for annotation
  342. mid_idx = len(tracing_points) // 2
  343. text_x = int(tracing_points[mid_idx][0])
  344. text_y = int(tracing_points[mid_idx][1]) - 20
  345. # Ensure text stays within frame bounds
  346. text_x = max(50, min(text_x, length_frame.shape[1] - 150))
  347. text_y = max(30, min(text_y, length_frame.shape[0] - 10))
  348. # Length annotation directly on neurite
  349. cv2.putText(length_frame, f"Length: {current_length:.1f} um",
  350. (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, colors['text'], 2)
  351. # Location annotation (with color coding)
  352. location_color = colors.get(current_location, colors['text'])
  353. cv2.putText(length_frame, f"Location: {current_location}",
  354. (text_x, text_y + 25),
  355. cv2.FONT_HERSHEY_SIMPLEX, 0.7, location_color, 2)
  356. except Exception as e:
  357. print(f"Error in zoomed view for frame {frame_idx}: {e}")
  358. # Keep the original frame but add error message
  359. cv2.putText(length_frame, "Zoom error",
  360. (width//2-50, height//2),
  361. cv2.FONT_HERSHEY_SIMPLEX, 0.7, colors['text'], 2)
  362. # Add length history graph to length tracking video
  363. length_history.append(current_length)
  364. frame_history.append(frame_idx)
  365. location_history.append(current_location)
  366. # Draw simple length history graph
  367. graph_width, graph_height = 250, 120
  368. graph_x, graph_y = 10, length_frame.shape[0] - graph_height - 10
  369. # Draw graph background
  370. cv2.rectangle(length_frame, (graph_x, graph_y),
  371. (graph_x + graph_width, graph_y + graph_height), (50, 50, 50), -1)
  372. cv2.rectangle(length_frame, (graph_x, graph_y),
  373. (graph_x + graph_width, graph_y + graph_height), colors['text'], 1)
  374. if len(length_history) > 1:
  375. # Normalize values for display
  376. max_length = max(length_history) if max(length_history) > 0 else 1
  377. min_length = min(length_history)
  378. for i in range(1, len(length_history)):
  379. x1 = graph_x + int((i-1) * graph_width / (len(length_history)-1))
  380. y1 = graph_y + graph_height - int((length_history[i-1] - min_length) * graph_height / (max_length - min_length))
  381. x2 = graph_x + int(i * graph_width / (len(length_history)-1))
  382. y2 = graph_y + graph_height - int((length_history[i] - min_length) * graph_height / (max_length - min_length))
  383. # Color code by location if available
  384. location_color = colors.get(location_history[i-1], colors['length_indicator'])
  385. cv2.line(length_frame, (x1, y1), (x2, y2), location_color, 2)
  386. cv2.putText(length_frame, "Length History", (graph_x, graph_y - 5),
  387. cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors['text'], 1)
  388. # Add frame info to length video
  389. frame_info = f"Frame: {frame_idx}/{len(frame_files)} | Time: {current_time_minutes:.1f} min"
  390. cv2.putText(length_frame, frame_info, (10, 30),
  391. cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors['text'], 2)
  392. # VIDEO 2: CENTROID DYNAMICS
  393. # Track centroid positions
  394. centroid_positions.append(current_centroid)
  395. # Draw movement trail with location-based coloring
  396. for i in range(1, len(centroid_positions)):
  397. if i-1 < len(location_history):
  398. trail_color = colors.get(location_history[i-1], colors['trail'])
  399. else:
  400. trail_color = colors['trail']
  401. cv2.line(dynamics_frame, centroid_positions[i-1], centroid_positions[i],
  402. trail_color, 3, cv2.LINE_AA)
  403. # Draw current centroid
  404. cv2.circle(dynamics_frame, current_centroid, 10, colors['centroid'], -1)
  405. cv2.circle(dynamics_frame, current_centroid, 12, colors['text'], 2)
  406. # Calculate and display dynamics metrics
  407. velocity = 0.0
  408. net_displacement = 0.0
  409. signed_direction = 0.0
  410. if len(centroid_positions) > 1:
  411. # Calculate net displacement from first position
  412. start_pos = centroid_positions[0]
  413. net_dx = current_centroid[0] - start_pos[0]
  414. net_dy = current_centroid[1] - start_pos[1]
  415. net_displacement = np.sqrt(net_dx**2 + net_dy**2) / self.pixel_to_micron
  416. signed_direction = np.arctan2(net_dy, net_dx)
  417. # Calculate instantaneous velocity (if we have previous frame data)
  418. if prev_frame_data is not None:
  419. prev_centroid = (prev_frame_data['centroid_x'], prev_frame_data['centroid_y'])
  420. dx = current_centroid[0] - prev_centroid[0]
  421. dy = current_centroid[1] - prev_centroid[1]
  422. frame_distance = np.sqrt(dx**2 + dy**2) / self.pixel_to_micron
  423. time_interval_minutes = time_per_frame_minutes
  424. velocity = frame_distance / time_interval_minutes # um/min
  425. # Draw ONLY instantaneous movement vector (choose one)
  426. vector_scale = 5
  427. end_x = int(current_centroid[0] + dx * vector_scale)
  428. end_y = int(current_centroid[1] + dy * vector_scale)
  429. velocity_color = colors['velocity_positive'] if velocity >= 0 else colors['velocity_negative']
  430. cv2.arrowedLine(dynamics_frame, current_centroid, (end_x, end_y),
  431. velocity_color, 3, tipLength=0.3)
  432. # Add movement metrics from calculate_movement_with_gaps if available
  433. cumulative_text = []
  434. if movement_metrics:
  435. cumulative_text = [
  436. "CUMULATIVE METRICS:",
  437. f"Net Movement: {movement_metrics.get('net_movement', 0):+.2f} um",
  438. f"Advancements: {movement_metrics.get('n_advancements', 0)}",
  439. f"Retractions: {movement_metrics.get('n_retractions', 0)}",
  440. f"Location Changes: {movement_metrics.get('n_location_changes', 0)}"
  441. ]
  442. # Add dynamics information overlay
  443. dynamics_text = [
  444. "INSTANTANEOUS DYNAMICS:",
  445. f"Velocity: {velocity:+.2f} um/min",
  446. f"Net Displacement: {net_displacement:.2f} um",
  447. f"Direction: {np.degrees(signed_direction):.1f}°",
  448. f"Trail Points: {len(centroid_positions)}",
  449. f"Time: {current_time_minutes:.1f} min"
  450. ]
  451. # Combine all text
  452. all_text = dynamics_text + [""] + cumulative_text
  453. for i, text in enumerate(all_text):
  454. y_position = 30 + i * 22
  455. cv2.putText(dynamics_frame, text, (10, y_position),
  456. cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors['text'], 1)
  457. # Create zoomed view of centroid region
  458. if tracing_points and len(tracing_points) >= 2:
  459. try:
  460. zoomed_view, adjusted_points = self.zoom_into_centroid(
  461. frame, current_centroid, tracing_points, zoom_size=150
  462. )
  463. # Overlay zoomed view on dynamics frame
  464. zoom_height, zoom_width = zoomed_view.shape[:2]
  465. # Ensure the zoomed view fits in the corner
  466. if zoom_height <= height - 20 and zoom_width <= width - 20:
  467. dynamics_frame[10:10+zoom_height, width-zoom_width-10:width-10] = zoomed_view
  468. # Draw zoom border
  469. cv2.rectangle(dynamics_frame,
  470. (width-zoom_width-15, 5),
  471. (width-5, 5+zoom_height+10),
  472. colors['text'], 2)
  473. cv2.putText(dynamics_frame, "Zoomed Centroid",
  474. (width-zoom_width-10, 20),
  475. cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors['text'], 1)
  476. except Exception as e:
  477. print(f"Error creating centroid zoom for frame {frame_idx}: {e}")
  478. # Store current frame data for next iteration
  479. prev_frame_data = neurite_row
  480. else:
  481. # No data for this frame - just show the frames without annotations
  482. # For length video, we can show a message
  483. cv2.putText(length_frame, "No neurite data",
  484. (width//2-80, height//2),
  485. cv2.FONT_HERSHEY_SIMPLEX, 0.7, colors['text'], 2)
  486. # For dynamics video, just show the frame as is
  487. # Add titles to both videos
  488. cv2.putText(length_frame, "LENGTH TRACKING", (width//2-100, 30),
  489. cv2.FONT_HERSHEY_SIMPLEX, 1, colors['text'], 2)
  490. cv2.putText(dynamics_frame, "CENTROID DYNAMICS", (width//2-120, 30),
  491. cv2.FONT_HERSHEY_SIMPLEX, 1, colors['text'], 2)
  492. # Add cluster info to both videos
  493. cluster_info = f"Cluster: {cluster_id}"
  494. cv2.putText(length_frame, cluster_info, (width-150, 30),
  495. cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors['text'], 2)
  496. cv2.putText(dynamics_frame, cluster_info, (width-150, 30),
  497. cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors['text'], 2)
  498. # Write frames to separate videos
  499. length_out.write(length_frame)
  500. dynamics_out.write(dynamics_frame)
  501. # Release video writers
  502. length_out.release()
  503. dynamics_out.release()
  504. print(f"Length tracking video saved: {length_video_path}")
  505. print(f"Centroid dynamics video saved: {dynamics_video_path}")
  506. def zoom_into_tracing(self, image, tracing_points, zoom_padding=80):
  507. """
  508. Zoom into the area around the tracing points to better visualize the neurite.
  509. """
  510. if not tracing_points:
  511. print("No tracing points provided for zooming.")
  512. return image.copy()
  513. # Convert tracing points to numpy array
  514. points = np.array(tracing_points, dtype=np.float32)
  515. # Calculate bounding box around tracing
  516. min_x = np.min(points[:, 0])
  517. max_x = np.max(points[:, 0])
  518. min_y = np.min(points[:, 1])
  519. max_y = np.max(points[:, 1])
  520. # Add padding
  521. min_x = max(0, min_x - zoom_padding)
  522. max_x = min(image.shape[1], max_x + zoom_padding)
  523. min_y = max(0, min_y - zoom_padding)
  524. max_y = min(image.shape[0], max_y + zoom_padding)
  525. # Calculate dimensions
  526. bbox_width = max_x - min_x
  527. bbox_height = max_y - min_y
  528. # Ensure minimum size
  529. if bbox_width < 200:
  530. center_x = (min_x + max_x) / 2
  531. min_x = max(0, center_x - 100)
  532. max_x = min(image.shape[1], center_x + 100)
  533. bbox_width = max_x - min_x
  534. if bbox_height < 200:
  535. center_y = (min_y + max_y) / 2
  536. min_y = max(0, center_y - 100)
  537. max_y = min(image.shape[0], center_y + 100)
  538. bbox_height = max_y - min_y
  539. # Extract ROI
  540. zoomed_image = image[int(min_y):int(max_y), int(min_x):int(max_x)].copy()
  541. # Resize if too small for consistent display
  542. if zoomed_image.shape[0] < 300 or zoomed_image.shape[1] < 300:
  543. scale_factor = max(300/zoomed_image.shape[0], 300/zoomed_image.shape[1])
  544. new_width = int(zoomed_image.shape[1] * scale_factor)
  545. new_height = int(zoomed_image.shape[0] * scale_factor)
  546. zoomed_image = cv2.resize(zoomed_image, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
  547. return zoomed_image
  548. def zoom_into_centroid(self, image, centroid, tracing_points, zoom_size=150, padding_color=(0, 0, 0)):
  549. """Enhanced zoom into centroid region with better coordinate adjustment."""
  550. x, y = int(centroid[0]), int(centroid[1])
  551. # Calculate required padding based on image boundaries
  552. pad_top = max(0, zoom_size - y)
  553. pad_bottom = max(0, (y + zoom_size) - image.shape[0])
  554. pad_left = max(0, zoom_size - x)
  555. pad_right = max(0, (x + zoom_size) - image.shape[1])
  556. # Apply padding
  557. padded_image = cv2.copyMakeBorder(
  558. image,
  559. pad_top, pad_bottom, pad_left, pad_right,
  560. cv2.BORDER_CONSTANT, value=padding_color
  561. )
  562. # Adjust centroid position for padding
  563. padded_x = x + pad_left
  564. padded_y = y + pad_top
  565. # Define ROI
  566. start_x = padded_x - zoom_size
  567. end_x = padded_x + zoom_size
  568. start_y = padded_y - zoom_size
  569. end_y = padded_y + zoom_size
  570. # Extract zoomed area
  571. zoomed_image = padded_image[start_y:end_y, start_x:end_x].copy()
  572. # Adjust tracing points to new coordinates
  573. adjusted_tracing_points = []
  574. for point in tracing_points:
  575. adj_x = int(point[0] - start_x + pad_left)
  576. adj_y = int(point[1] - start_y + pad_top)
  577. adjusted_tracing_points.append((adj_x, adj_y))
  578. # Draw enhanced tracing on zoomed image
  579. for i in range(1, len(adjusted_tracing_points)):
  580. cv2.line(zoomed_image, adjusted_tracing_points[i-1],
  581. adjusted_tracing_points[i], (0, 255, 0), 3)
  582. # Draw centroid in zoomed view
  583. centroid_zoom_x = zoom_size
  584. centroid_zoom_y = zoom_size
  585. cv2.circle(zoomed_image, (centroid_zoom_x, centroid_zoom_y), 8, (255, 0, 255), -1)
  586. cv2.circle(zoomed_image, (centroid_zoom_x, centroid_zoom_y), 10, (255, 255, 255), 2)
  587. # Add crosshairs for centroid
  588. cv2.line(zoomed_image, (centroid_zoom_x-15, centroid_zoom_y),
  589. (centroid_zoom_x+15, centroid_zoom_y), (255, 255, 255), 1)
  590. cv2.line(zoomed_image, (centroid_zoom_x, centroid_zoom_y-15),
  591. (centroid_zoom_x, centroid_zoom_y+15), (255, 255, 255), 1)
  592. # Add scale bar (assuming 10 microns)
  593. scale_pixels = int(10 * self.pixel_to_micron)
  594. scale_y = zoom_size - 20
  595. cv2.line(zoomed_image, (20, scale_y), (20 + scale_pixels, scale_y),
  596. (255, 255, 255), 3)
  597. cv2.putText(zoomed_image, "10 um", (25, scale_y - 10),
  598. cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1)
  599. return zoomed_image, adjusted_tracing_points
  600. def adaptive_eps(self, frame_index, base_eps=30, decay_factor=0.1, min_eps=10):
  601. """
  602. Adaptive epsilon for DBSCAN based on frame progression.
  603. Later frames may have more spread, so we adjust clustering sensitivity.
  604. """
  605. return max(min_eps, base_eps * (1 + decay_factor * frame_index))
  606. def cluster_neurites_across_frames(self, features_df, grid_mask, dataset_name,
  607. spatial_eps=50, temporal_eps=5, min_samples=2,
  608. max_temporal_gap=20):
  609. """
  610. Use DBSCAN to cluster neurite appearances across ALL frames to establish identity.
  611. PROPERLY handles temporal gaps with adaptive epsilon.
  612. Args:
  613. features_df: DataFrame with neurite features from all frames
  614. grid_mask: Binary mask for pillar/control classification
  615. dataset_name: Name of the dataset for pillar subtype classification
  616. spatial_eps: Maximum spatial distance between neurites to be considered same cluster
  617. temporal_eps: Maximum temporal distance (in frames) to be considered same neurite
  618. min_samples: Minimum number of appearances to form a cluster
  619. max_temporal_gap: Maximum allowed gap between appearances for same neurite
  620. """
  621. if features_df.empty:
  622. return features_df
  623. # First, classify neurites by location using the grid mask
  624. features_df = self.classify_neurites_by_location(features_df, grid_mask, dataset_name=dataset_name)
  625. # Prepare features for DBSCAN clustering with PROPER temporal handling
  626. cluster_features = []
  627. feature_indices = []
  628. # Calculate adaptive epsilon based on frame distribution
  629. frame_range = features_df['frame_index'].max() - features_df['frame_index'].min()
  630. adaptive_spatial_eps = self.adaptive_eps(frame_range, base_eps=spatial_eps)
  631. print(f"Using adaptive spatial epsilon: {adaptive_spatial_eps:.1f} (frame range: {frame_range})")
  632. for idx, row in features_df.iterrows():
  633. # Combine spatial, temporal, and location features with PROPER scaling
  634. # Scale temporal component to be comparable to spatial distances
  635. temporal_component = row['frame_index'] * (adaptive_spatial_eps / temporal_eps)
  636. features = [
  637. row['centroid_x'], # Spatial x
  638. row['centroid_y'], # Spatial y
  639. temporal_component, # PROPERLY scaled temporal component
  640. row['length_microns'] * 0.1, # Length similarity (lightly weighted)
  641. row['direction_x'] * 20, # Direction similarity
  642. row['direction_y'] * 20, # Direction similarity
  643. # Add location features - encode location as numeric values
  644. 100 if row.get('location_type') == 'pillar' else 0, # Strong weight for pillar vs control
  645. ]
  646. cluster_features.append(features)
  647. feature_indices.append(idx)
  648. cluster_features = np.array(cluster_features)
  649. # Use DBSCAN with PROPER epsilon that accounts for combined feature space
  650. # The epsilon should be scaled to account for the multi-dimensional space
  651. combined_eps = np.sqrt(adaptive_spatial_eps**2 + adaptive_spatial_eps**2 +
  652. (adaptive_spatial_eps)**2) # Account for x, y, and temporal dimensions
  653. clustering = DBSCAN(
  654. eps=combined_eps, # Properly scaled for combined feature space
  655. min_samples=min_samples,
  656. metric='euclidean'
  657. ).fit(cluster_features)
  658. # Assign cluster labels
  659. features_df = features_df.copy()
  660. features_df['global_id'] = clustering.labels_
  661. clustered_df = features_df.iloc[feature_indices].copy()
  662. clustered_df['global_id'] = clustering.labels_
  663. # Print clustering results
  664. n_clusters = len(set(clustering.labels_)) - (1 if -1 in clustering.labels_ else 0)
  665. n_noise = list(clustering.labels_).count(-1)
  666. print(f"DBSCAN clustering completed: {n_clusters} clusters, {n_noise} noise points")
  667. print(f"Cluster distribution: {np.bincount(clustering.labels_ + 1)}") # +1 to handle -1 labels
  668. # Post-process clusters with BETTER temporal gap handling
  669. features_df = self._validate_temporal_consistency_improved(features_df, max_temporal_gap)
  670. return features_df,clustered_df
  671. def _validate_temporal_consistency_improved(self, df, max_temporal_gap=20):
  672. """
  673. Improved temporal consistency validation that handles gaps more intelligently.
  674. """
  675. valid_df = df.copy()
  676. for cluster_id in valid_df['global_id'].unique():
  677. if cluster_id == -1: # Skip noise
  678. continue
  679. cluster_data = valid_df[valid_df['global_id'] == cluster_id]
  680. if len(cluster_data) > 1:
  681. frames = sorted(cluster_data['frame_index'])
  682. # Calculate all temporal gaps
  683. gaps = [frames[i+1] - frames[i] for i in range(len(frames)-1)]
  684. max_gap = max(gaps) if gaps else 0
  685. # Calculate temporal coverage statistics
  686. total_time_span = frames[-1] - frames[0] + 1
  687. coverage_ratio = len(frames) / total_time_span
  688. # More intelligent gap handling:
  689. # - Allow larger gaps if overall coverage is good
  690. # - Consider the number of large gaps, not just the maximum
  691. large_gaps = sum(1 for gap in gaps if gap > max_temporal_gap)
  692. # Location consistency
  693. location_types = cluster_data['location_type'].value_counts()
  694. dominant_location = location_types.index[0]
  695. location_consistency = location_types[dominant_location] / len(cluster_data)
  696. # Decision criteria for keeping/splitting cluster
  697. should_split = False
  698. if large_gaps > 2: # Too many large gaps
  699. should_split = True
  700. reason = f"too many large gaps ({large_gaps})"
  701. elif max_gap > max_temporal_gap * 2: # One extremely large gap
  702. should_split = True
  703. reason = f"extremely large gap ({max_gap} frames)"
  704. elif coverage_ratio < 0.3 and max_gap > max_temporal_gap: # Poor coverage with large gap
  705. should_split = True
  706. reason = f"poor coverage ({coverage_ratio:.2f}) with large gap ({max_gap} frames)"
  707. elif location_consistency < 0.6: # Very inconsistent location
  708. should_split = True
  709. reason = f"low location consistency ({location_consistency:.2f})"
  710. if should_split:
  711. print(f"Cluster {cluster_id} split: {reason}")
  712. valid_df.loc[valid_df['global_id'] == cluster_id, 'global_id'] = -1
  713. else:
  714. # Store gap information for analysis
  715. valid_df.loc[valid_df['global_id'] == cluster_id, 'max_gap'] = max_gap
  716. valid_df.loc[valid_df['global_id'] == cluster_id, 'coverage_ratio'] = coverage_ratio
  717. valid_df.loc[valid_df['global_id'] == cluster_id, 'large_gaps_count'] = large_gaps
  718. return valid_df
  719. def cluster_neurites_with_custom_metric(self, features_df, grid_mask, dataset_name,
  720. spatial_eps=50, temporal_eps=5, min_samples=2,
  721. output_folder=None):
  722. """
  723. Alternative approach: Use custom distance metric that properly handles temporal gaps.
  724. This is more robust for handling neurites that disappear and reappear.
  725. """
  726. if features_df.empty:
  727. return features_df
  728. # Classify by location first
  729. features_df = self.classify_neurites_by_location(features_df, grid_mask, dataset_name=dataset_name)
  730. # Prepare features with clear indexing
  731. features_list = []
  732. feature_indices = []
  733. print("Preparing features for custom distance metric...")
  734. for idx, row in features_df.iterrows():
  735. features = [
  736. row['centroid_x'], # Index 0: spatial x
  737. row['centroid_y'], # Index 1: spatial y
  738. row['frame_index'], # Index 2: temporal (frame number)
  739. row['length_microns'], # Index 3: length
  740. row['direction_x'], # Index 4: direction vector x
  741. row['direction_y'], # Index 5: direction vector y
  742. 1 if row.get('location_type') == 'pillar' else 0, # Index 6: location flag
  743. ]
  744. features_list.append(features)
  745. feature_indices.append(idx)
  746. features_array = np.array(features_list)
  747. print("Computing pairwise distances...")
  748. # Use partial to create the metric function with temporal_eps
  749. metric_function = partial(neurite_distance, temporal_eps=temporal_eps)
  750. # Compute distance matrix
  751. distance_matrix = pairwise_distances(features_array, metric=metric_function)
  752. # Plot distance matrix heatmap
  753. if output_folder:
  754. self.plot_distance_matrix_heatmap(distance_matrix, features_df, output_folder, dataset_name)
  755. # Use DBSCAN with precomputed distances
  756. print("Performing DBSCAN clustering...")
  757. clustering = DBSCAN(
  758. eps=spatial_eps, # This now refers to our custom distance threshold
  759. min_samples=min_samples,
  760. metric='precomputed'
  761. ).fit(distance_matrix)
  762. features_df = features_df.copy()
  763. features_df['global_id'] = clustering.labels_
  764. clustered_df = features_df.iloc[feature_indices].copy()
  765. clustered_df['global_id'] = clustering.labels_
  766. # Analyze cluster quality
  767. self.analyze_cluster_quality(features_df, distance_matrix, output_folder, dataset_name)
  768. return features_df, clustered_df
  769. def plot_distance_matrix_heatmap(self, distance_matrix, features_df, output_folder, dataset_name):
  770. """
  771. Plot heatmap of the distance matrix to visualize clustering structure.
  772. """
  773. print("Creating distance matrix heatmap...")
  774. # Create output directory
  775. heatmap_dir = Path(output_folder) / "distance_analysis"
  776. heatmap_dir.mkdir(exist_ok=True)
  777. # Sort by frame index for better visualization
  778. sort_indices = np.argsort(features_df['frame_index'].values)
  779. sorted_distances = distance_matrix[sort_indices][:, sort_indices]
  780. sorted_frames = features_df['frame_index'].values[sort_indices]
  781. # Create the heatmap
  782. fig, ax = plt.subplots(figsize=(12, 10))
  783. # Use a diverging colormap
  784. im = ax.imshow(sorted_distances, cmap='viridis', aspect='auto',
  785. interpolation='nearest')
  786. # Add colorbar
  787. cbar = plt.colorbar(im, ax=ax, shrink=0.8)
  788. cbar.set_label('Custom Distance Metric', rotation=270, labelpad=20)
  789. # Add frame labels for some reference points
  790. n_ticks = min(10, len(sorted_frames))
  791. tick_indices = np.linspace(0, len(sorted_frames)-1, n_ticks, dtype=int)
  792. tick_labels = [f"F{sorted_frames[i]}" for i in tick_indices]
  793. ax.set_xticks(tick_indices)
  794. ax.set_yticks(tick_indices)
  795. ax.set_xticklabels(tick_labels, rotation=45)
  796. ax.set_yticklabels(tick_labels)
  797. ax.set_xlabel('Frame Index (sorted)')
  798. ax.set_ylabel('Frame Index (sorted)')
  799. ax.set_title(f'Neurite Distance Matrix\n{dataset_name}\n'
  800. f'({len(features_df)} observations)', fontsize=14, pad=20)
  801. # Add grid
  802. ax.grid(False)
  803. plt.tight_layout()
  804. # Save the plot
  805. heatmap_path = heatmap_dir / f"distance_matrix_{dataset_name}.png"
  806. plt.savefig(heatmap_path, dpi=300, bbox_inches='tight')
  807. plt.close()
  808. print(f"Distance matrix heatmap saved: {heatmap_path}")
  809. # Also create a smaller version showing cluster structure after clustering
  810. self.plot_clustered_distance_matrix(distance_matrix, features_df, heatmap_dir, dataset_name)
  811. def plot_clustered_distance_matrix(self, distance_matrix, features_df, output_dir, dataset_name):
  812. """
  813. Plot distance matrix sorted by cluster assignments to show clustering structure.
  814. """
  815. # Only plot if we have clusters
  816. if 'global_id' not in features_df.columns:
  817. return
  818. # Sort by cluster ID, then by frame index
  819. features_df_sorted = features_df.sort_values(['global_id', 'frame_index'])
  820. sort_indices = features_df_sorted.index
  821. # Reorder distance matrix
  822. clustered_distances = distance_matrix[sort_indices][:, sort_indices]
  823. cluster_ids = features_df_sorted['global_id'].values
  824. # Create the heatmap
  825. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))
  826. # Plot 1: Full distance matrix
  827. im1 = ax1.imshow(clustered_distances, cmap='viridis', aspect='auto')
  828. ax1.set_title(f'Distance Matrix (Cluster Sorted)\n{dataset_name}')
  829. ax1.set_xlabel('Observation Index')
  830. ax1.set_ylabel('Observation Index')
  831. plt.colorbar(im1, ax=ax1, shrink=0.8)
  832. # Plot 2: Zoomed in to show cluster blocks
  833. # Show only first 50x50 for clarity, or all if smaller
  834. zoom_size = min(50, len(clustered_distances))
  835. im2 = ax2.imshow(clustered_distances[:zoom_size, :zoom_size], cmap='viridis', aspect='auto')
  836. ax2.set_title(f'Zoomed View (First {zoom_size} observations)')
  837. ax2.set_xlabel('Observation Index')
  838. ax2.set_ylabel('Observation Index')
  839. plt.colorbar(im2, ax=ax2, shrink=0.8)
  840. # Add cluster boundaries to zoomed plot
  841. unique_clusters = np.unique(cluster_ids[:zoom_size])
  842. for cluster_id in unique_clusters:
  843. if cluster_id == -1:
  844. continue
  845. cluster_mask = cluster_ids[:zoom_size] == cluster_id
  846. if np.any(cluster_mask):
  847. cluster_indices = np.where(cluster_mask)[0]
  848. start_idx = cluster_indices[0]
  849. end_idx = cluster_indices[-1]
  850. # Add rectangle around cluster
  851. rect = plt.Rectangle((start_idx-0.5, start_idx-0.5),
  852. end_idx - start_idx + 1,
  853. end_idx - start_idx + 1,
  854. fill=False, edgecolor='red', linewidth=2)
  855. ax2.add_patch(rect)
  856. plt.tight_layout()
  857. cluster_heatmap_path = output_dir / f"clustered_distance_matrix_{dataset_name}.png"
  858. plt.savefig(cluster_heatmap_path, dpi=300, bbox_inches='tight')
  859. plt.close()
  860. print(f"Clustered distance matrix saved: {cluster_heatmap_path}")
  861. def analyze_cluster_quality(self, features_df, distance_matrix, output_folder, dataset_name):
  862. """
  863. Analyze and visualize cluster quality based on distance matrix.
  864. """
  865. if 'global_id' not in features_df.columns:
  866. return
  867. valid_clusters = features_df[features_df['global_id'] != -1]
  868. if valid_clusters.empty:
  869. return
  870. # Calculate intra-cluster and inter-cluster distances
  871. cluster_metrics = {}
  872. for cluster_id in valid_clusters['global_id'].unique():
  873. cluster_indices = valid_clusters[valid_clusters['global_id'] == cluster_id].index
  874. cluster_mask = features_df.index.isin(cluster_indices)
  875. # Intra-cluster distances (distances within the same cluster)
  876. intra_cluster_distances = distance_matrix[cluster_mask][:, cluster_mask]
  877. intra_mean = intra_cluster_distances.mean() if len(intra_cluster_distances) > 0 else 0
  878. # Inter-cluster distances (distances to other clusters)
  879. other_cluster_mask = (features_df['global_id'] != cluster_id) & (features_df['global_id'] != -1)
  880. if np.any(other_cluster_mask):
  881. inter_cluster_distances = distance_matrix[cluster_mask][:, other_cluster_mask]
  882. inter_mean = inter_cluster_distances.mean()
  883. else:
  884. inter_mean = 0
  885. cluster_metrics[cluster_id] = {
  886. 'size': len(cluster_indices),
  887. 'intra_cluster_mean': intra_mean,
  888. 'inter_cluster_mean': inter_mean,
  889. 'separation_ratio': inter_mean / intra_mean if intra_mean > 0 else float('inf')
  890. }
  891. # Create cluster quality visualization
  892. fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 12))
  893. # Plot 1: Cluster sizes
  894. cluster_sizes = [metrics['size'] for metrics in cluster_metrics.values()]
  895. ax1.bar(range(len(cluster_sizes)), cluster_sizes)
  896. ax1.set_xlabel('Cluster ID')
  897. ax1.set_ylabel('Number of Observations')
  898. ax1.set_title('Cluster Sizes')
  899. ax1.grid(True, alpha=0.3)
  900. # Plot 2: Intra vs Inter cluster distances
  901. intra_means = [metrics['intra_cluster_mean'] for metrics in cluster_metrics.values()]
  902. inter_means = [metrics['inter_cluster_mean'] for metrics in cluster_metrics.values()]
  903. x_pos = np.arange(len(cluster_metrics))
  904. width = 0.35
  905. ax2.bar(x_pos - width/2, intra_means, width, label='Intra-cluster', alpha=0.7)
  906. ax2.bar(x_pos + width/2, inter_means, width, label='Inter-cluster', alpha=0.7)
  907. ax2.set_xlabel('Cluster ID')
  908. ax2.set_ylabel('Mean Distance')
  909. ax2.set_title('Intra-cluster vs Inter-cluster Distances')
  910. ax2.legend()
  911. ax2.grid(True, alpha=0.3)
  912. # Plot 3: Separation ratios
  913. separation_ratios = [metrics['separation_ratio'] for metrics in cluster_metrics.values()]
  914. ax3.bar(range(len(separation_ratios)), separation_ratios)
  915. ax3.set_xlabel('Cluster ID')
  916. ax3.set_ylabel('Separation Ratio (Inter/Intra)')
  917. ax3.set_title('Cluster Separation Quality\n(Higher = Better Separation)')
  918. ax3.grid(True, alpha=0.3)
  919. # Plot 4: Summary statistics
  920. ax4.axis('off')
  921. summary_text = f"Cluster Quality Summary - {dataset_name}\n\n"
  922. summary_text += f"Total clusters: {len(cluster_metrics)}\n"
  923. summary_text += f"Total observations: {len(valid_clusters)}\n"
  924. summary_text += f"Average cluster size: {np.mean(cluster_sizes):.1f}\n"
  925. summary_text += f"Average intra-cluster distance: {np.mean(intra_means):.2f}\n"
  926. summary_text += f"Average inter-cluster distance: {np.mean(inter_means):.2f}\n"
  927. summary_text += f"Average separation ratio: {np.mean(separation_ratios):.2f}\n"
  928. summary_text += f"Noise points: {len(features_df[features_df['global_id'] == -1])}"
  929. ax4.text(0.1, 0.9, summary_text, transform=ax4.transAxes, fontsize=12,
  930. verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
  931. plt.tight_layout()
  932. quality_path = Path(output_folder) / "distance_analysis" / f"cluster_quality_{dataset_name}.png"
  933. plt.savefig(quality_path, dpi=300, bbox_inches='tight')
  934. plt.close()
  935. print(f"Cluster quality analysis saved: {quality_path}")
  936. def calculate_movement_with_gaps(self, tracked_df):
  937. """
  938. Calculate movement metrics that account for gaps in appearance.
  939. FIXED: Now properly handles negative values for retraction.
  940. """
  941. movement_metrics = {}
  942. for global_id, group in tracked_df.groupby('global_id'):
  943. if global_id == -1:
  944. continue
  945. group = group.sort_values('frame_index')
  946. movements = []
  947. advancements = []
  948. retractions = []
  949. location_changes = []
  950. centroids = group[['centroid_x', 'centroid_y']].values
  951. frames = group['frame_index'].values
  952. lengths = group['length_microns'].values
  953. directions = group[['direction_x', 'direction_y']].values
  954. locations = group['location_type'].values if 'location_type' in group.columns else ['unknown'] * len(group)
  955. pillar_subtypes = group['pillar_subtype'].values if 'pillar_subtype' in group.columns else ['unknown'] * len(group)
  956. # Calculate movements between consecutive appearances
  957. for i in range(1, len(group)):
  958. frame_gap = frames[i] - frames[i-1]
  959. # Spatial movement
  960. dx = centroids[i][0] - centroids[i-1][0]
  961. dy = centroids[i][1] - centroids[i-1][1]
  962. movement_distance = np.sqrt(dx**2 + dy**2)
  963. # Movement direction relative to neurite orientation
  964. prev_direction = directions[i-1]
  965. movement_vector = np.array([dx, dy])
  966. # Location change tracking
  967. location_change = locations[i] != locations[i-1]
  968. pillar_subtype_change = pillar_subtypes[i] != pillar_subtypes[i-1]
  969. if location_change:
  970. location_changes.append({
  971. 'from_frame': frames[i-1],
  972. 'to_frame': frames[i],
  973. 'from_location': locations[i-1],
  974. 'to_location': locations[i],
  975. 'from_subtype': pillar_subtypes[i-1],
  976. 'to_subtype': pillar_subtypes[i]
  977. })
  978. if np.linalg.norm(prev_direction) > 0 and np.linalg.norm(movement_vector) > 0:
  979. # Project movement onto neurite direction
  980. movement_component = np.dot(movement_vector, prev_direction)
  981. # Normalize by frame gap to get rate
  982. movement_rate = movement_component / frame_gap if frame_gap > 0 else 0
  983. if movement_component > 0:
  984. advancements.append({
  985. 'distance': movement_component, # Positive for advancement
  986. 'rate': movement_rate, # Positive rate
  987. 'frame_gap': frame_gap,
  988. 'from_frame': frames[i-1],
  989. 'to_frame': frames[i],
  990. 'from_location': locations[i-1],
  991. 'to_location': locations[i]
  992. })
  993. else:
  994. retractions.append({
  995. 'distance': movement_component, # Negative for retraction (NOT absolute value!)
  996. 'rate': movement_rate, # Negative rate
  997. 'frame_gap': frame_gap,
  998. 'from_frame': frames[i-1],
  999. 'to_frame': frames[i],
  1000. 'from_location': locations[i-1],
  1001. 'to_location': locations[i]
  1002. })
  1003. movements.append({
  1004. 'frame_gap': frame_gap,
  1005. 'distance': movement_distance,
  1006. 'dx': dx,
  1007. 'dy': dy,
  1008. 'location_change': location_change,
  1009. 'subtype_change': pillar_subtype_change
  1010. })
  1011. # Calculate overall metrics - NOW WITH NEGATIVE VALUES
  1012. total_advancement = sum(a['distance'] for a in advancements) # Positive
  1013. total_retraction = sum(r['distance'] for r in retractions) # Negative
  1014. net_movement = total_advancement + total_retraction # Since retraction is negative
  1015. # Calculate appearance statistics
  1016. appearance_frames = len(group)
  1017. total_frames_span = frames[-1] - frames[0] + 1
  1018. appearance_ratio = appearance_frames / total_frames_span
  1019. # Location statistics
  1020. dominant_location = max(set(locations), key=list(locations).count)
  1021. location_consistency = list(locations).count(dominant_location) / len(locations)
  1022. movement_metrics[global_id] = {
  1023. 'total_advancement': total_advancement,
  1024. 'total_retraction': total_retraction, # Now negative
  1025. 'net_movement': net_movement, # Can be positive or negative
  1026. 'appearance_frames': appearance_frames,
  1027. 'total_frames_span': total_frames_span,
  1028. 'appearance_ratio': appearance_ratio,
  1029. 'n_advancements': len(advancements),
  1030. 'n_retractions': len(retractions),
  1031. 'n_location_changes': len(location_changes),
  1032. 'dominant_location': dominant_location,
  1033. 'location_consistency': location_consistency,
  1034. 'avg_advancement_rate': np.mean([a['rate'] for a in advancements]) if advancements else 0,
  1035. 'avg_retraction_rate': np.mean([r['rate'] for r in retractions]) if retractions else 0,
  1036. }
  1037. return movement_metrics
  1038. def process_all_frames_improved(self, all_frame_tracings, frames_folder, total_time_hours=24):
  1039. """
  1040. Improved processing using DBSCAN for cross-frame neurite identity with location classification.
  1041. """
  1042. print("Preparing tracing features...")
  1043. features_df = self.prepare_tracing_features_for_tracking(all_frame_tracings)
  1044. if features_df.empty:
  1045. print("No valid tracing features found")
  1046. return pd.DataFrame()
  1047. # Load grid mask for this dataset
  1048. dataset_folder = Path(frames_folder).parent
  1049. dataset_name = dataset_folder.name
  1050. grid_mask = self.load_grid_mask(dataset_folder)
  1051. print(f"Clustering {len(features_df)} neurite appearances across frames with location classification...")
  1052. # Use DBSCAN to establish neurite identity across all frames with location features
  1053. # For datasets with frequent disappearances:
  1054. # clustered_df = self.cluster_neurites_across_frames(
  1055. # features_df, grid_mask, dataset_name,
  1056. # spatial_eps=60, # More spatial tolerance
  1057. # temporal_eps=10, # Larger temporal window
  1058. # max_temporal_gap=30, # Allow larger gaps
  1059. # min_samples=2 # Fewer appearances needed
  1060. # )
  1061. # Or use custom metric for maximum control:
  1062. clustered_df, clustered_df_tracings = self.cluster_neurites_with_custom_metric(
  1063. features_df, grid_mask, dataset_name,
  1064. spatial_eps=45,
  1065. temporal_eps=15,
  1066. output_folder=frames_folder # This enables the heatmaps!
  1067. )
  1068. print("Calculating movement metrics with gap handling...")
  1069. movement_metrics = self.calculate_movement_with_gaps(clustered_df)
  1070. metric_columns = [
  1071. 'total_advancement', 'total_retraction', 'net_movement',
  1072. 'appearance_frames', 'total_frames_span', 'appearance_ratio',
  1073. 'n_advancements', 'n_retractions', 'n_location_changes',
  1074. 'dominant_location', 'location_consistency',
  1075. 'avg_advancement_rate', 'avg_retraction_rate'
  1076. ]
  1077. for col in metric_columns:
  1078. clustered_df[col] = 0.0 if col not in ['dominant_location'] else 'unknown'
  1079. # Add movement metrics to dataframe
  1080. for global_id, metrics in movement_metrics.items():
  1081. mask = clustered_df['global_id'] == global_id
  1082. for key, value in metrics.items():
  1083. if key in metric_columns: # Skip nested data
  1084. clustered_df.loc[mask, key] = value
  1085. # Add time information in hours AND minutes
  1086. frames = sorted(all_frame_tracings.keys())
  1087. time_per_frame_hours = total_time_hours / len(frames) if frames else 0
  1088. time_per_frame_minutes = total_time_hours * 60 / len(frames) if frames else 0
  1089. clustered_df['time_hours'] = clustered_df['frame_index'] * time_per_frame_hours
  1090. clustered_df['time_minutes'] = clustered_df['frame_index'] * time_per_frame_minutes
  1091. # Print comprehensive summary
  1092. self._print_processing_summary(clustered_df, movement_metrics)
  1093. # CREATE EXPLANATION VIDEOS FOR EACH CLUSTER
  1094. print("\nCreating explanation videos for each cluster...")
  1095. self.create_cluster_explanation_videos(clustered_df_tracings, movement_metrics, frames_folder, total_time_hours)
  1096. return clustered_df
  1097. def create_cluster_explanation_videos(self, clustered_df, movement_metrics, frames_folder, total_time_hours=24):
  1098. """
  1099. Create explanation videos for each cluster showing length changes and centroid dynamics.
  1100. """
  1101. # Get unique clusters (excluding noise)
  1102. valid_clusters = clustered_df[clustered_df['global_id'] != -1]['global_id'].unique()
  1103. print(f"Tracing in clustered_df: {clustered_df['tracing_points']} valid tracings")
  1104. print(f"Creating videos for {len(valid_clusters)} clusters...")
  1105. for cluster_id in valid_clusters:
  1106. try:
  1107. print(f"Creating video for cluster {cluster_id}...")
  1108. # Get cluster data
  1109. cluster_data = clustered_df[clustered_df['global_id'] == cluster_id]
  1110. # Get movement metrics for this cluster
  1111. cluster_movement = movement_metrics.get(cluster_id, {})
  1112. # Create output path
  1113. output_path = Path(frames_folder).parent / "explanation_videos"
  1114. output_path.mkdir(parents=True, exist_ok=True)
  1115. # Create the enhanced video
  1116. self.create_neurite_video(
  1117. cluster_id=cluster_id,
  1118. cluster_data=cluster_data,
  1119. frames_folder=frames_folder,
  1120. output_path=output_path,
  1121. movement_metrics=cluster_movement, # Pass the movement metrics
  1122. total_time_hours=total_time_hours
  1123. )
  1124. except Exception as e:
  1125. print(f"Error creating video for cluster {cluster_id}: {e}")
  1126. import traceback
  1127. traceback.print_exc()
  1128. def _print_processing_summary(self, df, movement_metrics):
  1129. """Print comprehensive processing summary with location analysis."""
  1130. valid_neurites = df[df['global_id'] != -1]
  1131. n_neurites = valid_neurites['global_id'].nunique()
  1132. n_appearances = len(valid_neurites)
  1133. print(f"\n=== PROCESSING SUMMARY ===")
  1134. print(f"Unique neurites identified: {n_neurites}")
  1135. print(f"Total appearances: {n_appearances}")
  1136. print(f"Noise/unassigned: {len(df[df['global_id'] == -1])}")
  1137. if n_neurites > 0:
  1138. avg_appearances = n_appearances / n_neurites
  1139. print(f"Average appearances per neurite: {avg_appearances:.1f}")
  1140. # Movement summary
  1141. total_advancement = sum(m['total_advancement'] for m in movement_metrics.values())
  1142. total_retraction = sum(m['total_retraction'] for m in movement_metrics.values())
  1143. print(f"Total advancement: {total_advancement:.2f} μm")
  1144. print(f"Total retraction: {total_retraction:.2f} μm")
  1145. print(f"Net movement: {total_advancement - total_retraction:.2f} μm")
  1146. # Location-based analysis
  1147. if 'location_type' in df.columns:
  1148. print(f"\n=== LOCATION ANALYSIS ===")
  1149. location_counts = df['location_type'].value_counts()
  1150. for loc_type, count in location_counts.items():
  1151. percentage = (count / len(df)) * 100
  1152. print(f"{loc_type}: {count} ({percentage:.1f}%)")
  1153. # Analysis of valid neurites by location
  1154. if n_neurites > 0:
  1155. print(f"\nValid neurites by location:")
  1156. neurites_by_location = valid_neurites.groupby('location_type')['global_id'].nunique()
  1157. for loc_type, count in neurites_by_location.items():
  1158. print(f" {loc_type}: {count} neurites")
  1159. # Pillar subtype analysis
  1160. if 'pillar_subtype' in df.columns:
  1161. pillar_data = df[df['location_type'] == 'pillar']
  1162. if not pillar_data.empty:
  1163. print(f"\nPillar subtypes:")
  1164. subtype_counts = pillar_data['pillar_subtype'].value_counts()
  1165. for subtype, count in subtype_counts.items():
  1166. percentage = (count / len(pillar_data)) * 100
  1167. print(f" {subtype}: {count} ({percentage:.1f}%)")
  1168. def process_single_dataset(self, folder_path):
  1169. """Process a single dataset folder."""
  1170. folder_path = Path(folder_path)
  1171. folder_name = folder_path.name
  1172. print(f"\nProcessing dataset: {folder_name}")
  1173. # Set up paths
  1174. frames_folder = folder_path
  1175. ndf_folder = folder_path
  1176. output_folder = self.outputs_folder / folder_name / "pngs"
  1177. output_folder.mkdir(parents=True, exist_ok=True)
  1178. try:
  1179. # Process frames and NDFs
  1180. tracing_data = self.process_frames_and_ndf(frames_folder, ndf_folder, output_folder)
  1181. if not tracing_data:
  1182. print(f"No tracing data found for {folder_name}")
  1183. return
  1184. # Process all frames with enhanced pipeline
  1185. df = self.process_all_frames_improved(tracing_data, str(output_folder))
  1186. if df.empty:
  1187. print(f"No valid data generated for {folder_name}")
  1188. return
  1189. # Save results
  1190. output_file = output_folder / f'clustered_data_{folder_name}.csv'
  1191. df.to_csv(output_file, index=False)
  1192. # Generate quality report
  1193. self.generate_quality_report(df, output_folder)
  1194. # Print summary
  1195. print(f"Dataset {folder_name} processed successfully:")
  1196. print(f" - Total observations: {len(df)}")
  1197. print(f" - Global neurites: {len(df[df['global_id'] != -1]['global_id'].unique())}")
  1198. print(f" - Output: {output_file}")
  1199. # Print cluster distribution and location statistics
  1200. cluster_counts = df[df['global_id'] != -1]['global_id'].value_counts()
  1201. print(f" - Cluster size distribution: min={cluster_counts.min()}, max={cluster_counts.max()}, mean={cluster_counts.mean():.1f}")
  1202. # Print location-based statistics
  1203. if 'location_type' in df.columns:
  1204. pillar_neurites = df[df['location_type'] == 'pillar']
  1205. control_neurites = df[df['location_type'] == 'control']
  1206. print(f" - Pillar neurites: {len(pillar_neurites)}")
  1207. print(f" - Control neurites: {len(control_neurites)}")
  1208. if len(pillar_neurites) > 0:
  1209. pillar_global_ids = pillar_neurites[pillar_neurites['global_id'] != -1]['global_id'].nunique()
  1210. print(f" - Unique global neurites on pillars: {pillar_global_ids}")
  1211. if len(control_neurites) > 0:
  1212. control_global_ids = control_neurites[control_neurites['global_id'] != -1]['global_id'].nunique()
  1213. print(f" - Unique global neurites in control areas: {control_global_ids}")
  1214. except Exception as e:
  1215. print(f"Error processing {folder_name}: {e}")
  1216. traceback.print_exc()
  1217. def generate_quality_report(self, df, output_folder):
  1218. """Generate a quality assessment report for the processed data."""
  1219. report = []
  1220. report.append("NEURITE PREPROCESSING QUALITY REPORT")
  1221. report.append("=" * 50)
  1222. report.append(f"Total frames processed: {df['frame_index'].nunique()}")
  1223. report.append(f"Total global neurites identified: {len(df[df['global_id'] != -1]['global_id'].unique())}")
  1224. report.append(f"Noise/unassigned observations: {len(df[df['global_id'] == -1])}")
  1225. # Quality metrics per global neurite
  1226. valid_neurites = df[df['global_id'] != -1].groupby('global_id')
  1227. lifespans = []
  1228. qualities = []
  1229. lengths = []
  1230. for gid, group in valid_neurites:
  1231. lifespan = group['frame_index'].max() - group['frame_index'].min() + 1
  1232. avg_length = group['length_microns'].mean()
  1233. lifespans.append(lifespan)
  1234. lengths.append(avg_length)
  1235. if lifespans:
  1236. report.append(f"\nNeurite Lifespan Statistics:")
  1237. report.append(f" Mean: {np.mean(lifespans):.1f} frames")
  1238. report.append(f" Median: {np.median(lifespans):.1f} frames")
  1239. report.append(f" Min: {min(lifespans)} frames")
  1240. report.append(f" Max: {max(lifespans)} frames")
  1241. report.append(f"\nNeurite Quality Statistics:")
  1242. report.append(f" Mean quality score: {np.mean(qualities):.3f}")
  1243. report.append(f" Median quality score: {np.median(qualities):.3f}")
  1244. report.append(f"\nNeurite Length Statistics (μm):")
  1245. report.append(f" Mean: {np.mean(lengths):.2f}")
  1246. report.append(f" Median: {np.median(lengths):.2f}")
  1247. report.append(f" Min: {min(lengths):.2f}")
  1248. report.append(f" Max: {max(lengths):.2f}")
  1249. # Add location-based analysis
  1250. if 'location_type' in df.columns:
  1251. report_path = Path(output_folder) / 'location_analysis_report.txt'
  1252. with open(report_path, 'w', encoding="utf-8") as f:
  1253. f.write("Location-Based Analysis Report\n")
  1254. f.write("=" * 40 + "\n\n")
  1255. # Overall statistics
  1256. f.write("Overall Statistics:\n")
  1257. f.write(f"Total neurite observations: {len(df)}\n")
  1258. location_counts = df['location_type'].value_counts()
  1259. for loc_type, count in location_counts.items():
  1260. percentage = (count / len(df)) * 100
  1261. f.write(f"{loc_type.capitalize()} neurites: {count} ({percentage:.1f}%)\n")
  1262. f.write("\n" + "=" * 40 + "\n\n")
  1263. # Global neurite analysis by location
  1264. f.write("Global Neurite Analysis by Location:\n")
  1265. global_neurites = df[df['global_id'] != -1]
  1266. if not global_neurites.empty:
  1267. global_stats = global_neurites.groupby('location_type')['global_id'].nunique()
  1268. for loc_type, count in global_stats.items():
  1269. f.write(f"{loc_type.capitalize()} global neurites: {count}\n")
  1270. # Save report
  1271. report_file = Path(output_folder) / "preprocessing_quality_report.txt"
  1272. with open(report_file, 'w', encoding='utf-8') as f:
  1273. f.write('\n'.join(report))
  1274. print(f"Quality report saved to: {report_file}")
  1275. def run_full_preprocessing(self):
  1276. """Process all datasets in the base folder."""
  1277. subfolders = [f for f in self.base_folder.iterdir() if f.is_dir()]
  1278. print(f"Found {len(subfolders)} datasets to process")
  1279. print(f"Input folder: {self.base_folder}")
  1280. print(f"Output folder: {self.outputs_folder}")
  1281. print("-" * 50)
  1282. for folder in subfolders:
  1283. self.process_single_dataset(folder)
  1284. print(f"\nPreprocessing complete. Results saved to: {self.outputs_folder}")
  1285. def main():
  1286. """Main execution function."""
  1287. base_folder = 'data/new_movies_sorted'
  1288. outputs_folder = 'data/outputs_by_location_vids'
  1289. preprocessor = NeuritePreprocessor(base_folder, outputs_folder)
  1290. preprocessor.run_full_preprocessing()
  1291. if __name__ == "__main__":
  1292. main()

preprocessing_bulk_vids.py at commit 9389c9a, no license · at the source

Overview

Authors: Claudia Latte Bovio1,2, Esther Matamoros3,4, Valentina Mollo1, Anna Mariano1, Valeria Criscuolo3,4, Francesca Santoro1,3,4
  1. Tissue Electronics, Istituto Italiano di Tecnologia, Naples, Italy
  2. Dipartimento di Chimica, Materiali e Produzione Industriale, Università di Napoli Federico II, Naples, Italy
  3. Neuroelectronic Interfaces, Faculty of Electrical Engineering and IT, RWTH Aachen, Aachen, Germany
  4. Institute for Biological Information Processing‐Bioelectronics (IBI‐3), Forschungszentrum Juelich, Juelich, Germany
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany), volume 13, issue 33, article e10822
Dates: received 18 June 2025; accepted 14 January 2026; published online 19 May 2026; in print June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1002/advs.202510822 · PMID 42154454 · PMCID PMC13271619 · OpenAlex W7161726734
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: optical imaging (calcium, voltage, 2-photon) (modality), human (organism)
Methods: Statistics, Preprocessing, Graphs, fMRI & imaging
Keywords: artificial spines, early‐stage neuronal interfacing, neuromorphic biomaterials, neuronal polarity and guidance, two‐photon polymerization
MeSH: Biomimetic Materials*, Biomimetics*, Neuronal Outgrowth*, Neurons*, Tissue Engineering*, Animals, Biocompatible Materials, Humans, Neurodevelopment (* major topic)
Topic: Advanced Materials and Mechanics (Mechanical Engineering, Engineering), according to OpenAlex
Citations: cited by 1 paper (Europe PMC); 73 references in the paper

Abstract

Neuromorphic biomaterials represent a novel class of materials designed to replicate the architecture and functionality of neuronal structures, offering new opportunities in tissue engineering and bioelectronics. By mimicking the complex microenvironment of native neural tissue, biomimetic microstructures provide physical scaffolding to support and guide neuronal processes, with potential applications in chip‐based platforms for monitoring and stimulating neuronal networks. However, achieving precise control over the morphology of neuromorphic materials and understanding their influence on early‐stage neuronal development are remaining challenges. In this study, we present biomimetic microstructure arrays, fabricated via two‐photon polymerization, that emulate the diverse morphologies and spatial arrangements of dendritic spines. These structures enable the investigation of neuronal responses at the early developmental stage, focusing on key processes such as cell adhesion, neuronal polarity, growth cone dynamics, and network formation.

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

Repositories

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

CHARLESProtocol2/Neurite-Growth-Analysis-Toolkit

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 9389c9a0d7707c3c47207ccda66ad36310333fd5, 26 November 2025
Languages: Python (7)
Size: 8 files, 7 scripts
Software Heritage: not archived
Found in: the text, “Analysis of Neurites Outgrowth”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (7 files), Matplotlib (6 files), pandas (5 files), SciPy (4 files), OpenCV (3 files), seaborn (3 files), scikit-learn (2 files), Pillow (1 file), Plotly (1 file), scikit-image (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
8 files

EstherMatamoros/ProteinExpression

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 1f3196f1d7c8c4d3dea63eb37b9d3b945402aa6c, 10 September 2024
Languages: Python (2)
Size: 3 files, 2 scripts
Software Heritage: not archived
Found in: the text, “Cell Adhesion”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (2 files), pandas (2 files), NumPy (1 file), OpenCV (1 file), scikit-image (1 file), SciPy (1 file), seaborn (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
3 files

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:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 9 scripts, each with its path and the digest of its content;
  • 3 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

No dataset and no data link were found in the paper.

Data Availability Statement

The data that support the findings of this study are available from the corresponding author upon reasonable request.

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, 28 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 6 authors, 5 keywords, 9 MeSH terms, 57 references.

Cite

This paper

Latte Bovio, C., Matamoros, E., Mollo, V., Mariano, A., Criscuolo, V., & Santoro, F. (2026). How Neuromorphic Microstructures Control In Vitro Early-Stage Neuronal Outgrowth. Advanced science (Weinheim, Baden-Wurttemberg, Germany), 13(33), e10822. https://doi.org/10.1002/advs.202510822

BibTeX

@article{lattebovio2026how,
author = {Latte Bovio, Claudia and Matamoros, Esther and Mollo, Valentina and Mariano, Anna and Criscuolo, Valeria and Santoro, Francesca},
title = {{How Neuromorphic Microstructures Control In Vitro Early-Stage Neuronal Outgrowth}},
journal = {Advanced science (Weinheim, Baden-Wurttemberg, Germany)},
year = {2026},
month = may,
volume = {13},
number = {33},
pages = {e10822},
publisher = {Wiley},
issn = {2198-3844},
doi = {10.1002/advs.202510822},
url = {https://doi.org/10.1002/advs.202510822},
pmid = {42154454},
pmcid = {PMC13271619}
}

RIS

TY - JOUR
AU - Latte Bovio, Claudia
AU - Matamoros, Esther
AU - Mollo, Valentina
AU - Mariano, Anna
AU - Criscuolo, Valeria
AU - Santoro, Francesca
TI - How Neuromorphic Microstructures Control In Vitro Early-Stage Neuronal Outgrowth
T2 - Advanced science (Weinheim, Baden-Wurttemberg, Germany)
J2 - Adv Sci (Weinh)
PY - 2026
DA - 2026/05/19
VL - 13
IS - 33
SP - e10822
SN - 2198-3844
PB - Wiley
DO - 10.1002/advs.202510822
UR - https://doi.org/10.1002/advs.202510822
LA - en
ER -

CSL-JSON

{
"id": "10.1002/advs.202510822",
"type": "article-journal",
"title": "How Neuromorphic Microstructures Control In Vitro Early-Stage Neuronal Outgrowth",
"container-title": "Advanced science (Weinheim, Baden-Wurttemberg, Germany)",
"author": [
{
"family": "Latte Bovio",
"given": "Claudia"
},
{
"family": "Matamoros",
"given": "Esther"
},
{
"family": "Mollo",
"given": "Valentina"
},
{
"family": "Mariano",
"given": "Anna"
},
{
"family": "Criscuolo",
"given": "Valeria"
},
{
"family": "Santoro",
"given": "Francesca"
}
],
"container-title-short": "Adv Sci (Weinh)",
"volume": "13",
"issue": "33",
"page": "e10822",
"DOI": "10.1002/advs.202510822",
"PMID": "42154454",
"PMCID": "PMC13271619",
"ISSN": "2198-3844",
"publisher": "Wiley",
"URL": "https://doi.org/10.1002/advs.202510822",
"language": "en",
"issued": {
"date-parts": [
[
2026,
5,
19
]
]
}
}

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.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: Plotly, OpenCV, scikit-image, 8 other tools
[2] doi:10.1002/alz.71649 [code]
Postmortem brain MRI reveals differential associations of subcortical and limbic volumes with cortical thinning and neurodegenerative pathologies.
Journal: Alzheimer's & dementia : the journal of the Alzheimer's Association
In common: Plotly, OpenCV, scikit-image, 8 other tools
[3] doi:10.1016/j.isci.2026.116825 [code]
Social hierarchy shapes behavioral and transcriptional responses to chronic stress and ketamine in male mice.
Journal: iScience
In common: Plotly, OpenCV, scikit-image, 8 other tools
[4] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: OpenCV, scikit-image, Pillow, 7 other tools, optical imaging (calcium, voltage, 2-photon)
[5] doi:10.1016/j.isci.2026.116206 [code]
Gut distension evokes rapid neural dynamics in vagal and hindbrain populations of larval zebrafish.
Journal: iScience
In common: OpenCV, scikit-image, Pillow, 7 other tools, optical imaging (calcium, voltage, 2-photon)
[6] doi:10.1038/s41467-026-72437-1 [code]
High-speed whole-brain imaging in Drosophila.
Journal: Nature communications
In common: Plotly, OpenCV, scikit-image, 7 other tools
[7] doi:10.1186/s12880-026-02481-2 [code]
Deep learning-based neuroanatomical profiling reveals population-specific brain changes in multiple sclerosis: a large-scale Middle Eastern study.
Journal: BMC medical imaging
In common: Plotly, OpenCV, scikit-image, 7 other tools
[8] doi:10.1371/journal.pone.0348866 [code]
Using deep learning to identify inherited retinal diseases based on wide-field retinal imaging data.
Journal: PloS one
In common: Plotly, OpenCV, scikit-image, 7 other tools
[9] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: Plotly, OpenCV, scikit-image, 7 other tools
[10] doi:10.3389/fendo.2026.1828487 [code]
Castration-induced nigrostriatal deficits are linked to reduced TrkB and loss of mature spines in the dorsal striatum.
Journal: Frontiers in endocrinology
In common: Plotly, OpenCV, scikit-image, 6 other tools, 1 reference

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.