OSCR

An enteric neuron ionotropic receptor regulates salt stress resistance.

Code ↔ Paper

The paper beside its authors' code: matches between them have not been computed for this paper yet.

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 · 944 lines · 40 KB · MIT

  1. #!/usr/bin/env python3
  2. #######################################################################
  3. # Kymograph Generation for C. elegans Pharyngeal Pumping Analysis
  4. #
  5. # This script processes video recordings of C. elegans to generate kymographs
  6. # that visualize pharyngeal pumping behavior over time. The analysis pipeline:
  7. #
  8. # 1. Loads behavior recordings from HDF5 files
  9. # 2. Segments worms from background using image processing
  10. # 3. Extracts skeleton centerlines using Tierpsy tracker
  11. # 4. Creates kymographs showing intensity changes along the body
  12. # 5. Generates quality control videos with skeleton overlays
  13. #
  14. # Key outputs:
  15. # - Kymograph images showing pumping patterns
  16. # - Processed videos for quality control
  17. # - Numerical data for further analysis
  18. #######################################################################
  19. # Modules
  20. import os # File and folder path handling
  21. import gc # Garbage collection for memory management
  22. import datetime # Timestamp generation
  23. import numpy as np # Numerical computing and array operations
  24. import matplotlib.pyplot as plt # Plotting and image generation
  25. from scipy.io import savemat # MATLAB file export
  26. # Tierpsy Tracker - C. elegans computer vision library
  27. # https://github.com/SinRas/tierpsy-tracker
  28. import tierpsy
  29. from tierpsy.analysis.ske_create.getSkeletonsTables import getWormMask, getSkeleton
  30. # Custom utilities for data processing
  31. from utils import *
  32. # Import configuration parameters
  33. from config import *
  34. #######################################################################
  35. # Computer Vision Functions
  36. #
  37. # These functions handle the image processing pipeline for worm detection
  38. # and skeleton extraction. They work together to convert raw video frames
  39. # into structured data suitable for behavioral analysis.
  40. #######################################################################
  41. def create_foreground_mask(img_beh, threshold_foreground=130, threshold_area_min_object=100, threshold_area_max_object=500_000):
  42. """
  43. Create a binary mask identifying worm pixels in the image.
  44. This function segments the worm from the background by:
  45. 1. Applying intensity thresholding to find dark objects
  46. 2. Filtering by object size to remove debris and artifacts
  47. Args:
  48. img_beh: Input grayscale image (numpy array)
  49. threshold_foreground: Intensity threshold for worm detection (0-255)
  50. threshold_area_min_object: Minimum object size in pixels
  51. threshold_area_max_object: Maximum object size in pixels
  52. Returns:
  53. Binary mask where True indicates worm pixels
  54. """
  55. # Apply multi-scale thresholding to handle varying image quality
  56. img_beh_masked = (cv.medianBlur(img_beh, 11) < threshold_foreground) | \
  57. (cv.medianBlur(img_beh, 3) < threshold_foreground) | \
  58. (img_beh < threshold_foreground)
  59. # Remove objects that are too small or too large (debris, artifacts)
  60. n_labels, labels, stats, centroids = cv.connectedComponentsWithStats(img_beh_masked.astype(np.uint8))
  61. img_beh_masked &= False # Reset mask
  62. # Keep only objects within size constraints
  63. for label, area in enumerate(stats[:, -1]):
  64. if label == 0 or area < threshold_area_min_object or area > threshold_area_max_object:
  65. continue
  66. img_beh_masked |= labels == label
  67. # Return
  68. return img_beh_masked
  69. def extract_worm_mask(img_mask, coords_center=None, size_close=11):
  70. """
  71. Extract the worm mask by selecting the largest connected component and filling gaps.
  72. This function refines the foreground mask by:
  73. 1. Finding the object closest to the expected worm position
  74. 2. Filling gaps in the worm outline using morphological operations
  75. 3. Ensuring the mask represents a single, connected worm
  76. Args:
  77. img_mask: Binary foreground mask from create_foreground_mask()
  78. coords_center: Expected worm position (None = use image center)
  79. size_close: Size of morphological closing kernel
  80. Returns:
  81. Refined binary mask representing the worm
  82. """
  83. if coords_center is None:
  84. coords_center = np.array(img_mask.shape, dtype=np.float32) / 2
  85. # Find all connected components in the mask
  86. n_labels, labels, stats, centroids = cv.connectedComponentsWithStats(img_mask.astype(np.uint8))
  87. # Handle case where no objects are found
  88. if n_labels <= 1:
  89. return np.zeros_like(img_mask, dtype=np.bool_)
  90. # Select the object closest to the expected center position
  91. dists = np.linalg.norm(centroids - coords_center[np.newaxis, :], axis=1)
  92. label_closest_center = 1 + np.argmin(dists[1:])
  93. worm_mask = labels == label_closest_center
  94. # Fill gaps in the worm outline using morphological closing
  95. worm_mask = cv.morphologyEx(
  96. worm_mask.astype(np.uint8),
  97. cv.MORPH_CLOSE,
  98. cv.getStructuringElement(cv.MORPH_ELLIPSE, (size_close, size_close))
  99. ) > 0
  100. # Fill interior regions by inverting and finding background
  101. n_labels, labels, stats, centroids = cv.connectedComponentsWithStats((~worm_mask).astype(np.uint8))
  102. label_all_background = 1 + np.argmax(stats[1:, -1])
  103. worm_mask = labels != label_all_background
  104. # Return
  105. return worm_mask
  106. def convert_mask_for_tierpsy(worm_mask):
  107. """
  108. Convert binary mask to format expected by Tierpsy skeleton extraction.
  109. Tierpsy expects specific intensity values:
  110. - Background: 255 (white)
  111. - Threshold: 110 (splits background from worm)
  112. - Worm: 55 (dark gray, or any value below 110)
  113. Args:
  114. worm_mask: Binary mask where True = worm pixels
  115. Returns:
  116. Grayscale image with proper intensity values for Tierpsy
  117. """
  118. img_tierpsy = (worm_mask == 0).astype(np.uint8) * 200 # Background = 200
  119. img_tierpsy += 55 # Worm = 55, Background = 255
  120. return img_tierpsy
  121. def extract_skeleton(worm_mask, skeleton_prev=np.zeros(0), n_skeleton_segments=101):
  122. """
  123. Extract worm skeleton using Tierpsy computer vision library.
  124. This function uses Tierpsy's algorithms to find the centerline of the worm,
  125. which is essential for creating kymographs.
  126. Args:
  127. worm_mask: Binary mask representing the worm
  128. skeleton_prev: Previous skeleton for temporal consistency (optional)
  129. n_skeleton_segments: Number of points along the skeleton
  130. Returns:
  131. Array of skeleton points (x, y coordinates)
  132. """
  133. # Convert mask to Tierpsy format
  134. worm_tierpsy = convert_mask_for_tierpsy(worm_mask)
  135. # Extract worm contour using Tierpsy
  136. _, worm_cnt, _ = getWormMask(
  137. worm_img=worm_tierpsy,
  138. threshold=110, # Don't change it, this is paired with `img_mask_to_tierpsy`
  139. strel_size=11, # Hard coded in Tierpsy
  140. min_blob_area=TIERPSY_MIN_BLOB_AREA, # Change if you need to find skeleton for a very tiny worm, which should not be the case!
  141. is_light_background=TIERPSY_IS_LIGHT_BACKGROUND
  142. )
  143. # Extract skeleton from contour
  144. output = getSkeleton(
  145. worm_cnt=worm_cnt,
  146. prev_skeleton=skeleton_prev,
  147. resampling_N=n_skeleton_segments
  148. )
  149. skeleton, ske_len, cnt_side1, cnt_side2, cnt_widths, cnt_area = output
  150. # Return
  151. return skeleton
  152. #######################################################################
  153. # Time Series Processing Functions
  154. #
  155. # These functions handle temporal smoothing of skeleton data to reduce
  156. # noise and improve the quality of kymograph generation. They implement
  157. # different types of moving averages for different analysis needs.
  158. #######################################################################
  159. def moving_average_causal(arr, k, dtype=np.float32):
  160. """
  161. Apply causal (past-only) moving average to time series data.
  162. This function smooths data using only past values, making it suitable
  163. for real-time analysis where future data is not available.
  164. Args:
  165. arr: Input time series array
  166. k: Window size for averaging
  167. dtype: Output data type
  168. Returns:
  169. Smoothed time series array
  170. """
  171. if k <= 1:
  172. return arr.copy()
  173. if np.isnan(arr[0]) or np.isnan(arr[-1]):
  174. il = 0
  175. while il < len(arr) and np.isnan(arr[il]):
  176. il += 1
  177. ir = len(arr)-1
  178. while ir >= 0 and np.isnan(arr[ir]):
  179. ir -= 1
  180. ir += 1
  181. assert il < ir, "Please don't test me on all edge cases :)) give me a better array! :grin: (array is all NaNs)"
  182. result = np.zeros_like(arr) * np.nan
  183. result[il:ir] = moving_average_causal( arr[il:ir], k )
  184. return result
  185. # Rolling counts
  186. w = k+1
  187. arr_cumsum = np.nancumsum( arr, axis=0 )
  188. ns_cumsum = np.cumsum( ~np.isnan(arr), axis=0 )
  189. result = np.zeros_like(arr_cumsum, dtype=dtype) * np.nan
  190. # Beginning
  191. result[:w] = arr_cumsum[:w] / ns_cumsum[:w]
  192. # Rest
  193. result[w:] = (arr_cumsum[w:] - arr_cumsum[:-w]) / np.maximum(ns_cumsum[w:] - ns_cumsum[:-w], 1)
  194. # NaNs
  195. _indices = w + np.where( ns_cumsum[w:] == ns_cumsum[:-w] )[0]
  196. result[_indices] = np.nan
  197. # Return
  198. return result
  199. def moving_average_symmetric(arr, k, dtype=np.float32):
  200. """
  201. Apply symmetric (past and future) moving average to time series data.
  202. This function smooths data using both past and future values, providing
  203. better smoothing but requiring the entire time series to be available.
  204. Args:
  205. arr: Input time series array
  206. k: Window size for averaging
  207. dtype: Output data type
  208. Returns:
  209. Smoothed time series array
  210. """
  211. if k <= 1:
  212. return arr.astype(dtype)
  213. return (moving_average_causal(arr, k, dtype=dtype) * (k+1) +
  214. moving_average_causal(arr[::-1], k-1, dtype=dtype)[::-1] * k) / (2*k+1)
  215. #######################################################################
  216. # Skeleton Processing and Kymograph Generation
  217. #
  218. # These functions handle the geometric processing of skeleton data and
  219. # the creation of kymographs that visualize intensity changes along the
  220. # worm's body over time.
  221. #######################################################################
  222. from scipy.interpolate import make_interp_spline
  223. def smooth_skeleton(arr2d, stride=5, k=3):
  224. """
  225. Smooth skeleton coordinates using cubic spline interpolation.
  226. This function reduces noise in skeleton tracking by fitting smooth
  227. curves through the skeleton points.
  228. Args:
  229. arr2d: 2D array of skeleton coordinates (time, points, x/y)
  230. stride: Sampling interval for spline fitting
  231. k: Spline order (3 = cubic)
  232. Returns:
  233. Smoothed skeleton coordinates
  234. """
  235. ts = np.arange(len(arr2d))
  236. spl = make_interp_spline(ts[::stride], arr2d[::stride], k=k)
  237. return spl(ts)
  238. def interpolate_skeleton(coords, dists_to_interpolate, k=3):
  239. """
  240. Interpolate skeleton points at specific distances from the head.
  241. This function creates a standardized skeleton representation by
  242. sampling points at regular intervals along the worm's length.
  243. Args:
  244. coords: Skeleton coordinates (points, x/y)
  245. dists_to_interpolate: Target distances from head (in pixels)
  246. k: Spline order for interpolation
  247. Returns:
  248. Interpolated skeleton coordinates at specified distances
  249. """
  250. # Calculate cumulative distances along skeleton
  251. dists = np.zeros(len(coords))
  252. dists[1:] = np.cumsum(np.linalg.norm(np.diff(coords, axis=0), axis=1))
  253. # Interpolate at target distances
  254. spl = make_interp_spline(dists, coords, k=k)
  255. return spl(dists_to_interpolate)
  256. def compute_skeleton_normals(skeleton, normal_factors=np.arange(-5.0, 6.0, 1.0)):
  257. """
  258. Create perpendicular lines (normals) extending from skeleton points.
  259. These normals are used to measure intensity profiles
  260. across the body for kymograph generation.
  261. Args:
  262. skeleton: Skeleton coordinates (points, x/y)
  263. normal_factors: Distances along normals (negative = left, positive = right)
  264. Returns:
  265. 3D array of normal line coordinates (midpoints, x/y, normal_points)
  266. """
  267. # Calculate midpoints between consecutive skeleton points
  268. skeleton_mids = (skeleton[:-1] + skeleton[1:]) / 2
  269. # Calculate normal vectors (perpendicular to skeleton direction)
  270. diffs = np.diff(skeleton, axis=0)
  271. dists = np.linalg.norm(diffs, axis=1)
  272. normals = diffs[:, ::-1] / dists[:, np.newaxis] # Rotate 90 degrees
  273. normals[:, 0] *= -1 # Flip x-component for correct orientation
  274. # Create normal lines extending from midpoints
  275. skeleton_mids_normals = skeleton_mids[:, :, np.newaxis] + \
  276. (normals[:, :, np.newaxis] * normal_factors[np.newaxis, np.newaxis, :])
  277. return skeleton_mids_normals
  278. #######################################################################
  279. # Main Kymograph Processing Functions
  280. #
  281. # These functions process a segment of video data to generate kymographs
  282. # and quality control videos. They handle the complete pipeline from
  283. # raw video frames to kymograph images.
  284. #######################################################################
  285. def load_and_align_kymograph(fp_npz, scale_roll=1.0, il=130, ir=200):
  286. """
  287. Load kymograph data and align it to compensate for worm movement.
  288. This function addresses the challenge that worms can move during recording,
  289. causing the pharynx to shift position in the kymograph. Alignment is done
  290. by rolling the kymograph along the normal lines.
  291. Args:
  292. fp_npz: Path to NPZ file containing kymograph data
  293. scale_roll: Scaling factor for alignment correction
  294. il: Left boundary of analysis region (pixels from head)
  295. ir: Right boundary of analysis region (pixels from head)
  296. Returns:
  297. Tuple of (original_kymograph, aligned_kymograph)
  298. """
  299. # Load
  300. with np.load(fp_npz) as in_file:
  301. data = {
  302. key: in_file[key] for key in in_file.keys()
  303. }
  304. # Load or average
  305. intensities_skeleton_normals_avg = np.nanmean(
  306. data['intensities_skeleton_normals_full'][:, :, 15:46],
  307. axis=-1
  308. ) if 'intensities_skeleton_normals_full' in data else data['kymograph_skeleton_normals'].copy()
  309. # Find the gut boundary shifts
  310. weights = intensities_skeleton_normals_avg[:,il:ir]
  311. center_of_mass = np.nansum(
  312. weights * np.arange(ir-il)[np.newaxis,:], axis=1
  313. ) / np.nansum( weights, axis=1 )
  314. dindex_center_of_mass = np.round(
  315. (center_of_mass - np.nanmedian(center_of_mass))*scale_roll,
  316. 0
  317. ).astype(np.int64)
  318. # Rolled
  319. intensities_skeleton_normals_avg_rolled = intensities_skeleton_normals_avg.copy()
  320. for idx, k in enumerate(dindex_center_of_mass):
  321. if np.isnan(k) or np.isinf(k) or k == 0:
  322. continue
  323. intensities_skeleton_normals_avg_rolled[idx] = np.roll(intensities_skeleton_normals_avg_rolled[idx], k)
  324. # Return
  325. return intensities_skeleton_normals_avg, intensities_skeleton_normals_avg_rolled
  326. def process_kymograph_segment(idx_start, idx_end, indices_skeleton_reverse=set(), worm_id=None, condition=None, strain=None):
  327. """
  328. Process a video segment to generate kymographs and quality control videos.
  329. This function performs the complete analysis pipeline:
  330. 1. Creates foreground mask videos
  331. 2. Extracts worm masks and skeleton overlays
  332. 3. Generates kymographs showing intensity changes over time
  333. 4. Saves all data in multiple formats (NPZ, MAT, PNG)
  334. Args:
  335. idx_start: Starting frame index for processing
  336. idx_end: Ending frame index for processing
  337. indices_skeleton_reverse: Set of frame indices where skeleton orientation should be reversed
  338. """
  339. # Recording and writing parameters
  340. INDEX_START = idx_start
  341. INDICES_TO_CONSIDER = np.arange(idx_start, idx_end, 1)
  342. NT = len(INDICES_TO_CONSIDER)
  343. _PREFIX = f"{worm_id}_{condition}_{strain}_pumping_{str(idx_start).zfill(8)}_{str(idx_end).zfill(8)}_"
  344. FP_WRITE_VIDEO_FOREGROUND = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}movie_foreground.mp4" ) # Path to the video file to write masked foreground
  345. FP_WRITE_VIDEO_WORMMASK = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}movie_worm_mask.mp4" ) # Path to the video file to write worm mask video
  346. FP_WRITE_VIDEO_WORMSKELETON = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}movie_worm_skeleton.mp4" ) # Path to the video file to write worm mask video
  347. FP_WRITE_IMAGE_KYMOGRAPH = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_only_center.png" ) # Path to the video file to write worm mask video
  348. FP_WRITE_IMAGE_KYMOGRAPH_SCALED = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_only_center_scaled.png" ) # Path to the video file to write worm mask video
  349. FP_WRITE_IMAGE_KYMOGRAPH_NORMALS = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_normals.png" ) # Path to the video file to write worm mask video
  350. FP_WRITE_IMAGE_KYMOGRAPH_NORMALS_SCALED = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_normals_scaled.png" ) # Path to the video file to write worm mask video
  351. FP_WRITE_NPZ_KYMOGRAPH_ALL = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_all.npz" ) # Path to all the data for the kymographs, e.g. timestamps and distances in `um`
  352. FP_WRITE_MAT_KYMOGRAPH_ALL = os.path.join( FP_PUMPING_EXTRACTS, f"{_PREFIX}kymograph_all.mat" ) # Path to all the data for the kymographs, e.g. timestamps and distances in `um`
  353. # 1) Make foreground mask video
  354. # Add frame index and actual FPS
  355. # series_beh = SerializeDatas([imgs.copy()])
  356. ## Mask images (lazy calculation -> nothing is calculated until writing it to file)
  357. def do_mask(img):
  358. img_mask = create_foreground_mask(img, threshold_foreground=MASK_THRESHOLD_FOREGROUND, threshold_area_min_object=MASK_AREA_MIN_OBJECT, threshold_area_max_object=MASK_AREA_MAX_OBJECT)
  359. return img_mask.astype(np.uint8)
  360. series_to_write = ImgToProcess(series_beh, fn_process=do_mask, rescale=True)
  361. ## White color
  362. series_to_write = AddFrameIndicesTexts(
  363. series_to_write,
  364. texts=texts,
  365. textOrigin=TEXT_ORIGIN_WHITE, color=TEXT_COLOR_WHITE
  366. )
  367. ## Black color -> for very bright images
  368. series_to_write = AddFrameIndicesTexts(
  369. series_to_write,
  370. texts=texts,
  371. textOrigin=TEXT_ORIGIN_BLACK, color=TEXT_COLOR_BLACK
  372. )
  373. ## Slice specific range
  374. series_to_write = ReIndexData(
  375. series_to_write,
  376. indices_new=INDICES_TO_CONSIDER
  377. )
  378. write_video(
  379. series_to_write,
  380. fp=FP_WRITE_VIDEO_FOREGROUND,
  381. fps=FRAMES_PER_SECOND,
  382. verbose=True
  383. )
  384. # 2) Find worm mask
  385. # Add frame index and actual FPS
  386. # series_beh = SerializeDatas([imgs.copy()])
  387. ## Find worm mask
  388. def do_mask(img):
  389. img_mask = create_foreground_mask(img, threshold_foreground=MASK_THRESHOLD_FOREGROUND, threshold_area_min_object=MASK_AREA_MIN_OBJECT, threshold_area_max_object=MASK_AREA_MAX_OBJECT)
  390. worm_mask = extract_worm_mask(img_mask, coords_center=WORM_CENTER_ESTIMATE, size_close=WORM_SIZE_CLOSE)
  391. return worm_mask.astype(np.uint8)
  392. series_to_write = ImgToProcess(series_beh, fn_process=do_mask, rescale=True)
  393. ## White color
  394. series_to_write = AddFrameIndicesTexts(
  395. series_to_write,
  396. texts=texts,
  397. textOrigin=TEXT_ORIGIN_WHITE, color=TEXT_COLOR_WHITE
  398. )
  399. ## Black color -> for very bright images
  400. series_to_write = AddFrameIndicesTexts(
  401. series_to_write,
  402. texts=texts,
  403. textOrigin=TEXT_ORIGIN_BLACK, color=TEXT_COLOR_BLACK
  404. )
  405. ## Slice specific range
  406. series_to_write = ReIndexData(
  407. series_to_write,
  408. indices_new=INDICES_TO_CONSIDER
  409. )
  410. write_video(
  411. series_to_write,
  412. fp=FP_WRITE_VIDEO_WORMMASK,
  413. fps=FRAMES_PER_SECOND,
  414. verbose=True
  415. )
  416. # 3) Find skeleton and video from it
  417. # Add frame index and actual FPS
  418. # series_beh = SerializeDatas([imgs.copy()])
  419. ## Find worm mask
  420. def do_mask(img, idx_frame):
  421. # These non-local variables are used: idx_start, infos
  422. # Get skeleton
  423. idx_skeleton = idx_frame - idx_start if idx_frame >= idx_start else 0 # Technical due to definition of class `ImgToProcessIndexed`
  424. skeleton = infos['skeleton_smooth'][idx_skeleton]
  425. # Overlay image
  426. img_overlayed = cv.polylines( img.astype(np.uint8), [ skeleton.astype(np.int32)[:,np.newaxis,:] ], False, SKELETON_LINE_COLOR, SKELETON_LINE_WIDTH ) # Aesthetics: width and color of the skeleton
  427. # Head part
  428. img_overlayed = cv.polylines( img_overlayed, [ skeleton.astype(np.int32)[:SKELETON_HEAD_POINTS,np.newaxis,:] ], False, SKELETON_HEAD_LINE_COLOR, SKELETON_HEAD_LINE_WIDTH ) # Aesthetics: width and color of the skeleton
  429. return img_overlayed
  430. # Find skeleton
  431. infos = {
  432. 'indices': INDICES_TO_CONSIDER,
  433. 'skeleton': np.zeros((NT, SKELETON_N_SEGMENTS, 2), dtype=np.float64)*np.nan,
  434. }
  435. for idx, idx_frame in enumerate(tqdm(INDICES_TO_CONSIDER)):
  436. # Load
  437. img = series_beh[idx_frame]
  438. # Mask
  439. img_mask = create_foreground_mask(img, threshold_foreground=MASK_THRESHOLD_FOREGROUND, threshold_area_min_object=MASK_AREA_MIN_OBJECT, threshold_area_max_object=MASK_AREA_MAX_OBJECT)
  440. worm_mask = extract_worm_mask(img_mask, coords_center=WORM_CENTER_ESTIMATE, size_close=WORM_SIZE_CLOSE)
  441. # Skeleton
  442. skeleton_prev = np.zeros(0) if idx == 0 or np.any(np.isnan(infos['skeleton'][idx-1])) else infos['skeleton'][idx-1] # Use last skeleton if it was found, to re-orient and keep orientation consistency
  443. skeleton = extract_skeleton(
  444. worm_mask,
  445. skeleton_prev=skeleton_prev,
  446. n_skeleton_segments=SKELETON_N_SEGMENTS
  447. )
  448. ## Reverse initial skeleton
  449. if idx_frame in indices_skeleton_reverse:
  450. print(f"*** skeleton reversed at {idx_frame}")
  451. skeleton = skeleton[::-1]
  452. if len(skeleton) == 0:
  453. continue
  454. # Intensities
  455. infos['skeleton'][idx] = skeleton
  456. # Smooth Skeletons temporaly
  457. infos['skeleton_smooth'] = infos['skeleton'].copy()
  458. infos['skeleton_smooth'][1:] += infos['skeleton'][:-1]
  459. infos['skeleton_smooth'][:-1] += infos['skeleton'][1:]
  460. infos['skeleton_smooth'][:1] /= 2
  461. infos['skeleton_smooth'][1:-1] /= 3
  462. infos['skeleton_smooth'][-1:] /= 2
  463. # Write video
  464. series_to_write = ImgToProcessIndexed(series_beh, fn_process=do_mask, rescale=True)
  465. ## White color
  466. series_to_write = AddFrameIndicesTexts(
  467. series_to_write,
  468. texts=texts,
  469. textOrigin=TEXT_ORIGIN_WHITE, color=TEXT_COLOR_WHITE
  470. )
  471. ## Black color -> for very bright images
  472. series_to_write = AddFrameIndicesTexts(
  473. series_to_write,
  474. texts=texts,
  475. textOrigin=TEXT_ORIGIN_BLACK, color=TEXT_COLOR_BLACK
  476. )
  477. ## Slice specific range
  478. series_to_write = ReIndexData(
  479. series_to_write,
  480. indices_new=INDICES_TO_CONSIDER
  481. )
  482. write_video(
  483. series_to_write,
  484. fp=FP_WRITE_VIDEO_WORMSKELETON,
  485. fps=FRAMES_PER_SECOND,
  486. verbose=True
  487. )
  488. # 4) Create the kymograph
  489. # Interpolated
  490. dists_to_interpolate_px = SKELETON_INTENSITY_STEPS_UM / UM_PER_PIXEL
  491. _n = len(dists_to_interpolate_px)
  492. infos['skeleton_smooth_interp'] = infos['skeleton_smooth'].copy()
  493. infos['skeleton_smooth_interp_um'] = np.zeros((NT, _n, 2))*np.nan
  494. infos['skeleton_smooth_interp_um_normals'] = np.zeros((NT, _n, 2))*np.nan
  495. infos['intensities_skeleton'] = np.zeros((NT, _n))*np.nan
  496. infos['intensities_skeleton_normals'] = np.zeros((NT, _n-1))*np.nan
  497. infos['intensities_skeleton_normals_full'] = np.zeros((NT, _n-1, N_SKELETON_NORMALS_HALF_LENGTH_PIXELS))*np.nan
  498. infos['widths_skeleton_normals'] = np.zeros((NT, _n-1))*np.nan
  499. # Load intensities along lines
  500. for idx, idx_frame in enumerate(tqdm(infos['indices'])):
  501. # Skeleton Smoothed
  502. skeleton = infos['skeleton_smooth'][idx]
  503. if np.any(np.isnan(skeleton)):
  504. continue
  505. # Smooth spatially
  506. skeleton_interp = smooth_skeleton( skeleton, stride=5, k=3 )
  507. skeleton_interp_um = interpolate_skeleton(skeleton_interp, dists_to_interpolate_px, k=3 )
  508. skeleton_interp_um_normals = compute_skeleton_normals(
  509. skeleton_interp_um,
  510. normal_factors=np.arange(-SKELETON_NORMALS_HALF_LENGTH_PIXELS, SKELETON_NORMALS_HALF_LENGTH_PIXELS+1).astype(np.float32)
  511. )
  512. skeleton_interp_um_normals = np.minimum(
  513. np.maximum(
  514. skeleton_interp_um_normals, 0
  515. ),
  516. NX-1
  517. ).astype(np.int64)
  518. infos['skeleton_smooth_interp'][idx] = skeleton_interp
  519. infos['skeleton_smooth_interp_um'][idx] = skeleton_interp_um
  520. skeleton_int = np.minimum(
  521. np.maximum(
  522. skeleton_interp_um, 0
  523. ),
  524. NX-1
  525. ).astype(np.int64)
  526. # Load
  527. img = series_beh[idx_frame]
  528. # Mask
  529. img_mask = create_foreground_mask(img, threshold_foreground=MASK_THRESHOLD_FOREGROUND, threshold_area_min_object=MASK_AREA_MIN_OBJECT, threshold_area_max_object=MASK_AREA_MAX_OBJECT)
  530. worm_mask = extract_worm_mask(img_mask, coords_center=WORM_CENTER_ESTIMATE, size_close=WORM_SIZE_CLOSE)
  531. # NaNed
  532. img_naned = img.astype(np.float32)
  533. img_naned[~worm_mask] = np.nan
  534. # Intensities
  535. infos['intensities_skeleton'][idx] = img_naned[skeleton_int[:,1], skeleton_int[:,0]]
  536. infos['intensities_skeleton_normals'][idx] = np.nanmean(
  537. img_naned[skeleton_interp_um_normals[:,1], skeleton_interp_um_normals[:,0]],
  538. axis=-1
  539. )
  540. infos['widths_skeleton_normals'][idx] = np.sum(
  541. ~np.isnan(img_naned[skeleton_interp_um_normals[:,1], skeleton_interp_um_normals[:,0]]),
  542. axis=-1
  543. )
  544. infos['intensities_skeleton_normals_full'][idx] = img_naned[skeleton_interp_um_normals[:,1], skeleton_interp_um_normals[:,0]]
  545. # Store
  546. ## Numpy
  547. np.savez_compressed(
  548. FP_WRITE_NPZ_KYMOGRAPH_ALL,
  549. timestamps = times_beh[INDICES_TO_CONSIDER],
  550. kymograph_skeleton = infos['intensities_skeleton'],
  551. kymograph_skeleton_normals = infos['intensities_skeleton_normals'],
  552. intensities_skeleton_normals_full = infos['intensities_skeleton_normals_full'],
  553. distance_from_nose = SKELETON_INTENSITY_STEPS_UM,
  554. normal_scales_pixel = np.arange(-SKELETON_NORMALS_HALF_LENGTH_PIXELS, SKELETON_NORMALS_HALF_LENGTH_PIXELS+1).astype(np.float32),
  555. )
  556. ## Matlab
  557. savemat(
  558. FP_WRITE_MAT_KYMOGRAPH_ALL,
  559. dict(
  560. timestamps = times_beh[INDICES_TO_CONSIDER],
  561. kymograph_skeleton = infos['intensities_skeleton'],
  562. kymograph_skeleton_normals = infos['intensities_skeleton_normals'],
  563. distance_from_nose = SKELETON_INTENSITY_STEPS_UM,
  564. normal_scales_pixel = np.arange(-SKELETON_NORMALS_HALF_LENGTH_PIXELS, SKELETON_NORMALS_HALF_LENGTH_PIXELS+1).astype(np.float32),
  565. )
  566. )
  567. #
  568. _ = gc.collect()
  569. def plotting_function(kymograph, fp_write, title = None):
  570. plt.ioff()
  571. plt.figure(figsize=FIGURE_SIZE_LARGE, dpi=FIGURE_DPI)
  572. plt.title(title)
  573. plt.imshow( kymograph, cmap='gray')
  574. # X ticks
  575. _ticks = np.arange(NT)
  576. _labels = (1000*(times_beh[INDICES_TO_CONSIDER] - times_beh[INDICES_TO_CONSIDER[0]]).round(5) ).astype(np.int64)
  577. plt.xticks(ticks=_ticks[::TICK_INTERVAL_TIME], labels=_labels[::TICK_INTERVAL_TIME], rotation=90)
  578. plt.xlabel("Milliseconds")
  579. # Y ticks
  580. _n = kymograph.shape[0]
  581. _ticks = np.arange(len(dists_to_interpolate_px))[:_n]
  582. _labels = ( dists_to_interpolate_px * UM_PER_PIXEL ).round(1)[-_n:]
  583. plt.yticks(ticks=_ticks[::TICK_INTERVAL_DISTANCE], labels=_labels[::TICK_INTERVAL_DISTANCE])
  584. plt.ylabel("Distance from nose (um)")
  585. plt.savefig(fp_write, bbox_inches='tight')
  586. plt.clf()
  587. plt.cla()
  588. plt.close('all')
  589. _ = gc.collect()
  590. return
  591. ############################################
  592. plotting_function(
  593. kymograph = infos['intensities_skeleton'].T[:,:],
  594. fp_write = FP_WRITE_IMAGE_KYMOGRAPH,
  595. title = "Kymograph of intensity along the body"
  596. )
  597. plotting_function(
  598. kymograph = infos['intensities_skeleton_normals'].T[:,:],
  599. fp_write = FP_WRITE_IMAGE_KYMOGRAPH_NORMALS,
  600. title = "Kymograph of intensity along normals along the body"
  601. )
  602. ############################################
  603. # Normalize the kymograph?!
  604. _tmp = infos['intensities_skeleton'].T.copy()
  605. _tmp = _tmp[:, :]
  606. _tmp = np.clip(
  607. 128*(_tmp / np.nanmean(_tmp, axis=0, keepdims=True))**1.5,
  608. 0, 255
  609. )
  610. plt.ioff()
  611. plt.figure(figsize=FIGURE_SIZE_XLARGE, dpi=FIGURE_DPI)
  612. plt.title("Kymograph of intensity along the body - Rescaled")
  613. plt.imshow( _tmp, cmap='gray')
  614. # X ticks
  615. _ticks = np.arange(NT)
  616. _labels = (1000*(times_beh[INDICES_TO_CONSIDER] - times_beh[INDICES_TO_CONSIDER[0]]).round(5) ).astype(np.int64)
  617. plt.xticks(ticks=_ticks[::TICK_INTERVAL_TIME], labels=INDICES_TO_CONSIDER[::TICK_INTERVAL_TIME], rotation=90)
  618. plt.xlabel("Milliseconds")
  619. # Y ticks
  620. _ticks = np.arange(len(dists_to_interpolate_px))[:100]
  621. _labels = ( dists_to_interpolate_px * UM_PER_PIXEL ).round(1)[:100]
  622. plt.yticks(ticks=_ticks[::TICK_INTERVAL_DISTANCE], labels=_labels[::TICK_INTERVAL_DISTANCE])
  623. plt.ylabel("Distance from nose (um)")
  624. plt.savefig(FP_WRITE_IMAGE_KYMOGRAPH_SCALED, bbox_inches='tight')
  625. plt.clf()
  626. plt.cla()
  627. plt.close('all')
  628. ############################################
  629. # Normalize the kymograph?!
  630. _tmp = infos['intensities_skeleton_normals'].T.copy()
  631. _tmp = _tmp[:, :]
  632. _tmp = np.clip(
  633. 128*(_tmp / np.nanmean(_tmp, axis=0, keepdims=True))**1.5,
  634. 0, 255
  635. )
  636. plt.ioff()
  637. plt.figure(figsize=FIGURE_SIZE_XLARGE, dpi=FIGURE_DPI)
  638. plt.title("Kymograph of intensity along the body - Rescaled")
  639. plt.imshow( _tmp, cmap='gray')
  640. # X ticks
  641. _ticks = np.arange(NT)
  642. _labels = (1000*(times_beh[INDICES_TO_CONSIDER] - times_beh[INDICES_TO_CONSIDER[0]]).round(5) ).astype(np.int64)
  643. plt.xticks(ticks=_ticks[::TICK_INTERVAL_TIME], labels=INDICES_TO_CONSIDER[::TICK_INTERVAL_TIME], rotation=90)
  644. plt.xlabel("Milliseconds")
  645. # Y ticks
  646. _ticks = np.arange(len(dists_to_interpolate_px))[:100]
  647. _labels = ( dists_to_interpolate_px * UM_PER_PIXEL ).round(1)[:100]
  648. plt.yticks(ticks=_ticks[::TICK_INTERVAL_DISTANCE], labels=_labels[::TICK_INTERVAL_DISTANCE])
  649. plt.ylabel("Distance from nose (um)")
  650. plt.savefig(FP_WRITE_IMAGE_KYMOGRAPH_NORMALS_SCALED, bbox_inches='tight')
  651. plt.clf()
  652. plt.cla()
  653. plt.close('all')
  654. _ = gc.collect()
  655. #######################################################################
  656. # Main Function
  657. #
  658. # This function is the entry point for the script. It loads the files,
  659. # processes the kymographs, and saves the results.
  660. #######################################################################
  661. def download_sample_data(data_dir):
  662. """
  663. Download and extract the sample data from the GitHub release.
  664. The ZIP file is downloaded from the Yeon-2025-pumping-analysis GitHub
  665. release page and extracted into the data directory.
  666. Compatible with Windows, macOS, and Linux. Uses only Python standard
  667. library modules (plus tqdm for progress display).
  668. Args:
  669. data_dir: Path to the data directory where files will be extracted
  670. """
  671. import urllib.request
  672. import zipfile
  673. import tempfile
  674. DATA_URL = "https://github.com/venkatachalamlab/Yeon-2025-pumping-analysis/releases/download/v1.0/data.zip"
  675. CHUNK_SIZE = 8192 # 8 KB chunks for streaming download
  676. print(f"Sample data not found at: {data_dir}")
  677. print(f"Downloading sample data from GitHub release...")
  678. print(f" URL: {DATA_URL}")
  679. # Use a temporary file for the download (cross-platform safe)
  680. tmp_fd, zip_path = tempfile.mkstemp(suffix=".zip")
  681. os.close(tmp_fd) # Close the file descriptor; we'll open it ourselves
  682. try:
  683. # Download with progress bar
  684. response = urllib.request.urlopen(DATA_URL)
  685. total_size = int(response.headers.get("Content-Length", 0))
  686. with open(zip_path, "wb") as f:
  687. with tqdm(total=total_size, unit="B", unit_scale=True,
  688. unit_divisor=1024, desc="Downloading") as pbar:
  689. while True:
  690. chunk = response.read(CHUNK_SIZE)
  691. if not chunk:
  692. break
  693. f.write(chunk)
  694. pbar.update(len(chunk))
  695. print(f" Download complete.")
  696. except Exception as e:
  697. print(f"ERROR: Failed to download sample data: {e}")
  698. print(f"Please download manually from:")
  699. print(f" {DATA_URL}")
  700. print(f"Then extract into: {data_dir}")
  701. if os.path.exists(zip_path):
  702. os.remove(zip_path)
  703. exit(1)
  704. # Extract with progress bar
  705. print(f"Extracting to: {data_dir}")
  706. os.makedirs(data_dir, exist_ok=True)
  707. try:
  708. with zipfile.ZipFile(zip_path, "r") as zip_ref:
  709. members = zip_ref.infolist()
  710. with tqdm(total=len(members), unit="file", desc="Extracting") as pbar:
  711. for member in members:
  712. zip_ref.extract(member, data_dir)
  713. pbar.update(1)
  714. print(f" Extraction complete.")
  715. except Exception as e:
  716. print(f"ERROR: Failed to extract sample data: {e}")
  717. exit(1)
  718. finally:
  719. # Clean up the temporary ZIP file
  720. if os.path.exists(zip_path):
  721. os.remove(zip_path)
  722. print(f"Sample data is ready at: {data_dir}")
  723. if __name__ == "__main__":
  724. # Check if any behavior recording folders exist in the data directory.
  725. # Note: config.py creates data/ and its subdirectories on import, so
  726. # we check for actual *_behavior folders with .h5 files instead.
  727. _behavior_folders = glob(os.path.join(FP_READ_FOLDER, "*_behavior"))
  728. if len(_behavior_folders) == 0:
  729. download_sample_data(FP_READ_FOLDER)
  730. print(f"Starting kymograph generation for data in: {FP_READ_FOLDER}")
  731. # Expected folder and files structure
  732. # FP_READ_FOLDER
  733. # |-> WORM1_behavior
  734. # | |-> 000.h5
  735. # | |-> 001.h5
  736. # |-> WORM2_behavior
  737. # | |-> 000.h5
  738. # ...
  739. # Run
  740. files_beh, _, times_beh, series_beh = load_files_data_times_persistent(
  741. fp_folder=os.path.join( FP_READ_FOLDER, "*_behavior" ),
  742. fp_folder_persistence=os.path.join( FP_READ_FOLDER, "metadata" )
  743. )
  744. _, NX, NY = series_beh.shape
  745. # Annotation texts
  746. _n = len(times_beh)
  747. texts = [ "" ]
  748. for idx in range(1,_n):
  749. _T = times_beh[idx]-times_beh[0]
  750. _fps = 1/(times_beh[idx]-times_beh[idx-1])
  751. texts.append(
  752. ", {:>6.2f}s [fps: {:>4.1f}]".format(_T, _fps)
  753. )
  754. print(f"Behavior Series Loaded: records={len(series_beh)}")
  755. # Figure out files frame counts
  756. ns_per_recording = dict()
  757. recording_states = list()
  758. for file, n in zip(files_beh, series_beh.ns):
  759. name_recording = file.filename.split(os.sep)[-2]
  760. worm_id, condition, strain = name_recording.split("_")[:3]
  761. if name_recording not in ns_per_recording:
  762. ns_per_recording[name_recording] = n
  763. recording_states.append((worm_id, condition, strain))
  764. else:
  765. ns_per_recording[name_recording] += n
  766. indices_per_recording = np.array([0] + [
  767. ns_per_recording[k] for k in sorted(ns_per_recording)
  768. ])
  769. indices_per_recording = np.cumsum(indices_per_recording)
  770. print(list(zip( indices_per_recording[:-1], indices_per_recording[1:] )))
  771. # Manual Head-Tail confusion fixes
  772. # Possible good candidate that might have pumping as well
  773. from collections import defaultdict
  774. ## Intervals to extrct pumping from
  775. # e.g. this is by default the whole recording
  776. indices_inbetween_pairs = list(zip( indices_per_recording[:-1], indices_per_recording[1:] ))
  777. print(indices_inbetween_pairs)
  778. ## Indices where skeleton head-tail is confused
  779. ## Make sure you only add indices once, and only do it once for each interval
  780. # e.g. the head-tail remains consistent until the worm bends or the skeleton is not extractable by tierpsy
  781. # after the skeleton is again calculable, it might get head and tail confused.
  782. indices_skeleton_reverse = defaultdict(set)
  783. indices_skeleton_reverse[(0, 400)] = {0,}
  784. indices_skeleton_reverse[(400, 800)] = {400,}
  785. # indices_skeleton_reverse[(0, len(times_beh))] = set() # Add frame indices from the annotated video where the confusion starts and it will be flipped for the following frames as well
  786. # E.g. indices_skeleton_reverse[(0, 5956)] = { 1772, 2567, 4428, }
  787. # This means that the skeleton is reversed at frames 1772, 2567, 4428.
  788. # MANUAL PART! -> you need to add the indices manually if needed.
  789. # Make videos, kymographs and store data for all
  790. for (idx_start, idx_end), (worm_id, condition, strain) in zip(indices_inbetween_pairs,recording_states):
  791. print(f"##### {datetime.datetime.now()} Interval processing: {str(idx_start).zfill(8)}_{str(idx_end).zfill(8)}")
  792. # Extract all
  793. key = (idx_start, idx_end)
  794. ## Create Paths
  795. fp_read_pumping_kymograph = os.path.join(
  796. FP_PUMPING_EXTRACTS,
  797. f"{worm_id}_{condition}_{strain}_pumping_{str(idx_start).zfill(8)}_{str(idx_end).zfill(8)}_kymograph_all.npz"
  798. )
  799. fp_write_pumping_kymograph_png = os.path.join(
  800. FP_PUMPING_EXTRACTS,
  801. f"{worm_id}_{condition}_{strain}_pumping_{str(idx_start).zfill(8)}_{str(idx_end).zfill(8)}_kymograph_normals_true.png"
  802. )
  803. # Skip if exists
  804. if os.path.exists(fp_write_pumping_kymograph_png):
  805. continue
  806. # Report
  807. print("####"*20)
  808. print(f"#### {datetime.datetime.now()} Procesing interval: {str(idx_start).zfill(8)}-{str(idx_end).zfill(8)}")
  809. print("####"*20)
  810. # Do the thing
  811. process_kymograph_segment(idx_start, idx_end, indices_skeleton_reverse=indices_skeleton_reverse[key], worm_id=worm_id, condition=condition, strain=strain )
  812. print("####"*20)
  813. print(f"#### {datetime.datetime.now()} Finished!")
  814. print("####"*20)
  815. # Load Kymograph convert and write
  816. with np.load(fp_read_pumping_kymograph) as file_kymograph:
  817. # Load & Convert
  818. food_entry_normals_kymograph_uint8 = file_kymograph['kymograph_skeleton_normals']
  819. food_entry_normals_kymograph_uint8[np.isnan(food_entry_normals_kymograph_uint8)] = 255.0
  820. food_entry_normals_kymograph_uint8 = np.clip(food_entry_normals_kymograph_uint8, 0.0, 255.0).astype(np.uint8)
  821. ## Resize
  822. nx, ny = food_entry_normals_kymograph_uint8.shape
  823. food_entry_normals_kymograph_uint8 = cv.resize( food_entry_normals_kymograph_uint8, (ny, nx*IMAGE_RESIZE_FACTOR) )
  824. # Write
  825. plt.imsave(
  826. fp_write_pumping_kymograph_png,
  827. food_entry_normals_kymograph_uint8.T, cmap='gray',
  828. vmin=np.floor(np.nanmin(food_entry_normals_kymograph_uint8)), vmax=np.ceil(np.nanmax(food_entry_normals_kymograph_uint8))
  829. )
  830. # Load all NPZ files and create rolled kymographs
  831. fps_cases = sorted(glob(
  832. os.path.join(FP_PUMPING_EXTRACTS, "*.npz")
  833. ))
  834. print(f"Found {len(fps_cases)} cases")
  835. if len(fps_cases) == 0:
  836. raise ValueError(f"No NPZ files found in {FP_PUMPING_EXTRACTS}")
  837. # Generate aligned kymograph images for manual annotation
  838. for fp_npz in tqdm(fps_cases):
  839. # File path
  840. filename = fp_npz.split(os.sep)[-1]
  841. fp_write_png = os.path.join(
  842. FP_PUMPING_ANALYSIS,
  843. f"{filename[:-4]}.png"
  844. )
  845. # Skip if exists
  846. if os.path.exists(fp_write_png):
  847. continue
  848. # Load
  849. intensities_skeleton_normals_avg, intensities_skeleton_normals_avg_rolled = load_and_align_kymograph(fp_npz)
  850. # Store
  851. plt.imsave(
  852. fp_write_png,
  853. intensities_skeleton_normals_avg_rolled.T,
  854. cmap='gray',
  855. vmin=np.nanquantile( intensities_skeleton_normals_avg_rolled, 0.01 ),
  856. vmax=np.nanquantile( intensities_skeleton_normals_avg_rolled, 0.99 )
  857. )

