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.
@@ -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)