mantis-rtm 0.1.1.dev0__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.
- mantis/__init__.py +5 -0
- mantis/_version.py +24 -0
- mantis/diff_lib.py +1937 -0
- mantis/generate_equation_system.py +1975 -0
- mantis/live_plot_lib.py +623 -0
- mantis/model.py +942 -0
- mantis/monitor_plots.py +171 -0
- mantis/monolithic_solver.py +904 -0
- mantis/parallel_video.py +523 -0
- mantis/plot_data_new.py +1363 -0
- mantis/solvers_numba.py +219 -0
- mantis_rtm-0.1.1.dev0.dist-info/METADATA +175 -0
- mantis_rtm-0.1.1.dev0.dist-info/RECORD +17 -0
- mantis_rtm-0.1.1.dev0.dist-info/WHEEL +5 -0
- mantis_rtm-0.1.1.dev0.dist-info/entry_points.txt +6 -0
- mantis_rtm-0.1.1.dev0.dist-info/licenses/LICENSE +661 -0
- mantis_rtm-0.1.1.dev0.dist-info/top_level.txt +1 -0
mantis/parallel_video.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import multiprocessing as mp
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from mantis.live_plot_lib import BoundedVideoQueue
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import pathlib as pl
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_default_video_workers() -> int:
|
|
22
|
+
"""
|
|
23
|
+
Return the default number of worker processes for parallel video rendering.
|
|
24
|
+
Restricted to 60% of all available CPU cores, minimum 1.
|
|
25
|
+
"""
|
|
26
|
+
cores = os.cpu_count() or 1
|
|
27
|
+
return max(1, int(cores * 0.6))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_ffmpeg_encoder(target_ext: str = ".mp4") -> Tuple[str, List[str]]:
|
|
31
|
+
"""
|
|
32
|
+
Detect the most suitable FFmpeg video encoder available on the system.
|
|
33
|
+
Tests candidate encoders with a minimal 1-frame probe to ensure compatibility.
|
|
34
|
+
Prioritizes libx264, libopenh264, mpeg4, and libvpx-vp9.
|
|
35
|
+
"""
|
|
36
|
+
is_webm = target_ext.lower() == ".webm"
|
|
37
|
+
|
|
38
|
+
if is_webm:
|
|
39
|
+
candidates = [
|
|
40
|
+
("libvpx-vp9", ["-c:v", "libvpx-vp9", "-b:v", "4M", "-pix_fmt", "yuv420p"]),
|
|
41
|
+
("libvpx", ["-c:v", "libvpx", "-b:v", "4M", "-pix_fmt", "yuv420p"]),
|
|
42
|
+
]
|
|
43
|
+
else:
|
|
44
|
+
candidates = [
|
|
45
|
+
("libx264", ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p"]),
|
|
46
|
+
("libopenh264", ["-c:v", "libopenh264", "-pix_fmt", "yuv420p", "-b:v", "4M"]),
|
|
47
|
+
("mpeg4", ["-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p"]),
|
|
48
|
+
("libvpx-vp9", ["-c:v", "libvpx-vp9", "-b:v", "4M", "-pix_fmt", "yuv420p"]),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
res = subprocess.run(
|
|
53
|
+
["ffmpeg", "-encoders"],
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.PIPE,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
)
|
|
59
|
+
stdout = res.stdout
|
|
60
|
+
except Exception:
|
|
61
|
+
fallback = ("mpeg4", ["-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p"])
|
|
62
|
+
return fallback
|
|
63
|
+
|
|
64
|
+
for name, args in candidates:
|
|
65
|
+
matched = any(
|
|
66
|
+
name in line and line.strip().startswith("V")
|
|
67
|
+
for line in stdout.splitlines()
|
|
68
|
+
)
|
|
69
|
+
if not matched:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Probe with a 16x16 raw frame to verify encoder works
|
|
73
|
+
probe_cmd = [
|
|
74
|
+
"ffmpeg", "-y",
|
|
75
|
+
"-f", "rawvideo",
|
|
76
|
+
"-vcodec", "rawvideo",
|
|
77
|
+
"-s", "16x16",
|
|
78
|
+
"-pix_fmt", "rgba",
|
|
79
|
+
"-r", "1",
|
|
80
|
+
"-i", "-",
|
|
81
|
+
*args,
|
|
82
|
+
"-frames:v", "1",
|
|
83
|
+
"-f", "null", "-",
|
|
84
|
+
]
|
|
85
|
+
try:
|
|
86
|
+
probe = subprocess.run(
|
|
87
|
+
probe_cmd,
|
|
88
|
+
input=b"\x00" * (16 * 16 * 4),
|
|
89
|
+
stdout=subprocess.DEVNULL,
|
|
90
|
+
stderr=subprocess.DEVNULL,
|
|
91
|
+
timeout=5,
|
|
92
|
+
)
|
|
93
|
+
if probe.returncode == 0:
|
|
94
|
+
return name, args
|
|
95
|
+
except Exception:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Fallback to mpeg4
|
|
99
|
+
return "mpeg4", ["-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class IndexedVideoQueue:
|
|
103
|
+
"""
|
|
104
|
+
Queue wrapper that intercepts put() calls from the simulation solver,
|
|
105
|
+
attaches an incrementing frame index (0, 1, 2, ...), caches the latest snapshot,
|
|
106
|
+
and delegates to an underlying BoundedVideoQueue.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, bounded_queue: BoundedVideoQueue):
|
|
110
|
+
self._bq = bounded_queue
|
|
111
|
+
self._frame_idx = 0
|
|
112
|
+
self._last_snapshot: Optional[Tuple[Dict[str, np.ndarray], str]] = None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def last_snapshot(self) -> Optional[Tuple[Dict[str, np.ndarray], str]]:
|
|
116
|
+
return self._last_snapshot
|
|
117
|
+
|
|
118
|
+
def put(self, item: Any, block: bool = True, timeout: Optional[float] = None) -> bool:
|
|
119
|
+
if item is None:
|
|
120
|
+
return self._bq.put(None, block=block, timeout=timeout)
|
|
121
|
+
|
|
122
|
+
data, title = item
|
|
123
|
+
self._last_snapshot = (data, title)
|
|
124
|
+
indexed_item = (self._frame_idx, data, title)
|
|
125
|
+
self._frame_idx += 1
|
|
126
|
+
return self._bq.put(indexed_item, block=block, timeout=timeout)
|
|
127
|
+
|
|
128
|
+
def put_nowait(self, item: Any) -> bool:
|
|
129
|
+
return self.put(item, block=False)
|
|
130
|
+
|
|
131
|
+
def get(self, block: bool = True, timeout: Optional[float] = None) -> Any:
|
|
132
|
+
return self._bq.get(block=block, timeout=timeout)
|
|
133
|
+
|
|
134
|
+
def get_nowait(self) -> Any:
|
|
135
|
+
return self._bq.get_nowait()
|
|
136
|
+
|
|
137
|
+
def qsize(self) -> int:
|
|
138
|
+
return self._bq.qsize()
|
|
139
|
+
|
|
140
|
+
def empty(self) -> bool:
|
|
141
|
+
return self._bq.empty()
|
|
142
|
+
|
|
143
|
+
def full(self) -> bool:
|
|
144
|
+
return self._bq.full()
|
|
145
|
+
|
|
146
|
+
def stop(self) -> None:
|
|
147
|
+
self._bq.stop()
|
|
148
|
+
|
|
149
|
+
def cancel_join_thread(self) -> None:
|
|
150
|
+
self._bq.cancel_join_thread()
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
self._bq.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _video_render_worker(
|
|
157
|
+
worker_id: int,
|
|
158
|
+
layout_path: str,
|
|
159
|
+
display_length: float,
|
|
160
|
+
measured_data_path: Optional[str],
|
|
161
|
+
dpi: int,
|
|
162
|
+
task_queue: Any,
|
|
163
|
+
result_queue: Any,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Worker process target function.
|
|
167
|
+
Reads (frame_idx, data, title) from task_queue, renders plot into Matplotlib Agg,
|
|
168
|
+
extracts raw RGBA bytes, and pushes (frame_idx, buf, width, height) to result_queue.
|
|
169
|
+
Reuses figure handle across frames for maximum speed.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
os.setpgrp()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
if hasattr(signal, "SIGINT"):
|
|
177
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
178
|
+
if hasattr(signal, "SIGTERM"):
|
|
179
|
+
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0))
|
|
180
|
+
if hasattr(signal, "SIGPIPE"):
|
|
181
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
182
|
+
|
|
183
|
+
import matplotlib
|
|
184
|
+
matplotlib.use("Agg")
|
|
185
|
+
matplotlib.rcParams["figure.dpi"] = dpi
|
|
186
|
+
matplotlib.rcParams["savefig.dpi"] = dpi
|
|
187
|
+
import matplotlib.pyplot as plt
|
|
188
|
+
import mantis.plot_data_new as plot_data_new
|
|
189
|
+
|
|
190
|
+
fig = None
|
|
191
|
+
ax_objects = None
|
|
192
|
+
plt_desc = None
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
item = task_queue.get()
|
|
197
|
+
except Exception:
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
if item is None:
|
|
201
|
+
# Termination sentinel
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
frame_idx, data, title = item
|
|
205
|
+
try:
|
|
206
|
+
df = pd.DataFrame(data)
|
|
207
|
+
plt_desc = plot_data_new.load_layout_from_file(df, layout_path, measured_data_path)
|
|
208
|
+
|
|
209
|
+
if fig is None:
|
|
210
|
+
fig, ax_objects = plot_data_new.plot(
|
|
211
|
+
df,
|
|
212
|
+
display_length,
|
|
213
|
+
outfile=None,
|
|
214
|
+
show=False,
|
|
215
|
+
plot_description=plt_desc,
|
|
216
|
+
measured_data_path=measured_data_path,
|
|
217
|
+
keep_open=True,
|
|
218
|
+
title=title,
|
|
219
|
+
)
|
|
220
|
+
if dpi:
|
|
221
|
+
fig.set_dpi(dpi)
|
|
222
|
+
else:
|
|
223
|
+
plot_data_new.plot(
|
|
224
|
+
df,
|
|
225
|
+
display_length,
|
|
226
|
+
outfile=None,
|
|
227
|
+
show=False,
|
|
228
|
+
fig_handle=fig,
|
|
229
|
+
plot_description=plt_desc,
|
|
230
|
+
measured_data_path=measured_data_path,
|
|
231
|
+
keep_open=True,
|
|
232
|
+
title=title,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
fig.canvas.draw()
|
|
236
|
+
w, h = fig.canvas.get_width_height()
|
|
237
|
+
buf = bytes(fig.canvas.buffer_rgba())
|
|
238
|
+
result_queue.put((frame_idx, buf, w, h))
|
|
239
|
+
except Exception as e:
|
|
240
|
+
print(f"[ParallelVideoWorker {worker_id}] Error rendering frame {frame_idx}: {e}", flush=True)
|
|
241
|
+
result_queue.put((frame_idx, None, 0, 0))
|
|
242
|
+
|
|
243
|
+
if fig is not None:
|
|
244
|
+
try:
|
|
245
|
+
plt.close(fig)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# Signal completion of this worker
|
|
250
|
+
try:
|
|
251
|
+
result_queue.put(None)
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _video_sequencer(
|
|
257
|
+
video_path: str,
|
|
258
|
+
fps: int,
|
|
259
|
+
num_workers: int,
|
|
260
|
+
result_queue: Any,
|
|
261
|
+
stop_event: Any,
|
|
262
|
+
codec_name: str,
|
|
263
|
+
codec_args: List[str],
|
|
264
|
+
) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Sequencer process target function.
|
|
267
|
+
Collects rendered frames from workers, reassembles them in strict sequential order
|
|
268
|
+
(0, 1, 2, ...), and streams raw RGBA buffers directly into FFmpeg's stdin pipe.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
os.setpgrp()
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
if hasattr(signal, "SIGINT"):
|
|
276
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
277
|
+
if hasattr(signal, "SIGTERM"):
|
|
278
|
+
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0))
|
|
279
|
+
if hasattr(signal, "SIGPIPE"):
|
|
280
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
281
|
+
|
|
282
|
+
next_frame_idx = 0
|
|
283
|
+
pending_frames: Dict[int, Tuple[Optional[bytes], int, int]] = {}
|
|
284
|
+
ffmpeg_proc: Optional[subprocess.Popen] = None
|
|
285
|
+
workers_finished = 0
|
|
286
|
+
last_valid_buf: Optional[bytes] = None
|
|
287
|
+
total_encoded_frames = 0
|
|
288
|
+
|
|
289
|
+
def _ensure_ffmpeg(w: int, h: int) -> None:
|
|
290
|
+
nonlocal ffmpeg_proc
|
|
291
|
+
if ffmpeg_proc is None:
|
|
292
|
+
dirname = os.path.dirname(os.path.abspath(video_path))
|
|
293
|
+
if dirname:
|
|
294
|
+
os.makedirs(dirname, exist_ok=True)
|
|
295
|
+
cmd = [
|
|
296
|
+
"ffmpeg", "-y",
|
|
297
|
+
"-f", "rawvideo",
|
|
298
|
+
"-vcodec", "rawvideo",
|
|
299
|
+
"-s", f"{w}x{h}",
|
|
300
|
+
"-pix_fmt", "rgba",
|
|
301
|
+
"-r", str(fps),
|
|
302
|
+
"-i", "-",
|
|
303
|
+
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
304
|
+
*codec_args,
|
|
305
|
+
video_path,
|
|
306
|
+
]
|
|
307
|
+
ffmpeg_proc = subprocess.Popen(
|
|
308
|
+
cmd,
|
|
309
|
+
stdin=subprocess.PIPE,
|
|
310
|
+
stdout=subprocess.DEVNULL,
|
|
311
|
+
stderr=subprocess.PIPE,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
while True:
|
|
315
|
+
try:
|
|
316
|
+
res = result_queue.get(timeout=0.2)
|
|
317
|
+
except queue.Empty:
|
|
318
|
+
if stop_event.is_set() and result_queue.empty():
|
|
319
|
+
break
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
if res is None:
|
|
323
|
+
workers_finished += 1
|
|
324
|
+
if workers_finished >= num_workers:
|
|
325
|
+
break
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
frame_idx, buf, w, h = res
|
|
329
|
+
pending_frames[frame_idx] = (buf, w, h)
|
|
330
|
+
|
|
331
|
+
while next_frame_idx in pending_frames:
|
|
332
|
+
f_buf, fw, fh = pending_frames.pop(next_frame_idx)
|
|
333
|
+
if f_buf is not None:
|
|
334
|
+
_ensure_ffmpeg(fw, fh)
|
|
335
|
+
last_valid_buf = f_buf
|
|
336
|
+
try:
|
|
337
|
+
ffmpeg_proc.stdin.write(f_buf) # type: ignore[union-attr]
|
|
338
|
+
total_encoded_frames += 1
|
|
339
|
+
except (BrokenPipeError, OSError) as e:
|
|
340
|
+
print(f"[ParallelVideoSequencer] BrokenPipeError writing frame {next_frame_idx}: {e}", flush=True)
|
|
341
|
+
break
|
|
342
|
+
elif last_valid_buf is not None and ffmpeg_proc is not None:
|
|
343
|
+
# Fallback: duplicate last valid frame on worker render error
|
|
344
|
+
try:
|
|
345
|
+
ffmpeg_proc.stdin.write(last_valid_buf)
|
|
346
|
+
total_encoded_frames += 1
|
|
347
|
+
except (BrokenPipeError, OSError):
|
|
348
|
+
break
|
|
349
|
+
next_frame_idx += 1
|
|
350
|
+
|
|
351
|
+
# Drain any remaining sequential pending frames
|
|
352
|
+
while next_frame_idx in pending_frames:
|
|
353
|
+
f_buf, fw, fh = pending_frames.pop(next_frame_idx)
|
|
354
|
+
if f_buf is not None:
|
|
355
|
+
_ensure_ffmpeg(fw, fh)
|
|
356
|
+
try:
|
|
357
|
+
ffmpeg_proc.stdin.write(f_buf) # type: ignore[union-attr]
|
|
358
|
+
total_encoded_frames += 1
|
|
359
|
+
except (BrokenPipeError, OSError):
|
|
360
|
+
break
|
|
361
|
+
next_frame_idx += 1
|
|
362
|
+
|
|
363
|
+
if ffmpeg_proc is not None:
|
|
364
|
+
try:
|
|
365
|
+
ffmpeg_proc.stdin.close()
|
|
366
|
+
ffmpeg_proc.wait(timeout=30)
|
|
367
|
+
if ffmpeg_proc.returncode != 0:
|
|
368
|
+
err_msg = ffmpeg_proc.stderr.read().decode("utf-8", errors="replace") if ffmpeg_proc.stderr else ""
|
|
369
|
+
print(f"[ParallelVideoSequencer] FFmpeg returned error {ffmpeg_proc.returncode}: {err_msg}", flush=True)
|
|
370
|
+
else:
|
|
371
|
+
print(
|
|
372
|
+
f"[ParallelVideoSequencer] Successfully encoded {total_encoded_frames} frames to {video_path} using {codec_name}",
|
|
373
|
+
flush=True,
|
|
374
|
+
)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
ffmpeg_proc.kill()
|
|
377
|
+
print(f"[ParallelVideoSequencer] Exception finishing FFmpeg: {e}", flush=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ParallelVideoManager:
|
|
381
|
+
"""
|
|
382
|
+
Manages multi-core concurrent video rendering for MANTIS.
|
|
383
|
+
Spawns worker processes (defaulting to 60% of all CPU cores) and a sequencer process.
|
|
384
|
+
Provides backpressure through an IndexedVideoQueue (default max 100 entries).
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
layout_path: str,
|
|
390
|
+
display_length: float,
|
|
391
|
+
video_path: str,
|
|
392
|
+
measured_data_path: Optional[str] = None,
|
|
393
|
+
fps: int = 15,
|
|
394
|
+
video_dpi: int = 120,
|
|
395
|
+
video_workers: Optional[int] = None,
|
|
396
|
+
max_queue_size: int = 100,
|
|
397
|
+
resume_threshold: int = 50,
|
|
398
|
+
):
|
|
399
|
+
self.layout_path = layout_path
|
|
400
|
+
self.display_length = display_length
|
|
401
|
+
self.video_path = video_path
|
|
402
|
+
self.measured_data_path = measured_data_path
|
|
403
|
+
self.fps = fps
|
|
404
|
+
self.video_dpi = video_dpi
|
|
405
|
+
|
|
406
|
+
total_cores = os.cpu_count() or 1
|
|
407
|
+
if video_workers is None or video_workers <= 0:
|
|
408
|
+
self.num_workers = get_default_video_workers()
|
|
409
|
+
else:
|
|
410
|
+
self.num_workers = max(1, min(video_workers, total_cores))
|
|
411
|
+
|
|
412
|
+
self.max_queue_size = max_queue_size
|
|
413
|
+
self.resume_threshold = resume_threshold
|
|
414
|
+
|
|
415
|
+
self._ctx = mp.get_context("spawn")
|
|
416
|
+
self._raw_task_queue = BoundedVideoQueue(
|
|
417
|
+
maxsize=self.max_queue_size,
|
|
418
|
+
resume_threshold=self.resume_threshold,
|
|
419
|
+
ctx=self._ctx,
|
|
420
|
+
)
|
|
421
|
+
self._queue_wrapper = IndexedVideoQueue(self._raw_task_queue)
|
|
422
|
+
self._result_queue = self._ctx.Queue()
|
|
423
|
+
self._stop_event = self._ctx.Event()
|
|
424
|
+
|
|
425
|
+
self._workers: List[mp.Process] = []
|
|
426
|
+
self._sequencer: Optional[mp.Process] = None
|
|
427
|
+
self._started = False
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def queue(self) -> IndexedVideoQueue:
|
|
431
|
+
return self._queue_wrapper
|
|
432
|
+
|
|
433
|
+
def start(self) -> None:
|
|
434
|
+
"""Spawn sequencer and worker processes."""
|
|
435
|
+
if self._started:
|
|
436
|
+
return
|
|
437
|
+
self._started = True
|
|
438
|
+
|
|
439
|
+
ext = os.path.splitext(self.video_path)[1]
|
|
440
|
+
codec_name, codec_args = detect_ffmpeg_encoder(target_ext=ext)
|
|
441
|
+
print(
|
|
442
|
+
f"[ParallelVideoManager] Starting {self.num_workers} workers (60% cores) "
|
|
443
|
+
f"with encoder '{codec_name}', dpi={self.video_dpi}, queue_size={self.max_queue_size}...",
|
|
444
|
+
flush=True,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
self._sequencer = self._ctx.Process(
|
|
448
|
+
target=_video_sequencer,
|
|
449
|
+
args=(
|
|
450
|
+
self.video_path,
|
|
451
|
+
self.fps,
|
|
452
|
+
self.num_workers,
|
|
453
|
+
self._result_queue,
|
|
454
|
+
self._stop_event,
|
|
455
|
+
codec_name,
|
|
456
|
+
codec_args,
|
|
457
|
+
),
|
|
458
|
+
daemon=False,
|
|
459
|
+
)
|
|
460
|
+
self._sequencer.start()
|
|
461
|
+
|
|
462
|
+
for wid in range(self.num_workers):
|
|
463
|
+
p = self._ctx.Process(
|
|
464
|
+
target=_video_render_worker,
|
|
465
|
+
args=(
|
|
466
|
+
wid,
|
|
467
|
+
self.layout_path,
|
|
468
|
+
self.display_length,
|
|
469
|
+
self.measured_data_path,
|
|
470
|
+
self.video_dpi,
|
|
471
|
+
self._raw_task_queue,
|
|
472
|
+
self._result_queue,
|
|
473
|
+
),
|
|
474
|
+
daemon=False,
|
|
475
|
+
)
|
|
476
|
+
p.start()
|
|
477
|
+
self._workers.append(p)
|
|
478
|
+
|
|
479
|
+
def stop(self) -> None:
|
|
480
|
+
"""Gracefully terminate workers and sequencer, flushing all queued frames."""
|
|
481
|
+
if not self._started:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
print(f"[ParallelVideoManager] Stopping: sending {self.num_workers} sentinels...", flush=True)
|
|
485
|
+
|
|
486
|
+
# Send sentinels to all workers
|
|
487
|
+
for _ in range(self.num_workers):
|
|
488
|
+
try:
|
|
489
|
+
self._raw_task_queue.put(None, timeout=2.0)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
# Wait for workers to finish
|
|
494
|
+
for p in self._workers:
|
|
495
|
+
p.join(timeout=30)
|
|
496
|
+
if p.is_alive():
|
|
497
|
+
print(f"[ParallelVideoManager] Worker {p.pid} did not terminate; killing.", flush=True)
|
|
498
|
+
p.terminate()
|
|
499
|
+
p.join(timeout=2)
|
|
500
|
+
|
|
501
|
+
# Wait for sequencer to finish
|
|
502
|
+
if self._sequencer and self._sequencer.is_alive():
|
|
503
|
+
self._sequencer.join(timeout=45)
|
|
504
|
+
if self._sequencer.is_alive():
|
|
505
|
+
print(f"[ParallelVideoManager] Sequencer did not terminate; killing.", flush=True)
|
|
506
|
+
self._sequencer.terminate()
|
|
507
|
+
self._sequencer.join(timeout=2)
|
|
508
|
+
|
|
509
|
+
self._stop_event.set()
|
|
510
|
+
try:
|
|
511
|
+
self._raw_task_queue.cancel_join_thread()
|
|
512
|
+
self._raw_task_queue.close()
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
self._result_queue.cancel_join_thread()
|
|
518
|
+
self._result_queue.close()
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
self._started = False
|
|
523
|
+
print("[ParallelVideoManager] Stopped successfully.", flush=True)
|