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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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"]
|
mesofield/data/batch.py
ADDED
|
@@ -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
|
+
)
|