Skip to content

Commit d4af1a9

Browse files
authored
reorganized Figure export stuff (#710)
1 parent 497f574 commit d4af1a9

File tree

1 file changed

+37
-166
lines changed

1 file changed

+37
-166
lines changed

fastplotlib/layouts/_figure.py

Lines changed: 37 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,37 @@ def clear(self):
568568
for subplot in self:
569569
subplot.clear()
570570

571+
def export_numpy(self, rgb: bool = False) -> np.ndarray:
572+
"""
573+
Export a snapshot of the Figure as numpy array.
574+
575+
Parameters
576+
----------
577+
rgb: bool, default ``False``
578+
if True, use alpha blending to return an RGB image.
579+
if False, returns an RGBA array
580+
581+
Returns
582+
-------
583+
np.ndarray
584+
[n_rows, n_cols, 3] for RGB or [n_rows, n_cols, 4] for RGBA
585+
"""
586+
snapshot = self.renderer.snapshot()
587+
588+
if rgb:
589+
bg = np.zeros(snapshot.shape).astype(np.uint8)
590+
bg[:, :, -1] = 255
591+
592+
img_alpha = snapshot[..., -1] / 255
593+
594+
rgb = snapshot[..., :-1] * img_alpha[..., None] + bg[..., :-1] * np.ones(
595+
img_alpha.shape
596+
)[..., None] * (1 - img_alpha[..., None])
597+
598+
return rgb.astype(np.uint8)
599+
600+
return snapshot
601+
571602
def export(self, uri: str | Path | bytes, **kwargs):
572603
"""
573604
Use ``imageio`` for writing the current Figure to a file, or return a byte string.
@@ -593,24 +624,18 @@ def export(self, uri: str | Path | bytes, **kwargs):
593624
"conda install -c conda-forge imageio\n"
594625
)
595626
else:
596-
snapshot = self.renderer.snapshot()
597-
remove_alpha = True
598-
599627
# image formats that support alpha channel:
600628
# https://en.wikipedia.org/wiki/Alpha_compositing#Image_formats_supporting_alpha_channels
601629
alpha_support = [".png", ".exr", ".tiff", ".tif", ".gif", ".jxl", ".svg"]
602630

603-
if isinstance(uri, str):
604-
if any([uri.endswith(ext) for ext in alpha_support]):
605-
remove_alpha = False
631+
uri = Path(uri)
606632

607-
elif isinstance(uri, Path):
608-
if uri.suffix in alpha_support:
609-
remove_alpha = False
633+
if uri.suffix in alpha_support:
634+
rgb = False
635+
else:
636+
rgb = True
610637

611-
if remove_alpha:
612-
# remove alpha channel if it's not supported
613-
snapshot = snapshot[..., :-1].shape
638+
snapshot = self.export_numpy(rgb=rgb)
614639

615640
return iio.imwrite(uri, snapshot, **kwargs)
616641

@@ -660,157 +685,3 @@ def __repr__(self):
660685
f"\t{newline.join(subplot.__str__() for subplot in self)}"
661686
f"\n"
662687
)
663-
664-
665-
class FigureRecorder:
666-
def __init__(self, figure: Figure):
667-
self._figure = figure
668-
self._video_writer: VideoWriterAV = None
669-
self._video_writer_queue = Queue()
670-
self._record_fps = 25
671-
self._record_timer = 0
672-
self._record_start_time = 0
673-
674-
def _record(self):
675-
"""
676-
Sends frame to VideoWriter through video writer queue
677-
"""
678-
# current time
679-
t = time()
680-
681-
# put frame in queue only if enough time as passed according to the desired framerate
682-
# otherwise it tries to record EVERY frame on every rendering cycle, which just blocks the rendering
683-
if t - self._record_timer < (1 / self._record_fps):
684-
return
685-
686-
# reset timer
687-
self._record_timer = t
688-
689-
if self._video_writer is not None:
690-
ss = self._figure.canvas.snapshot()
691-
# exclude alpha channel
692-
self._video_writer_queue.put(ss.data[..., :-1])
693-
694-
def start(
695-
self,
696-
path: str | Path,
697-
fps: int = 25,
698-
codec: str = "mpeg4",
699-
pixel_format: str = "yuv420p",
700-
options: dict = None,
701-
):
702-
"""
703-
Start a recording, experimental. Call ``record_end()`` to end a recording.
704-
Note: playback duration does not exactly match recording duration.
705-
706-
Requires PyAV: https://github.com/PyAV-Org/PyAV
707-
708-
**Do not resize canvas during a recording, the width and height must remain constant!**
709-
710-
Parameters
711-
----------
712-
path: str or Path
713-
path to save the recording
714-
715-
fps: int, default ``25``
716-
framerate, do not use > 25 within jupyter
717-
718-
codec: str, default "mpeg4"
719-
codec to use, see ``ffmpeg`` list: https://www.ffmpeg.org/ffmpeg-codecs.html .
720-
In general, ``"mpeg4"`` should work on most systems. ``"libx264"`` is a
721-
better option if you have it installed.
722-
723-
pixel_format: str, default "yuv420p"
724-
pixel format
725-
726-
options: dict, optional
727-
Codec options. For example, if using ``"mpeg4"`` you can use ``{"q:v": "20"}`` to set the quality between
728-
1-31, where "1" is highest and "31" is lowest. If using ``"libx264"``` you can use ``{"crf": "30"}`` where
729-
the "crf" value is between "0" (highest quality) and "50" (lowest quality). See ``ffmpeg`` docs for more
730-
info on codec options
731-
732-
Examples
733-
--------
734-
735-
With ``"mpeg4"``
736-
737-
.. code-block:: python
738-
739-
# start recording video
740-
figure.recorder.start("./video.mp4", options={"q:v": "20"}
741-
742-
# do stuff like interacting with the plot, change things, etc.
743-
744-
# end recording
745-
figure.recorder.stop()
746-
747-
With ``"libx264"``
748-
749-
.. code-block:: python
750-
751-
# start recording video
752-
figure.recorder.start("./vid_x264.mp4", codec="libx264", options={"crf": "25"})
753-
754-
# do stuff like interacting with the plot, change things, etc.
755-
756-
# end recording
757-
figure.recorder.stop()
758-
759-
"""
760-
761-
if Path(path).exists():
762-
raise FileExistsError(f"File already exists at given path: {path}")
763-
764-
# queue for sending frames to VideoWriterAV process
765-
self._video_writer_queue = Queue()
766-
767-
# snapshot to get canvas width height
768-
ss = self._figure.canvas.snapshot()
769-
770-
# writer process
771-
self._video_writer = VideoWriterAV(
772-
path=str(path),
773-
queue=self._video_writer_queue,
774-
fps=int(fps),
775-
width=ss.width,
776-
height=ss.height,
777-
codec=codec,
778-
pixel_format=pixel_format,
779-
options=options,
780-
)
781-
782-
# start writer process
783-
self._video_writer.start()
784-
785-
# 1.3 seems to work well to reduce that difference between playback time and recording time
786-
# will properly investigate later
787-
self._record_fps = fps * 1.3
788-
self._record_start_time = time()
789-
790-
# record timer used to maintain desired framerate
791-
self._record_timer = time()
792-
793-
self._figure.add_animations(self._record)
794-
795-
def stop(self) -> float:
796-
"""
797-
End a current recording. Returns the real duration of the recording
798-
799-
Returns
800-
-------
801-
float
802-
recording duration
803-
"""
804-
805-
# tell video writer that recording has finished
806-
self._video_writer_queue.put(None)
807-
808-
# wait for writer to finish
809-
self._video_writer.join(timeout=5)
810-
811-
self._video_writer = None
812-
813-
# so self._record() is no longer called on every render cycle
814-
self._figure.remove_animation(self._record)
815-
816-
return time() - self._record_start_time

0 commit comments

Comments
 (0)