kopia lustrzana https://github.com/corrscope/corrscope
[wip] Frame capture and FFmpeg video output
rodzic
46df1b8adc
commit
9816001f9d
|
@ -0,0 +1,149 @@
|
||||||
|
# https://ffmpeg.org/ffplay.html
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional, TYPE_CHECKING, Type, Callable, TypeVar, List
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ovgenpy.ovgenpy import Config
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_FORMAT = 'png'
|
||||||
|
|
||||||
|
|
||||||
|
class OutputConfig:
|
||||||
|
cls: 'Type[Output]'
|
||||||
|
|
||||||
|
def __call__(self, ovgen_cfg: 'Config'):
|
||||||
|
return self.cls(ovgen_cfg, cfg=self)
|
||||||
|
|
||||||
|
|
||||||
|
class Output(ABC):
|
||||||
|
def __init__(self, ovgen_cfg: 'Config', cfg: OutputConfig):
|
||||||
|
self.ovgen_cfg = ovgen_cfg
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def output_frame_png(self, frame: bytes) -> None:
|
||||||
|
""" Output an encoded PNG file. TODO PNG compression overhead is bad """
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Glue logic
|
||||||
|
|
||||||
|
def register_output(config_t: Type[OutputConfig]):
|
||||||
|
def inner(output_t: Type[Output]):
|
||||||
|
config_t.cls = output_t
|
||||||
|
return output_t
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
# Output subclasses
|
||||||
|
|
||||||
|
## FFMPEG templates TODO rename to "...template..."
|
||||||
|
FFMPEG = 'ffmpeg'
|
||||||
|
FFPLAY = 'ffplay'
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_input_video(fps: int) -> List[str]:
|
||||||
|
# Removed: '-c:v {IMAGE_FORMAT}' since it doesn't work
|
||||||
|
return ['-f image2pipe -framerate', str(fps), '-i -']
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_input_audio(audio_path: str) -> List[str]:
|
||||||
|
return ['-i', audio_path]
|
||||||
|
|
||||||
|
|
||||||
|
FFMPEG_OUTPUT_VIDEO_DEFAULT = '-c:v libx264 -crf 18 -bf 2 -flags +cgop -pix_fmt yuv420p -movflags faststart'
|
||||||
|
FFMPEG_OUTPUT_AUDIO_DEFAULT = '-c:a aac -b:a 384k'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_templates(templates: List[str]) -> List[str]:
|
||||||
|
return [arg
|
||||||
|
for template in templates
|
||||||
|
for arg in shlex.split(template)]
|
||||||
|
|
||||||
|
|
||||||
|
# @dataclass
|
||||||
|
# class FFmpegCommand:
|
||||||
|
# audio: Optional[str] = None
|
||||||
|
#
|
||||||
|
# def generate_command(self):
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FFmpegOutputConfig(OutputConfig):
|
||||||
|
path: str
|
||||||
|
video_template: str = FFMPEG_OUTPUT_VIDEO_DEFAULT
|
||||||
|
audio_template: str = FFMPEG_OUTPUT_AUDIO_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
@register_output(FFmpegOutputConfig)
|
||||||
|
class FFmpegOutput(Output):
|
||||||
|
# TODO https://github.com/kkroening/ffmpeg-python
|
||||||
|
|
||||||
|
def __init__(self, ovgen_cfg: 'Config', cfg: FFmpegOutputConfig):
|
||||||
|
super().__init__(ovgen_cfg, cfg)
|
||||||
|
|
||||||
|
# Input
|
||||||
|
templates: List[str] = [FFMPEG]
|
||||||
|
|
||||||
|
# TODO factor out "get_ffmpeg_input"... what if wrong abstraction?
|
||||||
|
templates += ffmpeg_input_video(fps=ovgen_cfg.fps) # video
|
||||||
|
if ovgen_cfg.audio_path:
|
||||||
|
templates += ffmpeg_input_audio(audio_path=ovgen_cfg.audio_path) # audio
|
||||||
|
|
||||||
|
# Output
|
||||||
|
templates.append(cfg.video_template) # video
|
||||||
|
if ovgen_cfg.audio_path:
|
||||||
|
templates.append(cfg.audio_template) # audio
|
||||||
|
|
||||||
|
templates.append(cfg.path) # output filename
|
||||||
|
|
||||||
|
# Split arguments by words
|
||||||
|
args = parse_templates(templates)
|
||||||
|
|
||||||
|
self._popen = subprocess.Popen(args, stdin=subprocess.PIPE)
|
||||||
|
self._stream = self._popen.stdin
|
||||||
|
|
||||||
|
# Python documentation discourages accessing popen.stdin. It's wrong.
|
||||||
|
# https://stackoverflow.com/a/9886747
|
||||||
|
|
||||||
|
def output_frame_png(self, frame: bytes) -> None:
|
||||||
|
self._stream.write(frame)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stream.close()
|
||||||
|
self._popen.wait()
|
||||||
|
# {ffmpeg}
|
||||||
|
#
|
||||||
|
# # input
|
||||||
|
# -f image2pipe -framerate {framerate} -c:v {IMAGE_FORMAT} -i {img}
|
||||||
|
# -i {audio}
|
||||||
|
#
|
||||||
|
# # output
|
||||||
|
# -c:a aac -b:a 384k
|
||||||
|
# -c:v libx264 -crf 18 -bf 2 -flags +cgop -pix_fmt yuv420p -movflags faststart
|
||||||
|
# {outfile}
|
||||||
|
|
||||||
|
|
||||||
|
class FFplayOutputConfig(OutputConfig):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@register_output(FFplayOutputConfig)
|
||||||
|
class FFplayOutput(Output):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageOutputConfig:
|
||||||
|
path_prefix: str
|
||||||
|
|
||||||
|
|
||||||
|
@register_output(ImageOutputConfig)
|
||||||
|
class ImageOutput(Output):
|
||||||
|
pass
|
|
@ -5,6 +5,7 @@ from pathlib import Path
|
||||||
from typing import NamedTuple, Optional, List
|
from typing import NamedTuple, Optional, List
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
from ovgenpy import outputs
|
||||||
|
|
||||||
from ovgenpy.renderer import MatplotlibRenderer, RendererConfig
|
from ovgenpy.renderer import MatplotlibRenderer, RendererConfig
|
||||||
from ovgenpy.triggers import TriggerConfig, CorrelationTrigger
|
from ovgenpy.triggers import TriggerConfig, CorrelationTrigger
|
||||||
|
@ -23,6 +24,8 @@ class Config(NamedTuple):
|
||||||
|
|
||||||
trigger: TriggerConfig # Maybe overriden per Wave
|
trigger: TriggerConfig # Maybe overriden per Wave
|
||||||
render: RendererConfig
|
render: RendererConfig
|
||||||
|
outputs: List[outputs.OutputConfig]
|
||||||
|
create_window: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def time_visible_s(self) -> float:
|
def time_visible_s(self) -> float:
|
||||||
|
@ -37,9 +40,10 @@ _FPS = 60 # f_s
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument('wave_dir', type=Folder)
|
@click.argument('wave_dir', type=Folder)
|
||||||
@click.option('--master-wave', type=File, default=None)
|
@click.option('--audio_path', type=File, default=None)
|
||||||
@click.option('--fps', default=_FPS)
|
@click.option('--fps', default=_FPS)
|
||||||
def main(wave_dir: str, audio_path: Optional[str], fps: int):
|
@click.option('--output', default='output.mp4')
|
||||||
|
def main(wave_dir: str, audio_path: Optional[str], fps: int, output: str):
|
||||||
cfg = Config(
|
cfg = Config(
|
||||||
wave_dir=wave_dir,
|
wave_dir=wave_dir,
|
||||||
audio_path=audio_path,
|
audio_path=audio_path,
|
||||||
|
@ -57,7 +61,11 @@ def main(wave_dir: str, audio_path: Optional[str], fps: int):
|
||||||
render=RendererConfig( # todo
|
render=RendererConfig( # todo
|
||||||
1280, 720,
|
1280, 720,
|
||||||
ncols=1
|
ncols=1
|
||||||
)
|
),
|
||||||
|
outputs=[
|
||||||
|
outputs.FFmpegOutputConfig(output)
|
||||||
|
],
|
||||||
|
create_window=True
|
||||||
)
|
)
|
||||||
|
|
||||||
ovgen = Ovgen(cfg)
|
ovgen = Ovgen(cfg)
|
||||||
|
@ -72,9 +80,11 @@ class Ovgen:
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.waves: List[Wave] = []
|
self.waves: List[Wave] = []
|
||||||
self.nwaves: int = None
|
self.nwaves: int = None
|
||||||
|
self.outputs: List[outputs.Output] = []
|
||||||
|
|
||||||
def write(self):
|
def write(self):
|
||||||
self._load_waves() # self.waves =
|
self._load_waves() # self.waves =
|
||||||
|
self._load_outputs() # self.outputs =
|
||||||
self._render()
|
self._render()
|
||||||
|
|
||||||
def _load_waves(self):
|
def _load_waves(self):
|
||||||
|
@ -97,15 +107,22 @@ class Ovgen:
|
||||||
|
|
||||||
self.nwaves = len(self.waves)
|
self.nwaves = len(self.waves)
|
||||||
|
|
||||||
|
def _load_outputs(self):
|
||||||
|
self.outputs = []
|
||||||
|
for output_cfg in self.cfg.outputs:
|
||||||
|
output = output_cfg(self.cfg)
|
||||||
|
self.outputs.append(output)
|
||||||
|
|
||||||
def _render(self):
|
def _render(self):
|
||||||
# Calculate number of frames (TODO master file?)
|
# Calculate number of frames (TODO master file?)
|
||||||
time_visible_s = self.cfg.time_visible_s
|
time_visible_s = self.cfg.time_visible_s
|
||||||
fps = self.cfg.fps
|
fps = self.cfg.fps
|
||||||
|
create_window = self.cfg.create_window
|
||||||
|
|
||||||
nframes = fps * self.waves[0].get_s()
|
nframes = fps * self.waves[0].get_s()
|
||||||
nframes = int(nframes) + 1
|
nframes = int(nframes) + 1
|
||||||
|
|
||||||
renderer = MatplotlibRenderer(self.cfg.render, self.nwaves)
|
renderer = MatplotlibRenderer(self.cfg.render, self.nwaves, create_window)
|
||||||
|
|
||||||
if RENDER_PROFILING:
|
if RENDER_PROFILING:
|
||||||
begin = time.perf_counter()
|
begin = time.perf_counter()
|
||||||
|
@ -125,9 +142,18 @@ class Ovgen:
|
||||||
|
|
||||||
datas.append(wave.get_around(trigger_sample, region_len))
|
datas.append(wave.get_around(trigger_sample, region_len))
|
||||||
|
|
||||||
|
# Render frame
|
||||||
print(frame)
|
print(frame)
|
||||||
renderer.render_frame(datas)
|
renderer.render_frame(datas)
|
||||||
|
|
||||||
|
# Output frame
|
||||||
|
frame = renderer.get_frame()
|
||||||
|
|
||||||
|
# TODO write to file
|
||||||
|
# how to write ndarray to ffmpeg?
|
||||||
|
# idea: imageio.mimwrite(stdout, ... wait it's blocking = bad
|
||||||
|
# idea: -f rawvideo, pass cfg.render.options... to ffmpeg_input_video()
|
||||||
|
|
||||||
if RENDER_PROFILING:
|
if RENDER_PROFILING:
|
||||||
# noinspection PyUnboundLocalVariable
|
# noinspection PyUnboundLocalVariable
|
||||||
dtime = time.perf_counter() - begin
|
dtime = time.perf_counter() - begin
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from matplotlib import pyplot as plt
|
from matplotlib import pyplot as plt
|
||||||
from matplotlib.axes import Axes
|
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||||
from matplotlib.figure import Figure
|
|
||||||
from matplotlib.lines import Line2D
|
|
||||||
|
|
||||||
|
from ovgenpy.outputs import IMAGE_FORMAT
|
||||||
from ovgenpy.util import ceildiv
|
from ovgenpy.util import ceildiv
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from matplotlib.axes import Axes
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RendererConfig:
|
class RendererConfig:
|
||||||
|
@ -33,6 +38,9 @@ class RendererConfig:
|
||||||
|
|
||||||
class MatplotlibRenderer:
|
class MatplotlibRenderer:
|
||||||
"""
|
"""
|
||||||
|
Renderer backend which takes data and produces images.
|
||||||
|
Does not touch Wave or Channel.
|
||||||
|
|
||||||
If __init__ reads cfg, cfg cannot be hotswapped.
|
If __init__ reads cfg, cfg cannot be hotswapped.
|
||||||
|
|
||||||
Reasons to hotswap cfg: RendererCfg:
|
Reasons to hotswap cfg: RendererCfg:
|
||||||
|
@ -51,10 +59,10 @@ class MatplotlibRenderer:
|
||||||
|
|
||||||
DPI = 96
|
DPI = 96
|
||||||
|
|
||||||
def __init__(self, cfg: RendererConfig, nplots: int):
|
def __init__(self, cfg: RendererConfig, nplots: int, create_window: bool):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.nplots = nplots
|
self.nplots = nplots
|
||||||
self.fig: Figure = None
|
self.create_window = create_window
|
||||||
|
|
||||||
# Setup layout
|
# Setup layout
|
||||||
# "ncols=1" is good for vertical layouts.
|
# "ncols=1" is good for vertical layouts.
|
||||||
|
@ -64,8 +72,9 @@ class MatplotlibRenderer:
|
||||||
self.ncols = 0
|
self.ncols = 0
|
||||||
|
|
||||||
# Flat array of nrows*ncols elements, ordered by cfg.rows_first.
|
# Flat array of nrows*ncols elements, ordered by cfg.rows_first.
|
||||||
self.axes: List[Axes] = None # set by set_layout()
|
self.fig: 'Figure' = None
|
||||||
self.lines: List[Line2D] = None # set by render_frame() first call
|
self.axes: List['Axes'] = None # set by set_layout()
|
||||||
|
self.lines: List['Line2D'] = None # set by render_frame() first call
|
||||||
|
|
||||||
self.set_layout() # mutates self
|
self.set_layout() # mutates self
|
||||||
|
|
||||||
|
@ -85,7 +94,7 @@ class MatplotlibRenderer:
|
||||||
if self.fig:
|
if self.fig:
|
||||||
plt.close(self.fig) # FIXME
|
plt.close(self.fig) # FIXME
|
||||||
|
|
||||||
axes2d: np.ndarray[Axes]
|
axes2d: np.ndarray['Axes']
|
||||||
self.fig, axes2d = plt.subplots(
|
self.fig, axes2d = plt.subplots(
|
||||||
self.nrows, self.ncols,
|
self.nrows, self.ncols,
|
||||||
squeeze=False,
|
squeeze=False,
|
||||||
|
@ -101,7 +110,7 @@ class MatplotlibRenderer:
|
||||||
if self.cfg.ncols:
|
if self.cfg.ncols:
|
||||||
axes2d = axes2d.T
|
axes2d = axes2d.T
|
||||||
|
|
||||||
self.axes: List[Axes] = axes2d.flatten().tolist()[:self.nplots]
|
self.axes: List['Axes'] = axes2d.flatten().tolist()[:self.nplots]
|
||||||
|
|
||||||
# Setup figure geometry
|
# Setup figure geometry
|
||||||
self.fig.set_dpi(self.DPI)
|
self.fig.set_dpi(self.DPI)
|
||||||
|
@ -109,7 +118,8 @@ class MatplotlibRenderer:
|
||||||
self.cfg.width / self.DPI,
|
self.cfg.width / self.DPI,
|
||||||
self.cfg.height / self.DPI
|
self.cfg.height / self.DPI
|
||||||
)
|
)
|
||||||
plt.show(block=False)
|
if self.create_window:
|
||||||
|
plt.show(block=False)
|
||||||
|
|
||||||
def _calc_layout(self) -> Tuple[int, int]:
|
def _calc_layout(self) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
|
@ -156,3 +166,32 @@ class MatplotlibRenderer:
|
||||||
|
|
||||||
self.fig.canvas.draw()
|
self.fig.canvas.draw()
|
||||||
self.fig.canvas.flush_events()
|
self.fig.canvas.flush_events()
|
||||||
|
|
||||||
|
assert IMAGE_FORMAT == 'png'
|
||||||
|
RGB_DEPTH = 3
|
||||||
|
|
||||||
|
def get_frame(self):
|
||||||
|
canvas = self.fig.canvas
|
||||||
|
|
||||||
|
# Agg is the default noninteractive backend except on OSX.
|
||||||
|
# https://matplotlib.org/faq/usage_faq.html
|
||||||
|
if not isinstance(canvas, FigureCanvasAgg):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'oh shit, cannot read data from {type(canvas)} != FigureCanvasAgg')
|
||||||
|
|
||||||
|
# buffer_rgba, (w, h) = canvas.print_to_buffer()
|
||||||
|
|
||||||
|
w, h = canvas.get_width_height()
|
||||||
|
buffer_rgb = np.frombuffer(canvas.tostring_rgb(), np.uint8)
|
||||||
|
print(buffer_rgb.shape)
|
||||||
|
np.reshape(buffer_rgb, (w, h, self.RGB_DEPTH))
|
||||||
|
|
||||||
|
return buffer_rgb
|
||||||
|
# # TODO https://matplotlib.org/api/_as_gen/matplotlib.pyplot.imsave.html to
|
||||||
|
# # in-memory stream as png
|
||||||
|
#
|
||||||
|
# # or imsave(arr=...)
|
||||||
|
#
|
||||||
|
# # TODO http://www.icare.univ-lille1.fr/tutorials/convert_a_matplotlib_figure
|
||||||
|
#
|
||||||
|
# raise NotImplementedError
|
||||||
|
|
|
@ -43,3 +43,7 @@ def test_renderer(mock_show):
|
||||||
# 2 columns, 8 rows
|
# 2 columns, 8 rows
|
||||||
assert r.ncols == 2
|
assert r.ncols == 2
|
||||||
assert r.nrows == 8
|
assert r.nrows == 8
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: test get_frame()
|
||||||
|
# (integration test) ensure rendering to output works
|
||||||
|
|
Ładowanie…
Reference in New Issue