Skip to content

Commit

Permalink
Merge pull request #116 from pozitronik/player_perf_counter
Browse files Browse the repository at this point in the history
Live player fixes and optimizations
  • Loading branch information
pozitronik authored Mar 15, 2024
2 parents e6d1053 + 40da57c commit 0e4d540
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 142 deletions.
16 changes: 8 additions & 8 deletions sinner/gui/GUIForm.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from argparse import Namespace
from threading import Thread
from tkinter import filedialog, LEFT, Button, Frame, BOTH, StringVar, NW, X, Event, TOP, CENTER, Menu, CASCADE, COMMAND, RADIOBUTTON, CHECKBUTTON, SEPARATOR, BooleanVar, RIDGE, BOTTOM, NE
from tkinter import filedialog, LEFT, Button, Frame, BOTH, StringVar, NW, X, Event, TOP, CENTER, Menu, CASCADE, COMMAND, RADIOBUTTON, CHECKBUTTON, SEPARATOR, BooleanVar, RIDGE, BOTTOM, NE
from tkinter.ttk import Spinbox, Label
from typing import List

from customtkinter import CTk
from psutil import WINDOWS

from sinner.gui.controls.FramePlayer.BaseFramePlayer import ROTATE_90_CLOCKWISE, ROTATE_180, ROTATE_90_COUNTERCLOCKWISE
from sinner.models.Event import Event as SinnerEvent
from sinner.Status import Status
from sinner.gui.GUIModel import GUIModel
from sinner.gui.controls.FramePlayer.BaseFramePlayer import RotateMode
from sinner.gui.controls.FramePosition.BaseFramePosition import BaseFramePosition
from sinner.gui.controls.FramePosition.SliderFramePosition import SliderFramePosition
from sinner.gui.controls.ImageList import ImageList
Expand Down Expand Up @@ -224,16 +224,16 @@ def save_current_frame() -> None:
if save_file != '':
self.GUIModel.Player.save_to_file(save_file)

self.RotateModeVar: StringVar = StringVar(value=RotateMode.ROTATE_0.value)
self.RotateModeVar: StringVar = StringVar(value="0°")

self.RotateSubMenu: Menu = Menu(self.MainMenu, tearoff=False)
self.MainMenu.add(CASCADE, menu=self.RotateSubMenu, label='Rotation') # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_0.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_0)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_90.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_90)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_180.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_180)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_270.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_270)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="0°", command=lambda: set_rotate_mode(None)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="90°", command=lambda: set_rotate_mode(ROTATE_90_CLOCKWISE)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="180°", command=lambda: set_rotate_mode(ROTATE_180)) # type: ignore[no-untyped-call] # it is a library method
self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="270°", command=lambda: set_rotate_mode(ROTATE_90_COUNTERCLOCKWISE)) # type: ignore[no-untyped-call] # it is a library method

def set_rotate_mode(mode: RotateMode) -> None:
def set_rotate_mode(mode: int | None) -> None:
self.GUIModel.Player.rotate = mode

self.SoundEnabledVar: BooleanVar = BooleanVar(value=self.GUIModel.enable_sound())
Expand Down
35 changes: 20 additions & 15 deletions sinner/gui/GUIModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ def set_volume(self, volume: int) -> None:
def rewind(self, frame_position: int) -> None:
if self.player_is_started:
self.TimeLine.rewind(frame_position - 1)
self._event_rewind.set()
self._event_rewind.set(tag=frame_position - 1)
else:
self.update_preview()
self.position.set(frame_position)
Expand Down Expand Up @@ -439,10 +439,10 @@ def process_done(future_: Future[float | None]) -> None:
with ThreadPoolExecutor(max_workers=self.execution_threads) as executor: # this adds processing operations into a queue
while start_frame <= end_frame:
if self._event_rewind.is_set():
start_frame = self.TimeLine.get_frame_index()
start_frame = self._event_rewind.tag or 0
self._event_rewind.clear()

if not self.TimeLine.has_frame(start_frame):
if not self.TimeLine.has_index(start_frame):
future: Future[float | None] = executor.submit(self._process_frame, start_frame)
future.add_done_callback(process_done)
futures.append(future)
Expand Down Expand Up @@ -483,26 +483,31 @@ def _process_frame(self, frame_index: int) -> float | None:
return frame_render_time.execution_time

