Skip to content

Commit bae40d4

Browse files
committed
improvde image and video export from viewers
1 parent 2bd0e4d commit bae40d4

4 files changed

Lines changed: 428 additions & 133 deletions

File tree

python/themachinethatgoesping/pingprocessing/widgets/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
from .echogramviewer_pyqtgraph import *
1111
from .echogramviewer_pyqtgraph2 import EchogramViewerMultiChannel
1212
from .wciviewer_pyqtgraph2 import WCIViewerMultiChannel
13+
from .videoframes import VideoFrames
1314
from .mapviewer_pyqtgraph import MapViewerPyQtGraph
1415
from . import tools

python/themachinethatgoesping/pingprocessing/widgets/echogramviewer_pyqtgraph2.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2007,12 +2007,86 @@ def _get_slot_for_echogram(self, echogram_key: str) -> Optional[EchogramSlot]:
20072007
return slot
20082008
return None
20092009

2010+
# =========================================================================
2011+
# Export / Scene Access
2012+
# =========================================================================
2013+
2014+
def get_scene(self) -> QtWidgets.QGraphicsScene:
2015+
"""Return the QGraphicsScene backing the viewer.
2016+
2017+
Returns
2018+
-------
2019+
QGraphicsScene
2020+
The scene that contains all plot items.
2021+
"""
2022+
return self.graphics.gfxView.scene()
2023+
2024+
def save_scene(self, filename: str = "scene.svg") -> None:
2025+
"""Export the current scene to an SVG file.
2026+
2027+
Parameters
2028+
----------
2029+
filename : str
2030+
Output file path (should end in .svg).
2031+
"""
2032+
import pyqtgraph.exporters
2033+
exporter = pg.exporters.SVGExporter(self.get_scene())
2034+
exporter.export(filename)
2035+
2036+
def get_matplotlib(
2037+
self,
2038+
dpi: int = 150,
2039+
):
2040+
"""Render the current scene to a matplotlib Figure.
2041+
2042+
Parameters
2043+
----------
2044+
dpi : int
2045+
Resolution of the rasterised image.
2046+
2047+
Returns
2048+
-------
2049+
matplotlib.figure.Figure
2050+
A matplotlib figure showing the current viewer state.
2051+
"""
2052+
import matplotlib.pyplot as plt
2053+
import matplotlib.image as mpimg
2054+
import io
2055+
2056+
# Render scene to QImage
2057+
scene = self.get_scene()
2058+
rect = scene.sceneRect()
2059+
w = int(rect.width())
2060+
h = int(rect.height())
2061+
if w == 0 or h == 0:
2062+
w, h = self.widget_width_px, self.widget_height_px
2063+
2064+
image = QtGui.QImage(w, h, QtGui.QImage.Format.Format_ARGB32)
2065+
image.fill(QtCore.Qt.GlobalColor.white)
2066+
painter = QtGui.QPainter(image)
2067+
scene.render(painter)
2068+
painter.end()
2069+
2070+
# Convert QImage -> numpy array
2071+
ptr = image.bits()
2072+
if hasattr(ptr, 'setsize'):
2073+
ptr.setsize(h * w * 4)
2074+
arr = np.frombuffer(ptr, np.uint8).reshape((h, w, 4)).copy()
2075+
# BGRA -> RGBA
2076+
arr = arr[..., [2, 1, 0, 3]]
2077+
2078+
fig, ax = plt.subplots(dpi=dpi)
2079+
ax.imshow(arr)
2080+
ax.set_axis_off()
2081+
fig.tight_layout(pad=0)
2082+
return fig
2083+
20102084
# =========================================================================
20112085
# UI Event Handlers
20122086
# =========================================================================
20132087

