mesofield 0.3.2b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -0,0 +1,23 @@
1
+ """Acquisition-time data management.
2
+
3
+ Centralises three responsibilities:
4
+
5
+ - :mod:`~mesofield.data.manager` orchestrates per-run data collection,
6
+ notes, and timestamp writing.
7
+ - :mod:`~mesofield.data.writer` defines the OME-TIFF (:class:`CustomWriter`)
8
+ and MP4 (:class:`CV2Writer`) frame handlers.
9
+ - :mod:`~mesofield.data.batch` provides batch / post-hoc utilities used
10
+ by analysis scripts.
11
+
12
+ ``CustomWriter`` and ``CV2Writer`` are re-exported from this package so
13
+ that ``from mesofield.data import CustomWriter`` continues to work in
14
+ existing experiment scripts.
15
+ """
16
+
17
+ try:
18
+ from .writer import CustomWriter, CV2Writer
19
+ except ImportError: # pymmcore-plus not installed (analysis-only env)
20
+ CustomWriter = None # type: ignore[assignment,misc]
21
+ CV2Writer = None # type: ignore[assignment,misc]
22
+
23
+ __all__ = ["CustomWriter", "CV2Writer"]
@@ -0,0 +1,633 @@
1
+ import os
2
+ import concurrent.futures
3
+ import threading
4
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from tqdm import tqdm
9
+ import tifffile
10
+
11
+ from scipy.ndimage import percentile_filter
12
+
13
+ # Defaults for sCMOS widefield with GCaMP8s — tune per setup.
14
+ DFF_DEFAULTS = dict(
15
+ camera_offset=1602, # validated from dark frames on 260422 in the dhyana-sensitivty experiment
16
+ fs=49.9,
17
+ percentile=8.0,
18
+ window_seconds=60.0, # 'collapse_first' only
19
+ f0_floor=10.0, # Rupprecht's "10 or 20 units" guard
20
+ n_f0_samples=500, # 'pixelwise' only — frames used for global F0
21
+ chunk_frames=2000, # 'pixelwise' only — temporal chunk for Pass 2
22
+ fix_first_frame=True, # replace frame 0 with a copy of frame 1 (hot-pixel/
23
+ # rolling-shutter artifact on the first acquired frame)
24
+ max_frames=58227, # optional cap for methods that should only process
25
+ # an initial segment of the stack
26
+ )
27
+
28
+
29
+ def _apply_first_frame_fix(arr, params):
30
+ """If enabled and arr has >=2 frames along axis 0, overwrite arr[0] with arr[1]."""
31
+ if params.get('fix_first_frame') and arr.shape[0] >= 2:
32
+ arr[0] = arr[1]
33
+ return arr
34
+
35
+ # Set environment variables to suppress OpenCV/FFMPEG output BEFORE importing cv2
36
+ os.environ['OPENCV_LOG_LEVEL'] = 'SILENT'
37
+ os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'loglevel;quiet'
38
+ os.environ['OPENCV_VIDEOIO_DEBUG'] = '0'
39
+ os.environ['OPENCV_VIDEOIO_PRIORITY_FFMPEG'] = '0'
40
+
41
+ import cv2
42
+
43
+ # Set OpenCV logging to silent mode after import
44
+ try:
45
+ if hasattr(cv2, 'setLogLevel'):
46
+ cv2.setLogLevel(0) # 0 = Silent
47
+ elif (
48
+ hasattr(cv2, 'utils')
49
+ and hasattr(cv2.utils, 'logging')
50
+ and hasattr(cv2.utils.logging, 'setLogLevel')
51
+ ):
52
+ cv2.utils.logging.setLogLevel(0)
53
+ except Exception:
54
+ pass
55
+
56
+ # ─── H264 Video Codec ─────────────────────────────────────────────────────
57
+ # OpenH264 codec DLL for OpenCV video encoding (Windows only).
58
+ # The DLL lives at <repo-root>/external/video-codecs/.
59
+ _REPO_ROOT = Path(__file__).resolve().parent.parent.parent
60
+ CODEC_DIRECTORY = str(_REPO_ROOT / "external" / "video-codecs")
61
+ OPENH264_DLL_PATH = str(Path(CODEC_DIRECTORY) / "openh264-1.8.0-win64.dll")
62
+ # ─────────────────────────────────────────────────────────────────
63
+
64
+
65
+ # ─── Progress reporting ───────────────────────────────────────────────────
66
+ # Algorithm functions accept a `progress` callable and just call `progress(n)`
67
+ # after processing `n` frames. The default is a no-op, so algorithms stay
68
+ # unaware of whether progress is wired up. The orchestrator builds a single
69
+ # thread-safe reporter bound to a shared tqdm bar.
70
+
71
+ def _noop_progress(_n):
72
+ pass
73
+
74
+
75
+ def chunked(total, chunk, progress=_noop_progress):
76
+ """Yield (start, end) frame ranges and report progress after each chunk.
77
+
78
+ Use in algorithms instead of a manual `range(0, T, chunk)` loop:
79
+
80
+ for s, e in chunked(T, params['chunk_frames'], progress):
81
+ ...work on frames[s:e]...
82
+ """
83
+ for s in range(0, total, chunk):
84
+ e = min(s + chunk, total)
85
+ yield s, e
86
+ progress(e - s)
87
+
88
+
89
+ def _make_progress_reporter(total_work, desc, enabled=True):
90
+ """Return (progress_fn, close_fn). progress_fn is thread-safe."""
91
+ if not enabled:
92
+ return _noop_progress, lambda: None
93
+ pbar = tqdm(total=total_work, desc=desc, unit="frame",
94
+ unit_scale=True, leave=False)
95
+ lock = threading.Lock()
96
+
97
+ def _update(n):
98
+ with lock:
99
+ pbar.update(n)
100
+
101
+ return _update, pbar.close
102
+
103
+
104
+ # ─── ΔF/F algorithms ──────────────────────────────────────────────────────
105
+ # Each algorithm is a pure function (tiff_path, params, progress) -> np.ndarray.
106
+ # Register it in DFF_METHODS along with a `work(T, params)` estimator that
107
+ # returns the number of "frame units" the algorithm will process, so the
108
+ # orchestrator can size the progress bar without method-specific knowledge.
109
+
110
+ def _dff_collapse_first(tiff_path, params, progress=_noop_progress):
111
+ """Method A: spatial mean → moving-percentile F0 → ΔF/F on the 1D trace.
112
+ Cheap. Pixels weighted by photon yield."""
113
+ tiff_array = tifffile.memmap(tiff_path)
114
+ max_frames = params.get('max_frames')
115
+ if max_frames is not None:
116
+ tiff_array = tiff_array[:max_frames]
117
+ T = tiff_array.shape[0]
118
+
119
+ F = np.empty(T, dtype=np.float32)
120
+ for s, e in chunked(T, params['chunk_frames'], progress):
121
+ F[s:e] = np.mean(tiff_array[s:e], axis=(1, 2),
122
+ dtype=np.float64).astype(np.float32)
123
+
124
+ # Suppress the first-frame acquisition artifact before any ΔF/F math,
125
+ # otherwise the F0 estimator and the ratio at t=0 both get distorted.
126
+ _apply_first_frame_fix(F, params)
127
+
128
+ F -= params['camera_offset']
129
+ w = max(3, int(round(params['window_seconds'] * params['fs'])))
130
+ if w >= len(F):
131
+ F0 = np.full_like(F, np.percentile(F, params['percentile']))
132
+ else:
133
+ F0 = percentile_filter(F, percentile=params['percentile'],
134
+ size=w, mode='nearest').astype(np.float32)
135
+ F0 = np.maximum(F0, 0.0)
136
+ return (F - F0) / (F0 + params['f0_floor'])
137
+
138
+
139
+ def _dff_pixelwise(tiff_path, params, progress=_noop_progress):
140
+ """Method B: per-pixel global percentile F0 → ΔF/F → spatial mean.
141
+ F0 from n_f0_samples temporally-distributed frames (cheap, accurate
142
+ for slow drifts). No drift correction in F0 itself; detrend downstream
143
+ if bleaching matters. Pixels weighted equally — amplifies low-SNR regions."""
144
+ tiff_array = tifffile.memmap(tiff_path)
145
+ T = tiff_array.shape[0]
146
+ offset = params['camera_offset']
147
+
148
+ # Pass 1: F0 from temporally subsampled stack (memory-bounded).
149
+ # np.linspace(0, T-1, n) always includes index 0, so the first-frame
150
+ # artifact would leak into the F0 percentile estimate — fix it here.
151
+ n_samples = min(params['n_f0_samples'], T)
152
+ sample_idx = np.linspace(0, T - 1, n_samples, dtype=int)
153
+ sample_stack = np.asarray(tiff_array[sample_idx], dtype=np.float32)
154
+ if sample_idx[0] == 0:
155
+ _apply_first_frame_fix(sample_stack, params)
156
+ sample_stack -= offset
157
+ F0 = np.percentile(sample_stack, params['percentile'], axis=0)
158
+ F0 = np.maximum(F0, 0.0).astype(np.float32)
159
+ denom = F0 + params['f0_floor']
160
+ del sample_stack
161
+ progress(n_samples)
162
+
163
+ # Pass 2: chunked ΔF/F → spatial mean per frame. Fix the first frame of
164
+ # the very first block (before offset/ratio) so it propagates cleanly.
165
+ out = np.empty(T, dtype=np.float32)
166
+ for s, e in chunked(T, params['chunk_frames'], progress):
167
+ block = np.asarray(tiff_array[s:e], dtype=np.float32)
168
+ if s == 0:
169
+ _apply_first_frame_fix(block, params)
170
+ block -= offset
171
+ out[s:e] = ((block - F0) / denom).mean(axis=(1, 2), dtype=np.float64)
172
+ return out
173
+
174
+
175
+ # Registry: name -> (function, work-estimator). Add new algorithms here and
176
+ # they automatically participate in the shared progress bar.
177
+ DFF_METHODS = {
178
+ 'collapse_first': {
179
+ 'fn': _dff_collapse_first,
180
+ 'work': lambda T, params: min(T, params['max_frames']) if params.get('max_frames') is not None else T,
181
+ },
182
+ 'pixelwise': {
183
+ 'fn': _dff_pixelwise,
184
+ 'work': lambda T, params: T + min(params['n_f0_samples'], T),
185
+ },
186
+ }
187
+
188
+
189
+ def _tiff_frame_count(path):
190
+ try:
191
+ with tifffile.TiffFile(path) as tf:
192
+ return len(tf.pages)
193
+ except Exception:
194
+ return int(tifffile.memmap(path).shape[0])
195
+
196
+
197
+ def dff_trace_from_tiff(tiff_paths, method='collapse_first',
198
+ show_progress=True, max_workers=None, **dff_params):
199
+ """
200
+ Compute ΔF/F traces concurrently across tiff files.
201
+
202
+ method : key into DFF_METHODS (e.g. 'collapse_first', 'pixelwise').
203
+ max_workers : cap this for 'pixelwise' if RAM-bound (each worker holds
204
+ ~ n_f0_samples * H * W * 4 bytes during Pass 1).
205
+ **dff_params overrides DFF_DEFAULTS.
206
+
207
+ Returns dict {tiff_path: np.ndarray (T,) float32}
208
+ """
209
+ params = {**DFF_DEFAULTS, **dff_params}
210
+ spec = DFF_METHODS[method]
211
+ worker, work_of = spec['fn'], spec['work']
212
+
213
+ total_work = sum(work_of(_tiff_frame_count(p), params) for p in tiff_paths)
214
+ progress, close = _make_progress_reporter(
215
+ total_work, desc=f"ΔF/F ({method})", enabled=show_progress)
216
+
217
+ try:
218
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
219
+ futures = {executor.submit(worker, p, params, progress): p
220
+ for p in tiff_paths}
221
+ results = {}
222
+ for future in concurrent.futures.as_completed(futures):
223
+ results[futures[future]] = future.result()
224
+ finally:
225
+ close()
226
+ return results
227
+
228
+ def mean_trace_from_tiff(tiff_paths, show_progress=True, save=False):
229
+ """
230
+ Computes the mean traces for multiple TIFF files concurrently.
231
+ Returns a dictionary mapping each file path to its mean trace.
232
+ """
233
+ import concurrent.futures
234
+
235
+ def _compute_mean_trace(tiff_path):
236
+ tiff_array = tifffile.memmap(tiff_path)
237
+ return np.mean(tiff_array, axis=(1, 2))
238
+
239
+ with ThreadPoolExecutor() as executor:
240
+ futures = {executor.submit(_compute_mean_trace, path): path for path in tiff_paths}
241
+ results = {}
242
+
243
+ if show_progress:
244
+ with tqdm(total=len(tiff_paths), desc="Computing mean traces", leave=False) as pbar:
245
+ for future in concurrent.futures.as_completed(futures):
246
+ path = futures[future]
247
+ results[path] = future.result()
248
+ pbar.update(1)
249
+ else:
250
+ for future in concurrent.futures.as_completed(futures):
251
+ path = futures[future]
252
+ results[path] = future.result()
253
+
254
+ return results
255
+
256
+
257
+ def tiff_to_video(tiff_path: str,
258
+ output_path: str,
259
+ fps: int = 30,
260
+ output_format: str = "mp4",
261
+ use_color: bool = False,
262
+ show_progress: bool = True,
263
+ tqdm_position: int = 0):
264
+ """
265
+ Converts a multi-page TIFF stack to a video format.
266
+ """
267
+
268
+ tiff_array = tifffile.memmap(tiff_path) # shape -> (num_frames, height, width) or (num_frames, height, width, channels)
269
+
270
+ num_frames = tiff_array.shape[0]
271
+ height = tiff_array.shape[1]
272
+ width = tiff_array.shape[2] if not use_color else tiff_array.shape[2]
273
+
274
+ if output_format.lower() == 'avi':
275
+ fourcc = cv2.VideoWriter.fourcc(*'MJPG')
276
+ elif output_format.lower() == 'mp4':
277
+ fourcc = cv2.VideoWriter.fourcc(*'H264')
278
+ else:
279
+ raise ValueError(f"Unsupported output_format '{output_format}'. Use 'avi' or 'mp4'.")
280
+
281
+ out = cv2.VideoWriter(
282
+ filename=output_path,
283
+ fourcc=fourcc,
284
+ fps=fps,
285
+ frameSize=(width, height),
286
+ isColor=use_color
287
+ )
288
+
289
+ # Create a progress bar that updates and then clears itself when done.
290
+ frame_iter = tqdm(
291
+ range(num_frames),
292
+ desc=f"Processing {os.path.basename(tiff_path)}",
293
+ position=tqdm_position,
294
+ leave=False,
295
+ disable=not show_progress
296
+ )
297
+
298
+ for i in frame_iter:
299
+ frame = tiff_array[i]
300
+ if frame.dtype != np.uint8:
301
+ # Normalize the frame to the range [0, 255] before converting to uint8
302
+ frame = cv2.normalize(frame, None, 0, 255, cv2.NORM_MINMAX)
303
+ frame = cv2.convertScaleAbs(frame)
304
+ out.write(frame)
305
+
306
+ out.release()
307
+
308
+
309
+ def _convert_one(args):
310
+ """
311
+ Worker function to convert a single TIFF file to video.
312
+
313
+ Parameters
314
+ ----------
315
+ args : tuple
316
+ A tuple containing (file_path, processed_dir, output_format, fps, use_color, position)
317
+ """
318
+ file_path, processed_dir, output_format, fps, use_color, position = args
319
+ base_filename = os.path.splitext(os.path.basename(file_path))[0]
320
+ output_path = os.path.join(processed_dir, f"{base_filename}.{output_format}")
321
+
322
+ tiff_to_video(
323
+ tiff_path=file_path,
324
+ output_path=output_path,
325
+ fps=fps,
326
+ output_format=output_format,
327
+ use_color=use_color,
328
+ show_progress=True,
329
+ tqdm_position=position
330
+ )
331
+ # Optionally, you could write a message for each finished conversion:
332
+ tqdm.write(f"Finished converting {os.path.basename(file_path)}")
333
+
334
+
335
+ def tiff_to_mp4(parent_directory, fps=30, output_format="mp4", use_color=False):
336
+ """
337
+ Parses the BIDS directory to find pupil.ome.tiff files and converts them to video.
338
+ """
339
+ found_files = []
340
+ for root, dirs, files in os.walk(parent_directory):
341
+ for file in files:
342
+ if file.endswith("pupil.ome.tiff"):
343
+ full_path = os.path.join(root, file)
344
+ found_files.append(full_path)
345
+
346
+ processed_dir = os.path.join(parent_directory, "data", "processed")
347
+ print("Identified the following TIFF files:")
348
+ for tiff_path in found_files:
349
+ print(tiff_path)
350
+ print(f"\nProcessed data will be saved to: {processed_dir}")
351
+
352
+ user_input = input("\nContinue with conversion? (y/n): ")
353
+ if user_input.lower().startswith('y'):
354
+ os.makedirs(processed_dir, exist_ok=True)
355
+
356
+ # Prepare arguments as a list of tuples, including unique progress bar positions
357
+ args_list = [
358
+ (file_path, processed_dir, output_format, fps, use_color, idx)
359
+ for idx, file_path in enumerate(found_files)
360
+ ]
361
+
362
+ print("\nStarting conversion with multiprocessing...")
363
+ futures = []
364
+ with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
365
+ # Submit all tasks and store futures to ensure they all complete.
366
+ for args in args_list:
367
+ futures.append(executor.submit(_convert_one, args))
368
+ # Optionally, wait for all futures to complete
369
+ for future in concurrent.futures.as_completed(futures):
370
+ future.result()
371
+
372
+ else:
373
+ print("Conversion canceled.")
374
+
375
+ print("\nConversion complete.")
376
+
377
+
378
+ def convert_video_to_h264(input_path: str, output_path: str):
379
+ """Convert a single video file to H264 format."""
380
+ import os
381
+ import sys
382
+
383
+ # Use global codec paths
384
+ os.environ['OPENH264_LIBRARY'] = OPENH264_DLL_PATH
385
+
386
+ # Add codec directory to PATH for conda environment
387
+ if CODEC_DIRECTORY not in os.environ.get('PATH', ''):
388
+ os.environ['PATH'] = CODEC_DIRECTORY + os.pathsep + os.environ.get('PATH', '')
389
+
390
+ # Add to DLL search path on Windows (Python 3.8+)
391
+ if hasattr(os, 'add_dll_directory'):
392
+ os.add_dll_directory(CODEC_DIRECTORY)
393
+
394
+ cap = cv2.VideoCapture(input_path)
395
+ if not cap.isOpened():
396
+ raise ValueError(f"Could not open input video: {input_path}")
397
+
398
+ fps = int(cap.get(cv2.CAP_PROP_FPS))
399
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
400
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
401
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
402
+
403
+ fourcc = cv2.VideoWriter.fourcc(*'H264')
404
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height), isColor=False)
405
+
406
+ if not out.isOpened():
407
+ raise ValueError(f"Could not initialize VideoWriter for {output_path} with H264 codec")
408
+
409
+ for _ in range(total_frames):
410
+ ret, frame = cap.read()
411
+ if not ret:
412
+ break
413
+ if len(frame.shape) == 3:
414
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
415
+ out.write(frame)
416
+
417
+ cap.release()
418
+ out.release()
419
+ return output_path
420
+
421
+
422
+ def _convert_video_worker(args):
423
+ """Worker function for video conversion."""
424
+ input_path, output_dir, position = args
425
+ base_name = os.path.splitext(os.path.basename(input_path))[0]
426
+ output_path = os.path.join(output_dir, f"{base_name}_h264.mp4")
427
+
428
+ try:
429
+ convert_video_to_h264(input_path, output_path)
430
+ tqdm.write(f"Converted {os.path.basename(input_path)} to H264")
431
+ except Exception as e:
432
+ tqdm.write(f"Error converting {os.path.basename(input_path)}: {str(e)}")
433
+ raise
434
+
435
+
436
+ def batch_convert_to_h264(parent_directory: str, pattern: str = "*.mp4"):
437
+ """
438
+ Batch convert video files matching pattern to H264 format.
439
+
440
+ Parameters
441
+ ----------
442
+ parent_directory : str
443
+ Root directory to search for video files
444
+ pattern : str
445
+ Glob pattern to match files (e.g., "*.mp4", "*.avi", "pupil*.mp4")
446
+ """
447
+ import fnmatch
448
+
449
+ # Validate input directory
450
+ if not os.path.exists(parent_directory):
451
+ print(f"Error: Directory does not exist: {parent_directory}")
452
+ return
453
+
454
+ # Find all matching video files
455
+ found_files = []
456
+ try:
457
+ for root, dirs, files in os.walk(parent_directory):
458
+ for file in files:
459
+ if fnmatch.fnmatch(file, pattern):
460
+ full_path = os.path.join(root, file)
461
+ found_files.append(full_path)
462
+ except Exception as e:
463
+ print(f"Error scanning directory: {e}")
464
+ return
465
+
466
+ if not found_files:
467
+ print(f"No video files found matching pattern: {pattern}")
468
+ return
469
+
470
+ output_dir = os.path.join(parent_directory, "data", "processed", "converted_mp4_codec")
471
+ print(f"Found {len(found_files)} video files matching '{pattern}':")
472
+ for video_path in found_files:
473
+ print(video_path)
474
+ print(f"\nConverted videos will be saved to: {output_dir}")
475
+
476
+ user_input = input("\nContinue with H264 conversion? (y/n): ")
477
+ if user_input.lower().startswith('y'):
478
+ os.makedirs(output_dir, exist_ok=True)
479
+
480
+ args_list = [
481
+ (file_path, output_dir, idx)
482
+ for idx, file_path in enumerate(found_files)
483
+ ]
484
+
485
+ print("\nStarting H264 conversion with multiprocessing...")
486
+ with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
487
+ futures = [executor.submit(_convert_video_worker, args) for args in args_list]
488
+ for future in concurrent.futures.as_completed(futures):
489
+ future.result()
490
+ else:
491
+ print("Conversion canceled.")
492
+
493
+ print("\nH264 conversion complete.")
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # Codec utility
498
+ # ---------------------------------------------------------------------------
499
+
500
+ def get_video_codec(video_path: str) -> str:
501
+ """Return the FOURCC codec string for the given video file.
502
+
503
+ Raises IOError if the file cannot be opened.
504
+ """
505
+ cap = cv2.VideoCapture(video_path)
506
+ try:
507
+ if not cap.isOpened():
508
+ raise IOError(f"Cannot open video: {video_path!r}")
509
+ codec_int = int(cap.get(cv2.CAP_PROP_FOURCC))
510
+ codec = "".join(chr((codec_int >> 8 * i) & 0xFF) for i in range(4))
511
+ return codec
512
+ finally:
513
+ cap.release()
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Crop + enhance (batch MP4 processing)
518
+ # ---------------------------------------------------------------------------
519
+
520
+ # Defaults for crop_enhance_mp4 — callers may override per-invocation.
521
+ _CROP_DEFAULTS = dict(
522
+ frame_roi=0,
523
+ frame_adjust=0,
524
+ num_samples=3,
525
+ roi_size=512,
526
+ crop_size=256,
527
+ )
528
+
529
+
530
+ def make_square_roi(x, y, w, h, frame_shape, crop_size=256):
531
+ """Convert a rectangular ROI to a square ROI by expanding to a bounding square."""
532
+ frame_h, frame_w = frame_shape[:2]
533
+ size = max(w, h, crop_size)
534
+ center_x, center_y = x + w // 2, y + h // 2
535
+ new_x = max(0, center_x - size // 2)
536
+ new_y = max(0, center_y - size // 2)
537
+ if new_x + size > frame_w:
538
+ new_x = frame_w - size
539
+ if new_y + size > frame_h:
540
+ new_y = frame_h - size
541
+ new_x = max(0, new_x)
542
+ new_y = max(0, new_y)
543
+ size = min(size, frame_w - new_x, frame_h - new_y)
544
+ return int(new_x), int(new_y), int(size), int(size)
545
+
546
+
547
+ def select_rois(video_paths, cached_rois=None, frame_roi=0, crop_size=256):
548
+ """Interactively select square ROIs for a list of videos (skipping cached)."""
549
+ rois = dict(cached_rois or {})
550
+ for path in video_paths:
551
+ key = os.path.basename(path)
552
+ if key in rois:
553
+ continue
554
+ cap = cv2.VideoCapture(path)
555
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_roi)
556
+ _, frame = cap.read()
557
+ cap.release()
558
+ x, y, w, h = cv2.selectROI(f"Select ROI – {key} (will be made square)", frame, False, False)
559
+ cv2.destroyAllWindows()
560
+ x, y, w, h = make_square_roi(x, y, w, h, frame.shape, crop_size)
561
+ rois[key] = [int(x), int(y), int(w), int(h)]
562
+ return rois
563
+
564
+
565
+ def calibrate_adjust(samples, cached_adjust=None):
566
+ """Interactive contrast / brightness / gamma calibration via OpenCV trackbars."""
567
+ if cached_adjust:
568
+ return cached_adjust["alpha"], cached_adjust["beta"], cached_adjust["gamma"]
569
+
570
+ import numpy as _np
571
+
572
+ def nothing(_):
573
+ pass
574
+
575
+ win = "Adjust – press s to save"
576
+ cv2.namedWindow(win, cv2.WINDOW_NORMAL)
577
+ cv2.createTrackbar("Contrast×100", win, 100, 300, nothing)
578
+ cv2.createTrackbar("Brightness", win, 100, 200, nothing)
579
+ cv2.createTrackbar("Gamma×100", win, 100, 300, nothing)
580
+
581
+ while True:
582
+ c = cv2.getTrackbarPos("Contrast×100", win) / 100.0
583
+ b = cv2.getTrackbarPos("Brightness", win) - 100
584
+ g = cv2.getTrackbarPos("Gamma×100", win) / 100.0
585
+ invG = 1.0 / g if g > 0 else 1.0
586
+ table = _np.array([((i / 255.0) ** invG) * 255 for i in range(256)], dtype=_np.uint8)
587
+ adjusted = [cv2.LUT(cv2.convertScaleAbs(f, alpha=c, beta=b), table) for f in samples]
588
+ combo = _np.hstack(adjusted)
589
+ cv2.imshow(win, combo)
590
+ if cv2.waitKey(1) & 0xFF == ord("s"):
591
+ break
592
+
593
+ cv2.destroyAllWindows()
594
+ return c, b, g
595
+
596
+
597
+ def process_video_crop_enhance(path, roi, alpha, beta, gamma, output_dir, roi_size=512):
598
+ """Crop, adjust, and upsample a single video file."""
599
+ x, y, w, h = roi
600
+ cap = cv2.VideoCapture(path)
601
+ fps = cap.get(cv2.CAP_PROP_FPS)
602
+ fourcc = cv2.VideoWriter.fourcc(*"mp4v")
603
+ out = cv2.VideoWriter(
604
+ os.path.join(output_dir, os.path.basename(path)), fourcc, fps, (roi_size, roi_size)
605
+ )
606
+ invG = 1.0 / gamma if gamma > 0 else 1.0
607
+ table = np.array([((i / 255.0) ** invG) * 255 for i in range(256)], dtype=np.uint8)
608
+
609
+ while True:
610
+ ret, frame = cap.read()
611
+ if not ret:
612
+ break
613
+ crop = frame[y : y + h, x : x + w]
614
+ adj = cv2.convertScaleAbs(crop, alpha=alpha, beta=beta)
615
+ adj = cv2.LUT(adj, table)
616
+ upsampled = cv2.resize(adj, (roi_size, roi_size), interpolation=cv2.INTER_CUBIC)
617
+ out.write(upsampled)
618
+
619
+ cap.release()
620
+ out.release()
621
+
622
+
623
+ if __name__ == "__main__":
624
+ # Example usage
625
+ parent_dir = r"" # Replace with your actual parent directory
626
+ frames_per_second = 30
627
+
628
+ tiff_to_mp4(
629
+ parent_directory=parent_dir,
630
+ fps=frames_per_second,
631
+ output_format="mp4",
632
+ use_color=False,
633
+ )