def _show_frames(self) -> None:
last_shown_frame_index: int = -1
if self.Player:
while self._event_playback.is_set():
start_time = time.perf_counter()
try:
n_frame = self.TimeLine.get_frame()
except EOFError:
self.update_status("No more frames in the timeline")
self._event_playback.clear()
break
if n_frame is None:
time.sleep(self.frame_handler.frame_time / 2)
continue
self.Player.show_frame(n_frame.frame)
if self.TimeLine.last_returned_index is None:
self._status("Time position", "There are no ready frames")
else:
if not self._event_rewind.is_set():
self.position.set(self.TimeLine.last_returned_index)
if self.TimeLine.last_returned_index:
self._status("Time position", seconds_to_hmsms(self.TimeLine.last_returned_index * self.frame_handler.frame_time))
self._status("Last shown/rendered frame", f"{self.TimeLine.last_returned_index}/{self.TimeLine.last_added_index}")
if n_frame is not None and n_frame.index != last_shown_frame_index:
self.Player.show_frame(n_frame.frame)
last_shown_frame_index = n_frame.index
if self.TimeLine.last_returned_index is None:
self._status("Time position", "There are no ready frames")
else:
if not self._event_rewind.is_set():
self.position.set(self.TimeLine.last_returned_index)
if self.TimeLine.last_returned_index:
self._status("Time position", seconds_to_hmsms(self.TimeLine.last_returned_index * self.frame_handler.frame_time))
self._status("Last shown/rendered frame", f"{self.TimeLine.last_returned_index}/{self.TimeLine.last_added_index}")
loop_time = time.perf_counter() - start_time # time for the current loop, sec
sleep_time = self.frame_handler.frame_time - loop_time # time to wait for the next loop, sec
if sleep_time > 0:
time.sleep(sleep_time)
self.update_status("_show_frames loop done")

def extract_frames(self) -> bool:
Expand Down
66 changes: 17 additions & 49 deletions sinner/gui/controls/FramePlayer/BaseFramePlayer.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import time
from abc import abstractmethod
from enum import Enum

import numpy

from sinner.helpers import FrameHelper
from sinner.models.PerfCounter import PerfCounter
from sinner.typing import Frame

SWP_NOMOVE = 0x0002
Expand All @@ -16,32 +13,15 @@
HWND_NOTOPMOST = -2
SWP_NOACTIVATE = 0x0010


class RotateMode(Enum):
ROTATE_0 = "0°"
ROTATE_90 = "90°"
ROTATE_180 = "180°"
ROTATE_270 = "270°"

def __str__(self) -> str:
return self.value[1]

def prev(self) -> 'RotateMode':
enum_list = list(RotateMode)
current_index = enum_list.index(self)
previous_index = (current_index - 1) % len(enum_list)
return enum_list[previous_index]

def next(self) -> 'RotateMode':
enum_list = list(RotateMode)
current_index = enum_list.index(self)
next_index = (current_index + 1) % len(enum_list)
return enum_list[next_index]
# those are similar to cv2 constants with same names
ROTATE_90_CLOCKWISE = 0
ROTATE_180 = 1
ROTATE_90_COUNTERCLOCKWISE = 2


class BaseFramePlayer:
_last_frame: Frame | None = None # the last viewed frame
_rotate: RotateMode = RotateMode.ROTATE_0
_rotate: int | None = None

