Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] Live preview speedup #413

Merged
merged 9 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/seedsigner/gui/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ def configure_instance(cls):
renderer.draw = ImageDraw.Draw(renderer.canvas)


def show_image(self, image=None, alpha_overlay=None):
def show_image(self, image=None, alpha_overlay=None, show_direct=False):
if show_direct:
# Use the incoming image as the canvas and immediately render
self.disp.ShowImage(image, 0, 0)
return

if alpha_overlay:
if image == None:
image = self.canvas
Expand Down
118 changes: 82 additions & 36 deletions src/seedsigner/gui/screens/scan_screens.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
import time

from dataclasses import dataclass
from typing import List, Tuple
from PIL import Image, ImageDraw

from seedsigner.gui import renderer
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
from seedsigner.models import DecodeQR, DecodeQRStatus
from seedsigner.models.threads import BaseThread

from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering
from .screen import BaseScreen, ButtonListScreen
from ..components import GUIConstants, Fonts, TextArea




@dataclass
class ScanScreen(BaseScreen):
"""
Live preview has to balance three competing threads:
* Camera capturing frames and making them available to read.
* Decoder analyzing frames for QR codes.
* Live preview display writing frames to the screen.

All of this would ideally be rewritten as in C/C++/Rust with python bindings for
vastly improved performance.

Until then, we have to balance the resources the Pi Zero has to work with. Thus, we
set a modest fps target for the camera: 5fps. At this pace, the decoder and the live
display can more or less keep up with the flow of frames without much wasted effort
in any of the threads.

Note: performance tuning was targeted for the Pi Zero.

The resolution (480x480) has not been tweaked in order to guarantee that our
decoding abilities remain as-is. It's possible that more optimizations could be made
here (e.g. higher res w/no performance impact? Lower res w/same decoding but faster
performance? etc).

Note: This is quite a lot of important tasks for a Screen to be managing; much of
this should probably be refactored into the Controller.
"""
decoder: DecodeQR = None
instructions_text: str = "< back | Scan a QR code"
resolution: Tuple[int,int] = (480, 480)
framerate: int = 12
render_rect: Tuple[int,int,int,int] = None
resolution: tuple[int,int] = (480, 480)
framerate: int = 5 # TODO: alternate optimization for Pi Zero 2W?
render_rect: tuple[int,int,int,int] = None


def __post_init__(self):
Expand All @@ -42,7 +66,7 @@ def __post_init__(self):


class LivePreviewThread(BaseThread):
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: Tuple[int,int,int,int]):
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int]):
self.camera = camera
self.decoder = decoder
self.renderer = renderer
Expand All @@ -53,9 +77,7 @@ def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Rendere
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
self.render_width = self.render_rect[2] - self.render_rect[0]
self.render_height = self.render_rect[3] - self.render_rect[1]

print(f"render_width: {self.render_width}")
print(f"render_height: {self.render_height}")
self.decoder_fps = "0.0"

super().__init__()

Expand All @@ -64,44 +86,63 @@ def run(self):
from timeit import default_timer as timer

instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)

start_time = time.time()
num_frames = 0
show_framerate = False # enable for debugging / testing
while self.keep_running:
start = timer()
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
scan_text = self.instructions_text
num_frames += 1
cur_time = time.time()
cur_fps = num_frames / (cur_time - start_time)
if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
if show_framerate:
scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}"
else:
if show_framerate:
scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}"
else:
scan_text = self.instructions_text

with self.renderer.lock:
if frame.width > self.render_width or frame.height > self.render_height:
frame = frame.resize(
(self.render_width, self.render_height)
(self.render_width, self.render_height),
resample=Image.NEAREST # Use nearest neighbor for max speed
)
self.renderer.canvas.paste(
frame,
(self.render_rect[0], self.render_rect[1])
)

if scan_text:
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=scan_text,
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
draw = ImageDraw.Draw(frame)

self.renderer.show_image()

end = timer()
# print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282
if scan_text:
# Note: shadowed text (adding a 'stroke' outline) can
# significantly slow down the rendering.
# Temp solution: render a slight 1px shadow behind the text
# TODO: Replace the instructions_text with a disappearing
# toast/popup (see: QR Brightness UI)?
draw.text(xy=(
int(self.renderer.canvas_width/2 + 2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2
),
text=scan_text,
fill="black",
font=instructions_font,
anchor="ms")

# Render the onscreen instructions
draw.text(xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=scan_text,
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
anchor="ms")

self.renderer.show_image(frame, show_direct=True)
# print(f" {cur_fps:0.2f} | {self.decoder_fps}")

time.sleep(0.05) # turn this up or down to tune performance while decoding psbt
if self.camera._video_stream is None:
break

Expand All @@ -112,16 +153,21 @@ def _run(self):
Screen. Once interaction starts, the display updates have to be managed in
_run(). The live preview is an extra-complex case.
"""
num_frames = 0
start_time = time.time()
while True:
frame = self.camera.read_video_stream()
if frame is not None:
status = self.decoder.add_image(frame)

num_frames += 1
decoder_fps = f"{num_frames / (time.time() - start_time):0.2f}"
self.threads[0].decoder_fps = decoder_fps

if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
self.camera.stop_video_stream_mode()
break

# TODO: KEY_UP gives control to NavBar; use its back arrow to cancel
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
self.camera.stop_video_stream_mode()
break
Expand Down
1 change: 0 additions & 1 deletion src/seedsigner/hardware/camera.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import io
import numpy

from picamera import PiCamera
from PIL import Image
Expand Down