01_make_kymographs.py at commit 2157177, under MIT · at the source

Overview

Authors: Jihye Yeon1, Jinmahn Kim2, Koji Sato3, Stephen Nurrish1, Laurie Chen1, Nikhila Krishnan1, Sam Bates1, Sayoko Ihara3, Sina Rasouli2, Charmi Porwal1,4, Vivek Venkatachalam2, Kazushige Touhara3, Piali Sengupta1
  1. Department of Biology, Brandeis University, Waltham, MA USA
  2. Department of Physics, Northeastern University, Boston, MA USA
  3. Department of Applied Biological Chemistry, Graduate School of Agricultural and Life Sciences, The University of Tokyo, Tokyo, Japan
  4. Present Address: Department of Biology, State University of New York, Albany, NY USA
Journal: Nature, volume 654, issue 8120, pages 1023-1032
Dates: received 22 March 2025; accepted 2 March 2026; published online 1 April 2026; in print 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41586-026-10348-3 · PMID 41922765 · PMCID PMC13293861 · OpenAlex W7147035867
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: C. elegans (organism), cellular / molecular (subfield)
Methods: Spectral & time-frequency, Statistics, Smoothing, state filtering, decompositions, Evoked potentials, fMRI & imaging, Single-unit activity, calcium imaging
Keywords: Sensory processing, Ion channels in the nervous system, Stress and resilience
MeSH: Caenorhabditis elegans*, Caenorhabditis elegans Proteins*, Enteric Nervous System*, Neurons*, Salt Tolerance*, Acclimatization, Animals, Mutation, Pharynx, Salt Stress, Signal Transduction (* major topic)
Topic: Genetics, Aging, and Longevity in Model Organisms (Aging, Biochemistry, Genetics and Molecular Biology), according to OpenAlex
Funding: NINDS NIH HHS (R01 NS126334); NIGMS NIH HHS (R35 GM122463); NIDDK NIH HHS (R01 DK142077)
Citations: cited by 1 paper (Europe PMC); 76 references in the paper
Research resources: RRID:SCR_025892

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