@abstractmethod
def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int] | None = True, rotate: bool = True) -> None:
Expand All @@ -56,19 +36,6 @@ def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int]
"""
pass

def show_frame_wait(self, frame: Frame | None = None, resize: bool | tuple[int, int] | None = True, rotate: bool = True, duration: float = 0) -> float:
"""
Shows a frame for the given duration (awaits after frame being shown). If duration is lesser than the frame show time
function won't wait
:returns await time
"""
with PerfCounter() as timer:
self.show_frame(frame=frame, resize=resize, rotate=rotate)
await_time = duration - timer.execution_time
if await_time > 0:
time.sleep(await_time)
return await_time

@abstractmethod
def adjust_size(self, redraw: bool = True, size: tuple[int, int] | None = None) -> None:
pass
Expand All @@ -82,29 +49,30 @@ def clear(self) -> None:
pass

@property
def rotate(self) -> RotateMode:
def rotate(self) -> int | None:
return self._rotate

@rotate.setter
def rotate(self, value: RotateMode) -> None:
def rotate(self, value: int | None) -> None:
self._rotate = value
self.clear()
if self._last_frame is not None:
self.clear()
_tmp_frame = self._last_frame
self.show_frame(self._rotate_frame(self._last_frame), rotate=False)
self.show_frame(self._last_frame)
self._last_frame = _tmp_frame

def _rotate_frame(self, frame: Frame, rotate_mode: RotateMode | None = None) -> Frame:
def _rotate_frame(self, frame: Frame, rotate_mode: int | None = None) -> Frame:
if rotate_mode is None:
rotate_mode = self._rotate
if rotate_mode is RotateMode.ROTATE_0:
if rotate_mode is None:
return frame
if rotate_mode is RotateMode.ROTATE_90:
if rotate_mode == ROTATE_90_CLOCKWISE:
return numpy.rot90(frame)
if rotate_mode is RotateMode.ROTATE_180:
return numpy.rot90(numpy.rot90(frame))
if rotate_mode is RotateMode.ROTATE_270:
return numpy.rot90(numpy.rot90(numpy.rot90(frame)))
if rotate_mode is ROTATE_180:
return numpy.rot90(frame, k=2)
if rotate_mode == ROTATE_90_COUNTERCLOCKWISE:
return numpy.rot90(frame, k=3)
return frame

@abstractmethod
def set_fullscreen(self, fullscreen: bool = True) -> None:
Expand Down
27 changes: 19 additions & 8 deletions sinner/gui/controls/FramePlayer/PygameFramePlayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
from time import sleep
from typing import Callable

import cv2
import numpy
import pygame
from psutil import WINDOWS
from pygame import Surface

from sinner.gui.controls.FramePlayer.BaseFramePlayer import BaseFramePlayer, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, HWND_TOP, RotateMode, SWP_NOACTIVATE
from sinner.gui.controls.FramePlayer.BaseFramePlayer import BaseFramePlayer, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, HWND_TOP, SWP_NOACTIVATE, ROTATE_90_CLOCKWISE, ROTATE_180, ROTATE_90_COUNTERCLOCKWISE
from sinner.helpers.FrameHelper import resize_proportionally
from sinner.models.Event import Event
from sinner.typing import Frame
Expand Down Expand Up @@ -90,12 +89,10 @@ def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int]
frame = self._last_frame
if frame is not None:
self._last_frame = frame
if rotate:
frame = self._rotate_frame(frame, self.rotate.next())
else:
frame = self._rotate_frame(frame, rotate_mode=RotateMode.ROTATE_90) # need to bring together numpy/pygame coordinates
frame = numpy.flip((cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)), 0)
# note: now the frame has the flipped shape (WIDTH, HEIGHT)
frame = frame[::-1, :, [2, 1, 0]] # swaps colors channels from BGR to RGB, flips the frame to a pygame coordinates

# it's always required rotate frames for pygame to match the X coordinate
frame = self._rotate_frame(frame)

if resize is True: # resize to the current player size
frame = resize_proportionally(frame, (self.screen.get_width(), self.screen.get_height()))
Expand Down Expand Up @@ -141,3 +138,17 @@ def bring_to_front(self) -> None:
user32.SetWindowPos.restype = wintypes.HWND
user32.SetWindowPos.argtypes = [wintypes.HWND, wintypes.HWND, wintypes.INT, wintypes.INT, wintypes.INT, wintypes.INT, wintypes.UINT]
user32.SetWindowPos(pygame.display.get_wm_info()['window'], HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)

# the method is overlapped because pygame uses inverted X coordinate
def _rotate_frame(self, frame: Frame, rotate_mode: int | None = None) -> Frame:
if rotate_mode is None:
rotate_mode = self._rotate
if rotate_mode is None:
return numpy.rot90(frame, k=3)
if rotate_mode == ROTATE_90_CLOCKWISE:
return frame
if rotate_mode is ROTATE_180:
return numpy.rot90(frame)
if rotate_mode == ROTATE_90_COUNTERCLOCKWISE:
return numpy.rot90(frame, k=2)
return frame
6 changes: 3 additions & 3 deletions sinner/helpers/FrameHelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path

import cv2
from numpy import fromfile, uint8, full
from numpy import fromfile, uint8, full, dstack
from psutil import WINDOWS

from sinner.typing import Frame
Expand All @@ -20,9 +20,9 @@ def read_from_image(path: str) -> Frame:
if WINDOWS: # issue #511
image = cv2.imdecode(fromfile(path, dtype=uint8), cv2.IMREAD_UNCHANGED)
if len(image.shape) == 2: # fixes the b/w images issue
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
image = dstack([image] * 3)
if image.shape[2] == 4: # fixes the alpha-channel issue
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
image = image[:, :, :3]
return image
else:
return cv2.imread(path)
Expand Down
12 changes: 10 additions & 2 deletions sinner/models/Event.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,32 @@
class Event(threading.Event):
_on_set_callback: Callable[[], Any] | None = None
_on_clear_callback: Callable[[], Any] | None = None
_tag: int | None = None

def __init__(self, on_set_callback: Callable[[], Any] | None = None, on_clear_callback: Callable[[], Any] | None = None):
def __init__(self, on_set_callback: Callable[[], Any] | None = None, on_clear_callback: Callable[[], Any] | None = None, tag: int | None = None):
super().__init__()
self._on_set_callback = on_set_callback
self._on_clear_callback = on_clear_callback
self._tag = tag

def set_callback(self, callback: Callable[[], Any] | None = None) -> None:
self._on_set_callback = callback

def clear_callback(self, callback: Callable[[], Any] | None = None) -> None:
self._on_clear_callback = callback

def set(self) -> None:
def set(self, tag: int | None = None) -> None:
super().set()
self._tag = tag
if self._on_set_callback:
self._on_set_callback()

def clear(self) -> None:
super().clear()
self._tag = None
if self._on_clear_callback:
self._on_clear_callback()

@property
def tag(self) -> int | None:
return self._tag
Loading

0 comments on commit 0e4d540

Please sign in to comment.