20142088
def _on_layout_change(self, change: Dict[str, Any]) -> None:
2015-
"""Handle grid layout change."""
2089+
"""Handle grid layout change.""
20162090
new_rows, new_cols = change['new']
20172091
20182092
# Skip if no actual change
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""VideoFrames – container for captured viewer frames with export helpers."""
2+
from __future__ import annotations
3+
4+
from typing import Any, List, Optional
5+
6+
import numpy as np
7+
8+
9+
class VideoFrames:
10+
"""Container for captured video frames with per-frame metadata.
11+
12+
Stores RGB numpy arrays together with timestamps so that export
13+
can use either a fixed frame rate or timing derived from the
14+
actual ping timestamps.
15+
16+
Examples
17+
--------
18+
>>> viewer.frames.export_avif("out.avif", fps=10)
19+
>>> viewer.frames.export_mp4("out.mp4", fps=25)
20+
>>> viewer.frames.export_avif("out.avif", ping_time_speed=3.0)
21+
"""
22+
23+
def __init__(self) -> None:
24+
self._frames: List[np.ndarray] = [] # RGB uint8 arrays
25+
self._timestamps: List[Optional[float]] = [] # unix timestamps per frame
26+
27+
# -- mutation ----------------------------------------------------------
28+
29+
def clear(self) -> None:
30+
"""Remove all stored frames."""
31+
self._frames.clear()
32+
self._timestamps.clear()
33+
34+
def append(self, frame: np.ndarray, timestamp: Optional[float] = None) -> None:
35+
"""Append a single RGB frame with optional ping timestamp."""
36+
self._frames.append(frame)
37+
self._timestamps.append(timestamp)
38+
39+
# -- properties --------------------------------------------------------
40+
41+
def __len__(self) -> int:
42+
return len(self._frames)
43+
44+
def __getitem__(self, idx: int) -> np.ndarray:
45+
return self._frames[idx]
46+
47+
@property
48+
def frames(self) -> List[np.ndarray]:
49+
"""All stored RGB frames."""
50+
return self._frames
51+
52+
@property
53+
def timestamps(self) -> List[Optional[float]]:
54+
"""Per-frame timestamps (may contain None)."""
55+
return self._timestamps
56+
57+
# -- timing helpers ----------------------------------------------------
58+
59+
def _compute_durations(self, speed: float = 1.0) -> List[float]:
60+
"""Compute per-frame durations from ping timestamps.
61+
62+
Parameters
63+
----------
64+
speed : float
65+
Speed multiplier applied to the real time gaps.
66+
``speed=3`` means 3× real-time.
67+
68+
Returns
69+
-------
70+
list of float
71+
Duration in seconds for each frame transition.
72+
"""
73+
durations: List[float] = []
74+
for i in range(1, len(self._timestamps)):
75+
t_prev = self._timestamps[i - 1]
76+
t_cur = self._timestamps[i]
77+
if t_prev is not None and t_cur is not None:
78+
dt = abs(t_cur - t_prev) / max(speed, 0.001)
79+
durations.append(max(0.01, dt))
80+
else:
81+
durations.append(0.1) # fallback 100 ms
82+
return durations
83+
84+
# -- export ------------------------------------------------------------
85+
86+
def export_avif(
87+
self,
88+
filename: str = "video.avif",
89+
fps: Optional[float] = None,
90+
ping_time_speed: Optional[float] = None,
91+
quality: int = 75,
92+
loop: int = 0,
93+
) -> str:
94+
"""Export frames as animated AVIF.
95+
96+
Parameters
97+
----------
98+
filename : str
99+
Output path.
100+
fps : float, optional
101+
Fixed frame rate. Ignored when *ping_time_speed* is set.
102+
ping_time_speed : float, optional
103+
Use real ping timestamps scaled by this speed factor
104+
(e.g. 3.0 = 3× real-time).
105+
quality : int
106+
AVIF quality 1–100.
107+
loop : int
108+
Number of loops (0 = infinite).
109+
110+
Returns
111+
-------
112+
str
113+
The filename that was written.
114+
"""
115+
if len(self._frames) == 0:
116+
raise ValueError("No frames to export")
117+
118+
try:
119+
import pillow_avif # noqa: F401
120+
except ImportError:
121+
raise ImportError("pip install pillow-avif-plugin")
122+
from PIL import Image
123+
124+
pil_frames = [Image.fromarray(f) for f in self._frames]
125+
126+
if ping_time_speed is not None:
127+
durations = self._compute_durations(speed=ping_time_speed)
128+
duration_ms: Any = [int(d * 1000) for d in durations]
129+
# first frame needs a duration too
130+
duration_ms.insert(0, duration_ms[0] if duration_ms else 100)
131+
elif fps is not None:
132+
duration_ms = int(1000 / max(fps, 0.1))
133+
else:
134+
duration_ms = 100 # default 10 fps
135+
136+
pil_frames[0].save(
137+
filename,
138+
save_all=True,
139+
append_images=pil_frames[1:],
140+
duration=duration_ms,
141+
loop=loop,
142+
quality=quality,
143+
)
144+
return filename
145+
146+
def export_mp4(
147+
self,
148+
filename: str = "video.mp4",
149+
fps: Optional[float] = None,
150+
ping_time_speed: Optional[float] = None,
151+
codec: str = "libx264",
152+
quality: int = 8,
153+
) -> str:
154+
"""Export frames as MP4 video.
155+
156+
Parameters
157+
----------
158+
filename : str
159+
Output path.
160+
fps : float, optional
161+
Fixed frame rate. Ignored when *ping_time_speed* is set.
162+
ping_time_speed : float, optional
163+
Use real ping timestamps; the *average* resulting fps is
164+
passed to ffmpeg (per-frame variable rate is not supported
165+
by most containers).
166+
codec : str
167+
FFmpeg video codec.
168+
quality : int
169+
FFmpeg quality parameter.
170+
171+
Returns
172+
-------
173+
str
174+
The filename that was written.
175+
"""
176+
if len(self._frames) == 0:
177+
raise ValueError("No frames to export")
178+
179+
try:
180+
import imageio_ffmpeg # noqa: F401
181+
import imageio
182+
except ImportError:
183+
raise ImportError("pip install imageio imageio-ffmpeg")
184+
185+
if ping_time_speed is not None:
186+
durations = self._compute_durations(speed=ping_time_speed)
187+
avg_dur = sum(durations) / len(durations) if durations else 0.1
188+
effective_fps = 1.0 / avg_dur if avg_dur > 0 else 10.0
189+
elif fps is not None:
190+
effective_fps = max(fps, 0.1)
191+
else:
192+
effective_fps = 10.0
193+
194+
writer = imageio.get_writer(filename, fps=effective_fps, codec=codec, quality=quality)
195+
for frame in self._frames:
196+
writer.append_data(frame)
197+
writer.close()
198+
return filename
199+
200+
def __repr__(self) -> str:
201+
ts = [t for t in self._timestamps if t is not None]
202+
dt_str = ""
203+
if len(ts) >= 2:
204+
total = ts[-1] - ts[0]
205+
dt_str = f", span={total:.1f}s"
206+
return f"VideoFrames({len(self._frames)} frames{dt_str})"

0 commit comments

Comments
 (0)