Skip to content

Commit

Permalink
Merge pull request #541 from kdmukai/improved_animated_qr_display
Browse files Browse the repository at this point in the history
[Enhancement] Improved animated QR scanning percentage display
  • Loading branch information
newtonick authored May 1, 2024
2 parents adbf5bc + bb565c8 commit 62807fd
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 41 deletions.
9 changes: 9 additions & 0 deletions src/seedsigner/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,15 @@ def start_screensaver(self):
# Start the screensaver, but it will block until it can acquire the Renderer.lock.
self.screensaver.start()
print("Controller: Screensaver started")


def reset_screensaver_timeout(self):
"""
Reset the screensaver's timeout starting point to right now (i.e. make it think
that zero time has elapsed since the last user interaction).
"""
from seedsigner.hardware.buttons import HardwareButtons
HardwareButtons.get_instance().update_last_input_time()


def activate_toast(self, toast_manager_thread: BaseToastOverlayManagerThread):
Expand Down
2 changes: 2 additions & 0 deletions src/seedsigner/gui/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class GUIConstants:
BITCOIN_ORANGE = "#FF9416"
TESTNET_COLOR = "#00F100"
REGTEST_COLOR = "#00CAF1"
GREEN_INDICATOR_COLOR = "#00FF00"
INACTIVE_COLOR = "#414141"

ICON_FONT_NAME__FONT_AWESOME = "Font_Awesome_6_Free-Solid-900"
ICON_FONT_NAME__SEEDSIGNER = "seedsigner-icons"
Expand Down
136 changes: 122 additions & 14 deletions src/seedsigner/gui/screens/scan_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
from seedsigner.models.threads import BaseThread
from seedsigner.models.threads import BaseThread, ThreadsafeCounter

from .screen import BaseScreen, ButtonListScreen
from ..components import GUIConstants, Fonts, TextArea
from .screen import BaseScreen
from ..components import GUIConstants, Fonts, SeedSignerIconConstants



Expand Down Expand Up @@ -47,6 +47,9 @@ class ScanScreen(BaseScreen):
framerate: int = 6 # TODO: alternate optimization for Pi Zero 2W?
render_rect: tuple[int,int,int,int] = None

FRAME__ADDED_PART = 1
FRAME__REPEATED_PART = 2
FRAME__MISS = 3

def __post_init__(self):
from seedsigner.hardware.camera import Camera
Expand All @@ -58,17 +61,22 @@ def __post_init__(self):
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")

self.frames_decode_status = ThreadsafeCounter()
self.frames_decoded_counter = ThreadsafeCounter()

self.threads.append(ScanScreen.LivePreviewThread(
camera=self.camera,
decoder=self.decoder,
renderer=self.renderer,
instructions_text=self.instructions_text,
render_rect=self.render_rect,
frame_decode_status=self.frames_decode_status,
frames_decoded_counter=self.frames_decoded_counter,
))


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], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter):
self.camera = camera
self.decoder = decoder
self.renderer = renderer
Expand All @@ -77,6 +85,9 @@ def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Rendere
self.render_rect = render_rect
else:
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
self.frame_decode_status = frame_decode_status
self.frames_decoded_counter = frames_decoded_counter
self.last_frame_decoded_count = self.frames_decoded_counter.cur_count
self.render_width = self.render_rect[2] - self.render_rect[0]
self.render_height = self.render_rect[3] - self.render_rect[1]
self.decoder_fps = "0.0"
Expand All @@ -85,44 +96,52 @@ def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Rendere


def run(self):
from timeit import default_timer as timer

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

# pre-calculate how big the animated QR percent display can be
left, _, right, _ = instructions_font.getbbox("100%")
progress_text_width = right - left

start_time = time.time()
num_frames = 0
debug = False
show_framerate = False # enable for debugging / testing
while self.keep_running:
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
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:

scan_text = None
progress_percentage = self.decoder.get_percent_complete()
if progress_percentage == 0:
# We've just started scanning, no results yet
if show_framerate:
scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}"
else:
scan_text = self.instructions_text

