kopia lustrzana https://github.com/corrscope/corrscope
Make renderer backends pluggable, store constants inside (#267)
rodzic
59965cc789
commit
9f68bff952
|
@ -14,7 +14,7 @@ from corrscope.channel import Channel, ChannelConfig, DefaultLabel
|
|||
from corrscope.config import KeywordAttrs, DumpEnumAsStr, CorrError, with_units
|
||||
from corrscope.layout import LayoutConfig
|
||||
from corrscope.outputs import FFmpegOutputConfig
|
||||
from corrscope.renderer import MatplotlibRenderer, RendererConfig, Renderer
|
||||
from corrscope.renderer import Renderer, RendererConfig, BaseRenderer
|
||||
from corrscope.triggers import CorrelationTriggerConfig, PerFrameCache, SpectrumConfig
|
||||
from corrscope.util import pushd, coalesce
|
||||
from corrscope.wave import Wave, Flatten
|
||||
|
@ -217,9 +217,9 @@ class CorrScope:
|
|||
]
|
||||
yield
|
||||
|
||||
def _load_renderer(self) -> Renderer:
|
||||
def _load_renderer(self) -> BaseRenderer:
|
||||
dummy_datas = [channel.get_render_around(0) for channel in self.channels]
|
||||
renderer = MatplotlibRenderer(
|
||||
renderer = Renderer(
|
||||
self.cfg.render, self.cfg.layout, dummy_datas, self.cfg.channels
|
||||
)
|
||||
return renderer
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# Design Notes
|
||||
|
||||
## Renderer to Output
|
||||
|
||||
- `renderer.py` produces a frame-buffer format which is passed to `outputs.py`. This is a cross-cutting concern, and the 2 modules must agree on bytes per pixel and pixel format.
|
||||
- Each renderer returns a different pixel format, and I switch renderers more often than outputs.
|
||||
- `tests/test_renderer.py` must convert hexadecimal colors to the right pixel format, for comparison.
|
||||
|
||||
### Solution
|
||||
|
||||
- Each `BaseRenderer` subclass exposes classvars `bytes_per_pixel` and `ffmpeg_pixel_format`.
|
||||
- `renderer.py` exposes `Renderer = preferred subclass`.
|
||||
- `outputs.py` imports `renderer.Renderer` and uses the format stored within.
|
||||
- Additionally, `tests/test_renderer.py` uses `Renderer.color_to_bytes()`
|
|
@ -5,19 +5,14 @@ from abc import ABC, abstractmethod
|
|||
from os.path import abspath
|
||||
from typing import TYPE_CHECKING, Type, List, Union, Optional, ClassVar, Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from corrscope.config import DumpableAttrs
|
||||
from corrscope.renderer import ByteBuffer, Renderer
|
||||
from corrscope.settings.paths import MissingFFmpegError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from corrscope.corrscope import Config
|
||||
|
||||
|
||||
ByteBuffer = Union[bytes, np.ndarray]
|
||||
BYTES_PER_PIXEL = 3
|
||||
PIXEL_FORMAT = "rgb24"
|
||||
|
||||
FRAMES_TO_BUFFER = 2
|
||||
|
||||
FFMPEG_QUIET = "-nostats -hide_banner -loglevel error".split()
|
||||
|
@ -44,7 +39,9 @@ class Output(ABC):
|
|||
|
||||
rcfg = corr_cfg.render
|
||||
|
||||
frame_bytes = rcfg.divided_height * rcfg.divided_width * BYTES_PER_PIXEL
|
||||
frame_bytes = (
|
||||
rcfg.divided_height * rcfg.divided_width * Renderer.bytes_per_pixel
|
||||
)
|
||||
self.bufsize = frame_bytes * FRAMES_TO_BUFFER
|
||||
|
||||
def __enter__(self):
|
||||
|
@ -120,8 +117,8 @@ def ffmpeg_input_video(cfg: "Config") -> List[str]:
|
|||
height = cfg.render.divided_height
|
||||
|
||||
return [
|
||||
f"-f rawvideo -pixel_format {PIXEL_FORMAT} -video_size {width}x{height}",
|
||||
f"-framerate {fps}",
|
||||
f"-f rawvideo -pixel_format {Renderer.ffmpeg_pixel_format}",
|
||||
f"-video_size {width}x{height} -framerate {fps}",
|
||||
*FFMPEG_QUIET,
|
||||
"-i -",
|
||||
]
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
import enum
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List, TYPE_CHECKING, Any, Callable, TypeVar
|
||||
from typing import (
|
||||
Optional,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
TypeVar,
|
||||
Sequence,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
import attr
|
||||
import matplotlib # do NOT import anything else until we call matplotlib.use().
|
||||
import matplotlib.colors
|
||||
import numpy as np
|
||||
|
||||
from corrscope.config import DumpableAttrs, with_units, TypedEnumDump
|
||||
|
@ -16,8 +24,7 @@ from corrscope.layout import (
|
|||
RegionSpec,
|
||||
Edges,
|
||||
)
|
||||
from corrscope.outputs import BYTES_PER_PIXEL, ByteBuffer
|
||||
from corrscope.util import coalesce
|
||||
from corrscope.util import coalesce, obj_name
|
||||
|
||||
"""
|
||||
On first import, matplotlib.font_manager spends nearly 10 seconds
|
||||
|
@ -39,19 +46,26 @@ mpl_config_dir = "MPLCONFIGDIR"
|
|||
if mpl_config_dir in os.environ:
|
||||
del os.environ[mpl_config_dir]
|
||||
|
||||
matplotlib.use("agg")
|
||||
# matplotlib.use() only affects pyplot. We don't use pyplot.
|
||||
import matplotlib
|
||||
import matplotlib.colors
|
||||
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matplotlib.artist import Artist
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.backend_bases import FigureCanvasBase
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.spines import Spine
|
||||
from matplotlib.text import Text, Annotation
|
||||
from corrscope.channel import ChannelConfig
|
||||
|
||||
|
||||
# Used by outputs.py.
|
||||
ByteBuffer = Union[bytes, np.ndarray]
|
||||
|
||||
|
||||
def default_color() -> str:
|
||||
# import matplotlib.colors
|
||||
# colors = np.array([int(x, 16) for x in '1f 77 b4'.split()], dtype=float)
|
||||
|
@ -173,8 +187,35 @@ class LineParam:
|
|||
|
||||
UpdateLines = Callable[[List[np.ndarray]], None]
|
||||
|
||||
# TODO rename to Plotter
|
||||
class Renderer(ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def abstract_classvar(self) -> Any:
|
||||
"""A ClassVar to be overriden by a subclass."""
|
||||
|
||||
|
||||
class BaseRenderer(ABC):
|
||||
"""
|
||||
Renderer backend which takes data and produces images.
|
||||
Does not touch Wave or Channel.
|
||||
"""
|
||||
|
||||
# Class attributes and methods
|
||||
bytes_per_pixel: int = abstract_classvar
|
||||
ffmpeg_pixel_format: str = abstract_classvar
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def color_to_bytes(c: str) -> np.ndarray:
|
||||
"""
|
||||
Returns integer ndarray of length RGB_DEPTH.
|
||||
This must return ndarray (not bytes or list),
|
||||
since the caller performs arithmetic on the return value.
|
||||
|
||||
Only used for tests/test_renderer.py.
|
||||
"""
|
||||
|
||||
# Instance initializer
|
||||
def __init__(
|
||||
self,
|
||||
cfg: RendererConfig,
|
||||
|
@ -210,6 +251,8 @@ class Renderer(ABC):
|
|||
for color in line_colors
|
||||
]
|
||||
|
||||
# Instance functionality
|
||||
|
||||
_update_main_lines: Optional[UpdateLines] = None
|
||||
|
||||
def update_main_lines(self, datas: List[np.ndarray]) -> None:
|
||||
|
@ -247,29 +290,20 @@ def px_from_points(pt: Point) -> Pixel:
|
|||
return pt * PIXELS_PER_PT
|
||||
|
||||
|
||||
class MatplotlibRenderer(Renderer):
|
||||
class AbstractMatplotlibRenderer(BaseRenderer, ABC):
|
||||
"""Matplotlib renderer which can use any backend (agg, mplcairo).
|
||||
To pick a backend, subclass and set canvas_type at the class level.
|
||||
"""
|
||||
Renderer backend which takes data and produces images.
|
||||
Does not touch Wave or Channel.
|
||||
|
||||
If __init__ reads cfg, cfg cannot be hotswapped.
|
||||
_canvas_type: Type["FigureCanvasBase"] = abstract_classvar
|
||||
|
||||
Reasons to hotswap cfg: RendererCfg:
|
||||
- GUI preview size
|
||||
- Changing layout
|
||||
- Changing #smp drawn (samples_visible)
|
||||
(see RendererCfg)
|
||||
|
||||
Original OVGen does not support hotswapping.
|
||||
It disables changing options during rendering.
|
||||
|
||||
Reasons to hotswap trigger algorithms:
|
||||
- changing scan_nsamp (cannot be hotswapped, since correlation buffer is incompatible)
|
||||
So don't.
|
||||
"""
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def _canvas_to_bytes(canvas: "FigureCanvasBase") -> ByteBuffer:
|
||||
pass
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
Renderer.__init__(self, *args, **kwargs)
|
||||
BaseRenderer.__init__(self, *args, **kwargs)
|
||||
|
||||
dict.__setitem__(
|
||||
matplotlib.rcParams, "lines.antialiased", self.cfg.antialiasing
|
||||
|
@ -305,7 +339,7 @@ class MatplotlibRenderer(Renderer):
|
|||
cfg = self.cfg
|
||||
|
||||
self._fig = Figure()
|
||||
FigureCanvasAgg(self._fig)
|
||||
self._canvas_type(self._fig)
|
||||
|
||||
px_inch = PX_INCH / cfg.res_divisor
|
||||
self._fig.set_dpi(px_inch)
|
||||
|
@ -571,20 +605,22 @@ class MatplotlibRenderer(Renderer):
|
|||
|
||||
# Output frames
|
||||
def get_frame(self) -> ByteBuffer:
|
||||
""" Returns ndarray of shape w,h,3. """
|
||||
"""Returns bytes with shape (h, w, self.bytes_per_pixel).
|
||||
The actual return value's shape may be flat.
|
||||
"""
|
||||
self._redraw_over_background()
|
||||
|
||||
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):
|
||||
if not isinstance(canvas, self._canvas_type):
|
||||
raise RuntimeError(
|
||||
f"oh shit, cannot read data from {type(canvas)} != FigureCanvasAgg"
|
||||
f"oh shit, cannot read data from {obj_name(canvas)} != {self._canvas_type.__name__}"
|
||||
)
|
||||
|
||||
buffer_rgb = canvas.tostring_rgb()
|
||||
assert len(buffer_rgb) == self.w * self.h * BYTES_PER_PIXEL
|
||||
buffer_rgb = self._canvas_to_bytes(canvas)
|
||||
assert len(buffer_rgb) == self.w * self.h * self.bytes_per_pixel
|
||||
|
||||
return buffer_rgb
|
||||
|
||||
|
@ -603,6 +639,8 @@ class MatplotlibRenderer(Renderer):
|
|||
def _redraw_over_background(self) -> None:
|
||||
""" Redraw animated elements of the image. """
|
||||
|
||||
# Both FigureCanvasAgg and FigureCanvasCairo, but not FigureCanvasBase,
|
||||
# support restore_region().
|
||||
canvas: FigureCanvasAgg = self._fig.canvas
|
||||
canvas.restore_region(self.bg_cache)
|
||||
|
||||
|
@ -610,3 +648,24 @@ class MatplotlibRenderer(Renderer):
|
|||
artist.axes.draw_artist(artist)
|
||||
|
||||
# canvas.blit(self._fig.bbox) is unnecessary when drawing off-screen.
|
||||
|
||||
|
||||
class MatplotlibAggRenderer(AbstractMatplotlibRenderer):
|
||||
# implements AbstractMatplotlibRenderer
|
||||
_canvas_type = FigureCanvasAgg
|
||||
|
||||
@staticmethod
|
||||
def _canvas_to_bytes(canvas: FigureCanvasAgg) -> ByteBuffer:
|
||||
return canvas.tostring_rgb()
|
||||
|
||||
# implements BaseRenderer
|
||||
bytes_per_pixel = 3
|
||||
ffmpeg_pixel_format = "rgb24"
|
||||
|
||||
@staticmethod
|
||||
def color_to_bytes(c: str) -> np.ndarray:
|
||||
to_rgb = matplotlib.colors.to_rgb
|
||||
return np.array([round(c * 255) for c in to_rgb(c)], dtype=int)
|
||||
|
||||
|
||||
Renderer = MatplotlibAggRenderer
|
||||
|
|
|
@ -14,7 +14,7 @@ from corrscope.layout import (
|
|||
Orientation,
|
||||
StereoOrientation,
|
||||
)
|
||||
from corrscope.renderer import RendererConfig, MatplotlibRenderer
|
||||
from corrscope.renderer import RendererConfig, Renderer
|
||||
from corrscope.util import ceildiv
|
||||
from tests.test_renderer import WIDTH, HEIGHT, RENDER_Y_ZEROS
|
||||
|
||||
|
@ -222,7 +222,7 @@ def test_renderer_layout():
|
|||
nplots = 15
|
||||
|
||||
datas = [RENDER_Y_ZEROS] * nplots
|
||||
r = MatplotlibRenderer(cfg, lcfg, datas, None)
|
||||
r = Renderer(cfg, lcfg, datas, None)
|
||||
r.update_main_lines(datas)
|
||||
layout = r.layout
|
||||
|
||||
|
|
|
@ -15,14 +15,13 @@ import pytest
|
|||
from corrscope.channel import ChannelConfig
|
||||
from corrscope.corrscope import default_config, Config, CorrScope, Arguments
|
||||
from corrscope.outputs import (
|
||||
BYTES_PER_PIXEL,
|
||||
FFmpegOutput,
|
||||
FFmpegOutputConfig,
|
||||
FFplayOutput,
|
||||
FFplayOutputConfig,
|
||||
Stop,
|
||||
)
|
||||
from corrscope.renderer import RendererConfig, MatplotlibRenderer
|
||||
from corrscope.renderer import RendererConfig, Renderer
|
||||
from tests.test_renderer import RENDER_Y_ZEROS, WIDTH, HEIGHT
|
||||
|
||||
|
||||
|
@ -31,6 +30,9 @@ if TYPE_CHECKING:
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
BYTES_PER_PIXEL = Renderer.bytes_per_pixel
|
||||
|
||||
|
||||
# Global setup
|
||||
if not shutil.which("ffmpeg"):
|
||||
pytestmark = pytest.mark.xfail(
|
||||
|
@ -86,7 +88,7 @@ def test_render_output():
|
|||
""" Ensure rendering to output does not raise exceptions. """
|
||||
datas = [RENDER_Y_ZEROS]
|
||||
|
||||
renderer = MatplotlibRenderer(CFG.render, CFG.layout, datas, channel_cfgs=None)
|
||||
renderer = Renderer(CFG.render, CFG.layout, datas, channel_cfgs=None)
|
||||
out: FFmpegOutput = NULL_FFMPEG_OUTPUT(CFG)
|
||||
|
||||
renderer.update_main_lines(datas)
|
||||
|
@ -168,7 +170,7 @@ def test_corr_terminate_ffplay(Popen, mocker: "pytest_mock.MockFixture"):
|
|||
cfg = sine440_config()
|
||||
corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()]))
|
||||
|
||||
update_main_lines = mocker.patch.object(MatplotlibRenderer, "update_main_lines")
|
||||
update_main_lines = mocker.patch.object(Renderer, "update_main_lines")
|
||||
update_main_lines.side_effect = DummyException()
|
||||
with pytest.raises(DummyException):
|
||||
corr.play()
|
||||
|
@ -334,7 +336,7 @@ NO_FFMPEG = [[], [FFplayOutputConfig()]]
|
|||
def test_preview_performance(Popen, mocker: "pytest_mock.MockFixture", outputs):
|
||||
""" Ensure performance optimizations enabled
|
||||
if all outputs are FFplay or others. """
|
||||
get_frame = mocker.spy(MatplotlibRenderer, "get_frame")
|
||||
get_frame = mocker.spy(Renderer, "get_frame")
|
||||
previews, records = previews_records(mocker)
|
||||
|
||||
cfg = cfg_192x108()
|
||||
|
@ -368,7 +370,7 @@ YES_FFMPEG = [l + [FFmpegOutputConfig(None)] for l in NO_FFMPEG]
|
|||
def test_record_performance(Popen, mocker: "pytest_mock.MockFixture", outputs):
|
||||
""" Ensure performance optimizations disabled
|
||||
if any FFmpegOutputConfig is found. """
|
||||
get_frame = mocker.spy(MatplotlibRenderer, "get_frame")
|
||||
get_frame = mocker.spy(Renderer, "get_frame")
|
||||
previews, records = previews_records(mocker)
|
||||
|
||||
cfg = cfg_192x108()
|
||||
|
|
|
@ -2,7 +2,6 @@ from typing import Optional, TYPE_CHECKING, List
|
|||
|
||||
import attr
|
||||
import hypothesis.strategies as hs
|
||||
import matplotlib.colors
|
||||
import numpy as np
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
|
@ -10,8 +9,8 @@ from hypothesis import given
|
|||
from corrscope.channel import ChannelConfig
|
||||
from corrscope.corrscope import CorrScope, default_config, Arguments
|
||||
from corrscope.layout import LayoutConfig
|
||||
from corrscope.outputs import BYTES_PER_PIXEL, FFplayOutputConfig
|
||||
from corrscope.renderer import RendererConfig, MatplotlibRenderer, LabelPosition, Font
|
||||
from corrscope.outputs import FFplayOutputConfig
|
||||
from corrscope.renderer import RendererConfig, Renderer, LabelPosition, Font
|
||||
from corrscope.util import perr
|
||||
from corrscope.wave import Flatten
|
||||
|
||||
|
@ -21,6 +20,9 @@ if TYPE_CHECKING:
|
|||
parametrize = pytest.mark.parametrize
|
||||
|
||||
|
||||
color_to_bytes = Renderer.color_to_bytes
|
||||
BYTES_PER_PIXEL = Renderer.bytes_per_pixel
|
||||
|
||||
WIDTH = 64
|
||||
HEIGHT = 64
|
||||
|
||||
|
@ -97,13 +99,13 @@ def test_default_colors(appear: Appearance, data):
|
|||
lcfg = LayoutConfig(orientation=ORIENTATION)
|
||||
datas = [data] * NPLOTS
|
||||
|
||||
r = MatplotlibRenderer(cfg, lcfg, datas, None)
|
||||
r = Renderer(cfg, lcfg, datas, None)
|
||||
verify(r, appear, datas)
|
||||
|
||||
# Ensure default ChannelConfig(line_color=None) does not override line color
|
||||
chan = ChannelConfig(wav_path="")
|
||||
channels = [chan] * NPLOTS
|
||||
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
||||
r = Renderer(cfg, lcfg, datas, channels)
|
||||
verify(r, appear, datas)
|
||||
|
||||
|
||||
|
@ -120,14 +122,14 @@ def test_line_colors(appear: Appearance, data):
|
|||
cfg.init_line_color = "#888888"
|
||||
chan.line_color = appear.fg_str
|
||||
|
||||
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
||||
r = Renderer(cfg, lcfg, datas, channels)
|
||||
verify(r, appear, datas)
|
||||
|
||||
|
||||
TOLERANCE = 3
|
||||
|
||||
|
||||
def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
|
||||
def verify(r: Renderer, appear: Appearance, datas: List[np.ndarray]):
|
||||
bg_str = appear.bg_str
|
||||
fg_str = appear.fg_str
|
||||
grid_str = appear.grid_str
|
||||
|
@ -138,14 +140,14 @@ def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
|
|||
(-1, BYTES_PER_PIXEL)
|
||||
)
|
||||
|
||||
bg_u8 = to_rgb(bg_str)
|
||||
fg_u8 = to_rgb(fg_str)
|
||||
bg_u8 = color_to_bytes(bg_str)
|
||||
fg_u8 = color_to_bytes(fg_str)
|
||||
all_colors = [bg_u8, fg_u8]
|
||||
|
||||
is_grid = bool(grid_str and grid_line_width >= 1)
|
||||
|
||||
if is_grid:
|
||||
grid_u8 = to_rgb(grid_str)
|
||||
grid_u8 = color_to_bytes(grid_str)
|
||||
all_colors.append(grid_u8)
|
||||
else:
|
||||
grid_u8 = np.array([1000] * BYTES_PER_PIXEL)
|
||||
|
@ -188,11 +190,6 @@ def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
|
|||
assert (np.amin(frame_colors, axis=0) == np.amin(all_colors, axis=0)).all()
|
||||
|
||||
|
||||
def to_rgb(c) -> np.ndarray:
|
||||
to_rgb = matplotlib.colors.to_rgb
|
||||
return np.array([round(c * 255) for c in to_rgb(c)], dtype=int)
|
||||
|
||||
|
||||
# Test label positioning and rendering
|
||||
@parametrize("label_position", LabelPosition.__members__.values())
|
||||
@parametrize("data", [RENDER_Y_ZEROS, RENDER_Y_STEREO])
|
||||
|
@ -204,7 +201,7 @@ def test_label_render(label_position: LabelPosition, data, hide_lines):
|
|||
- even if no lines are drawn at all
|
||||
"""
|
||||
font_str = "#FF00FF"
|
||||
font_u8 = to_rgb(font_str)
|
||||
font_u8 = color_to_bytes(font_str)
|
||||
|
||||
# If hide_lines: set line color to purple, draw text using the line color.
|
||||
# Otherwise: draw lines white, draw text purple,
|
||||
|
@ -228,7 +225,7 @@ def test_label_render(label_position: LabelPosition, data, hide_lines):
|
|||
labels = ["#"] * nplots
|
||||
datas = [data] * nplots
|
||||
|
||||
r = MatplotlibRenderer(cfg, lcfg, datas, None)
|
||||
r = Renderer(cfg, lcfg, datas, None)
|
||||
r.add_labels(labels)
|
||||
if not hide_lines:
|
||||
r.update_main_lines(datas)
|
||||
|
@ -302,13 +299,13 @@ def verify_res_divisor_rounding(
|
|||
cfg.before_preview()
|
||||
|
||||
if speed_hack:
|
||||
mocker.patch.object(MatplotlibRenderer, "_save_background")
|
||||
mocker.patch.object(Renderer, "_save_background")
|
||||
datas = []
|
||||
else:
|
||||
datas = [RENDER_Y_ZEROS]
|
||||
|
||||
try:
|
||||
renderer = MatplotlibRenderer(cfg, LayoutConfig(), datas, channel_cfgs=None)
|
||||
renderer = Renderer(cfg, LayoutConfig(), datas, channel_cfgs=None)
|
||||
if not speed_hack:
|
||||
renderer.update_main_lines(datas)
|
||||
renderer.get_frame()
|
||||
|
|
Ładowanie…
Reference in New Issue