[wip] Frame capture and FFmpeg video output

pull/357/head
nyanpasu64 2018-07-22 02:06:10 -07:00
rodzic 46df1b8adc
commit 9816001f9d
4 zmienionych plików z 233 dodań i 15 usunięć

149
ovgenpy/outputs.py 100644
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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