elif debug:
# Special debugging output for animated QRs
scan_text = f"{self.decoder.get_percent_complete()}% | {self.decoder.get_percent_complete(weight_mixed_frames=True)}% (new)"
if show_framerate:
scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}"

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),
resample=Image.NEAREST # Use nearest neighbor for max speed
)

draw = ImageDraw.Draw(frame)

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 = ImageDraw.Draw(frame)
draw.text(xy=(
int(self.renderer.canvas_width/2 + 2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2
Expand All @@ -142,8 +161,82 @@ def run(self):
font=instructions_font,
anchor="ms")

else:
# Render the progress bar
rectangle = Image.new('RGBA', (self.renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, GUIConstants.BUTTON_HEIGHT), (0, 0, 0, 0))
draw = ImageDraw.Draw(rectangle)

# Start with a background rounded rectangle, same dims as the buttons
overlay_color = (0, 0, 0, 191) # opacity ranges from 0-255
draw.rounded_rectangle(
(
(0, 0),
(rectangle.width, rectangle.height)
),
fill=overlay_color,
radius=8,
outline=overlay_color,
width=2,
)

progress_bar_thickness = 4
progress_bar_width = rectangle.width - 2*GUIConstants.EDGE_PADDING - progress_text_width - int(GUIConstants.EDGE_PADDING/2)
progress_bar_xy = (
(GUIConstants.EDGE_PADDING, int((rectangle.height - progress_bar_thickness) / 2)),
(GUIConstants.EDGE_PADDING + progress_bar_width, int(rectangle.height + progress_bar_thickness) / 2)
)
draw.rounded_rectangle(
progress_bar_xy,
fill=GUIConstants.INACTIVE_COLOR,
radius=8
)

progress_percentage = self.decoder.get_percent_complete(weight_mixed_frames=True)
draw.rounded_rectangle(
(
progress_bar_xy[0],
(GUIConstants.EDGE_PADDING + int(progress_percentage * progress_bar_width / 100.0), progress_bar_xy[1][1])
),
fill=GUIConstants.GREEN_INDICATOR_COLOR,
radius=8
)


draw.text(
xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)),
text=f"{progress_percentage}%",
# text=f"100%",
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
anchor="rm", # right-justified, middle
)

frame.paste(rectangle, (GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - rectangle.height), rectangle)

# Render the dot to indicate successful QR frame read
indicator_size = 10
self.last_frame_decoded_count = self.frames_decoded_counter.cur_count
status_color_map = {
ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR,
ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR,
ScanScreen.FRAME__MISS: None,
}
status_color = status_color_map.get(self.frame_decode_status.cur_count)
if status_color:
# Good! Most recent frame successfully decoded.
# Draw the onscreen indicator dot
draw = ImageDraw.Draw(frame)
draw.ellipse(
(
(self.renderer.canvas_width - GUIConstants.EDGE_PADDING - indicator_size, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING - indicator_size),
(self.renderer.canvas_width - GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING)
),
fill=status_color,
outline="black",
width=1,
)

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

if self.camera._video_stream is None:
break
Expand All @@ -169,6 +262,21 @@ def _run(self):
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
self.camera.stop_video_stream_mode()
break

self.frames_decoded_counter.increment()
# Notify the live preview thread how our most recent decode went
if status == DecodeQRStatus.FALSE:
# Did not find anything to decode in the current frame
self.frames_decode_status.set_value(self.FRAME__MISS)

else:
if status == DecodeQRStatus.PART_COMPLETE:
# We received a valid frame that added new data
self.frames_decode_status.set_value(self.FRAME__ADDED_PART)

elif status == DecodeQRStatus.PART_EXISTING:
# We received a valid frame, but we've already seen in
self.frames_decode_status.set_value(self.FRAME__REPEATED_PART)

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()
Expand Down
2 changes: 1 addition & 1 deletion src/seedsigner/hardware/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def read_video_stream(self, as_image=False):
return frame
else:
if frame is not None:
return Image.fromarray(frame.astype('uint8'), 'RGB').rotate(90 + self._camera_rotation)
return Image.fromarray(frame.astype('uint8'), 'RGB').convert('RGBA').rotate(90 + self._camera_rotation)
return None


Expand Down
Loading

0 comments on commit 62807fd

Please sign in to comment.