Its files are read in the Code ↔ Paper reader above.

venkatachalamlab/Yeon-2025-pumping-analysis

License: MIT
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 21571776c1725b6b5da0816ac40a3da81de16fd0, 10 February 2026
Languages: Python (198), Shell (6), C (2)
Size: 301 files, 206 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (requirements.txt, tierpsy-tracker/requirements.txt, tierpsy-tracker/setup.py, tierpsy-tracker/docker/Dockerfile, tierpsy-tracker/tierpsy/analysis/ske_create/segWormPython/cython_files/_old/setup.py), tests, documentation
Not found: CITATION.cff, continuous integration
Tools: NumPy (98 files), pandas (46 files), OpenCV (26 files), SciPy (24 files), Matplotlib (19 files), PyTorch (8 files), scikit-image (5 files), h5py (3 files), Keras (3 files), scikit-learn (2 files), Numba (1 file), Pillow (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
208 files

Zenodo 13748735

License: CC-BY-4.0
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers (HTTP 200)
  • 28 September 2026: the link answers (HTTP 200)
At the source:

Code availability statement

The paper has a code availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41586-026-10348-3.

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;
  • 206 scripts, each with its path and the digest of its content;
  • no match between paragraphs and code yet;
  • 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 statement

The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41586-026-10348-3.

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 2, 28 September 2026

  • Publisher: n/a → Nature Portfolio

Version 1, 28 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 13 authors, 3 keywords, 11 MeSH terms, 3 funders, 75 references, 1 RRID.

Cite

This paper

Yeon, J., Kim, J., Sato, K., Nurrish, S., Chen, L., Krishnan, N., Bates, S., Ihara, S., Rasouli, S., Porwal, C., Venkatachalam, V., Touhara, K., & Sengupta, P. (2026). An enteric neuron ionotropic receptor regulates salt stress resistance. Nature, 654(8120), 1023-1032. https://doi.org/10.1038/s41586-026-10348-3

BibTeX

@article{yeon2026enteric,
author = {Yeon, Jihye and Kim, Jinmahn and Sato, Koji and Nurrish, Stephen and Chen, Laurie and Krishnan, Nikhila and Bates, Sam and Ihara, Sayoko and Rasouli, Sina and Porwal, Charmi and Venkatachalam, Vivek and Touhara, Kazushige and Sengupta, Piali},
title = {{An enteric neuron ionotropic receptor regulates salt stress resistance}},
journal = {Nature},
year = {2026},
month = apr,
volume = {654},
number = {8120},
pages = {1023--1032},
publisher = {Nature Portfolio},
issn = {0028-0836},
doi = {10.1038/s41586-026-10348-3},
url = {https://doi.org/10.1038/s41586-026-10348-3},
pmid = {41922765},
pmcid = {PMC13293861}
}

RIS

TY - JOUR
AU - Yeon, Jihye
AU - Kim, Jinmahn
AU - Sato, Koji
AU - Nurrish, Stephen
AU - Chen, Laurie
AU - Krishnan, Nikhila
AU - Bates, Sam
AU - Ihara, Sayoko
AU - Rasouli, Sina
AU - Porwal, Charmi
AU - Venkatachalam, Vivek
AU - Touhara, Kazushige
AU - Sengupta, Piali
TI - An enteric neuron ionotropic receptor regulates salt stress resistance
T2 - Nature
J2 - Nature
PY - 2026
DA - 2026/04/01
VL - 654
IS - 8120
SP - 1023
EP - 1032
SN - 0028-0836
PB - Nature Portfolio
DO - 10.1038/s41586-026-10348-3
UR - https://doi.org/10.1038/s41586-026-10348-3
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41586-026-10348-3",
"type": "article-journal",
"title": "An enteric neuron ionotropic receptor regulates salt stress resistance",
"container-title": "Nature",
"author": [
{
"family": "Yeon",
"given": "Jihye"
},
{
"family": "Kim",
"given": "Jinmahn"
},
{
"family": "Sato",
"given": "Koji"
},
{
"family": "Nurrish",
"given": "Stephen"
},
{
"family": "Chen",
"given": "Laurie"
},
{
"family": "Krishnan",
"given": "Nikhila"
},
{
"family": "Bates",
"given": "Sam"
},
{
"family": "Ihara",
"given": "Sayoko"
},
{
"family": "Rasouli",
"given": "Sina"
},
{
"family": "Porwal",
"given": "Charmi"
},
{
"family": "Venkatachalam",
"given": "Vivek"
},
{
"family": "Touhara",
"given": "Kazushige"
},
{
"family": "Sengupta",
"given": "Piali"
}
],
"container-title-short": "Nature",
"volume": "654",
"issue": "8120",
"page": "1023-1032",
"DOI": "10.1038/s41586-026-10348-3",
"PMID": "41922765",
"PMCID": "PMC13293861",
"ISSN": "0028-0836",
"publisher": "Nature Portfolio",
"URL": "https://doi.org/10.1038/s41586-026-10348-3",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
1
]
]
}
}

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/s41593-026-02257-5 [code]
Neural sequences underlying directed turning in Caenorhabditis elegans.
Journal: Nature neuroscience
In common: h5py, Pillow, PyTorch, 4 other tools, C. elegans, 7 references
[2] doi:10.1038/s41467-026-72057-9 [code]
Sex-specific behavioral feedback modulates sensorimotor processing and drives flexible social behavior.
Journal: Nature communications
In common: Keras, Numba, OpenCV, 10 other tools
[3] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: Keras, Numba, OpenCV, 9 other tools, cellular / molecular
[4] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: Keras, Numba, OpenCV, 9 other tools
[5] 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: Numba, OpenCV, scikit-image, 9 other tools, 1 reference
[6] 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: Keras, Numba, OpenCV, 8 other tools, cellular / molecular, 1 reference
[7] 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: Keras, Numba, OpenCV, 9 other tools
[8] doi:10.1016/j.cub.2026.06.016 [code]
Neuronal RNAi and oxygen-sensing circuit shape germline resilience to heat stress.
Journal: Current biology : CB
In common: Numba, statsmodels, scikit-learn, 4 other tools, C. elegans, 4 references
[9] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: Numba, OpenCV, scikit-image, 9 other tools
[10] doi:10.1038/s41467-026-72709-w [code]
An epifluorescence microscope design for naturalistic behavior and cellular activity in freely moving Caenorhabditis elegans.
Journal: Nature communications
In common: OpenCV, scikit-image, h5py, 6 other tools, C. elegans, cellular / molecular, 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.