add the loading screen

This commit is contained in:
2026-03-12 23:52:07 +01:00
parent e80cea7bbf
commit bec76d6796
7 changed files with 218 additions and 5 deletions

1
.gitignore vendored
View File

@@ -170,3 +170,4 @@ cython_debug/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
frames

View File

@@ -18,6 +18,8 @@ stateDiagram-v2
[*] --> Main [*] --> Main
state "Main View<br>(timer + weight)" as Main state "Main View<br>(timer + weight)" as Main
state "Menu<br>(carousel)" as Menu
state "Bluetooth Pairing<br>(waiting for connection)" as BTPairing
state "Recipe Selection<br>(carousel)" as RecipeSelection state "Recipe Selection<br>(carousel)" as RecipeSelection
state "Do Recipe<br>(step-by-step)" as DoRecipe state "Do Recipe<br>(step-by-step)" as DoRecipe
state "Edit Recipe<br>(step list)" as EditRecipe state "Edit Recipe<br>(step list)" as EditRecipe
@@ -26,8 +28,14 @@ stateDiagram-v2
state "Edit Step Value Entry<br>(morse input)" as ValueEntry state "Edit Step Value Entry<br>(morse input)" as ValueEntry
state "Edit Step Goal Time Entry<br>(morse input)" as GoalTime state "Edit Step Goal Time Entry<br>(morse input)" as GoalTime
Main --> Menu : both long press
Main --> RecipeSelection : right long press Main --> RecipeSelection : right long press
Menu --> Main : left long press [back]
Menu --> BTPairing : right long press [BT Pair]
BTPairing --> Menu : left long press [cancel]
RecipeSelection --> Main : left long press RecipeSelection --> Main : left long press
RecipeSelection --> DoRecipe : right long press [on recipe → run] RecipeSelection --> DoRecipe : right long press [on recipe → run]
RecipeSelection --> EditRecipe : both long press [on recipe → edit]<br>or right long press [on + → add] RecipeSelection --> EditRecipe : both long press [on recipe → edit]<br>or right long press [on + → add]
@@ -49,3 +57,27 @@ stateDiagram-v2
GoalTime --> EditRecipe : left long press [save]<br>or left press [cancel] GoalTime --> EditRecipe : left long press [save]<br>or left press [cancel]
``` ```
## Tools
### `export_loading_frames.py`
Renders the loading screen animation and exports every unique frame as both PNG files and a NumPy array.
**Usage**
```bash
uv run export_loading_frames.py [--out-dir <directory>]
```
| Argument | Default | Description |
|---|---|---|
| `--out-dir` | `frames/` | Directory to write output files into |
**Outputs**
| File | Description |
|---|---|
| `frames/frame_XXXX.png` | One PNG per unique frame, zero-padded index |
| `frames/frames.npy` | NumPy array of shape `(N, H, W)`, dtype `bool` |
Consecutive duplicate frames are collapsed so only visually distinct frames are exported. The script prints the total number of frames rendered and the number of unique frames kept.

73
export_loading_frames.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Export loading screen animation as a list of unique, ordered frames.
Outputs:
- frames/frame_XXXX.png — one PNG per unique frame
- frames/frames.npy — numpy array of shape (n_unique, H, W), dtype bool
Run with:
uv run export_loading_frames.py [--out-dir frames]
"""
import argparse
import os
import sys
import numpy as np
from frontend.views.loading import LoadingView
def render_all_frames(view: LoadingView) -> list:
"""Render every animation frame and return as list of PIL Images."""
frames = []
while True:
im, done = view.get_frame()
frames.append(im)
if done:
break
return frames
def deduplicate(frames: list) -> list:
"""Keep only frames that differ from the previous one."""
unique = [frames[0]]
prev = frames[0].tobytes()
for im in frames[1:]:
b = im.tobytes()
if b != prev:
unique.append(im)
prev = b
return unique
def main():
parser = argparse.ArgumentParser(description="Export loading screen frames.")
parser.add_argument("--out-dir", default="frames", help="Output directory")
args = parser.parse_args()
size = (168, 144)
center = (168 // 2, 144 // 2)
view = LoadingView(size, center)
all_frames = render_all_frames(view)
unique_frames = deduplicate(all_frames)
print(f"Total frames rendered : {len(all_frames)}")
print(f"Unique frames exported: {len(unique_frames)}")
os.makedirs(args.out_dir, exist_ok=True)
# Save individual PNGs
for i, im in enumerate(unique_frames):
im.save(os.path.join(args.out_dir, f"frame_{i:04d}.png"))
# Save as numpy array (bool, shape: n_frames x H x W)
arr = np.stack([np.array(im) for im in unique_frames]) # shape (N, H, W)
npy_path = os.path.join(args.out_dir, "frames.npy")
np.save(npy_path, arr)
print(f"Saved PNGs and {npy_path} ({arr.shape}, {arr.dtype})")
if __name__ == "__main__":
main()

View File

@@ -16,6 +16,7 @@ class DISPLAY_TYPES(Flag):
CIRCLE = 2 CIRCLE = 2
class DISPLAY_MODES(Enum): class DISPLAY_MODES(Enum):
LOADING = 0
MAIN = 1 MAIN = 1
SETTINGS = 2 SETTINGS = 2
RECIPE_SELECTION = 3 RECIPE_SELECTION = 3

View File

@@ -2,4 +2,5 @@ from .number import NumberView
from .circle import CircleView from .circle import CircleView
from .timer import TimerView from .timer import TimerView
from .text import TextView from .text import TextView
from .loading import LoadingView
from .main_view import MainView from .main_view import MainView

98
frontend/views/loading.py Normal file
View File

@@ -0,0 +1,98 @@
from PIL import Image, ImageDraw
class LoadingView:
"""Loading screen with animated JP logo. Circle matches CircleView."""
SETUP_FRAMES = 50
ANIMATION_FRAMES = 100 # ~1s at 50fps
HOLD_FRAMES = 100 # hold completed logo ~9s (total ~10s)
# SVG cubic bezier segments (absolute coordinates in 500x500 viewport)
# P path: starts at center, goes up and curves around the bump
_P_SEGMENTS = [
((248.462, 250.406), (248.125, 213.074), (246.529, 178.548), (246.529, 178.548)),
((246.529, 178.548), (250.153, 120.959), (302.781, 109.091), (316.648, 109.906)),
((316.648, 109.906), (384.748, 113.909), (403.0, 185.637), (372.292, 222.732)),
]
# J path: starts at bottom-left hook, ends at center
_J_SEGMENTS = [
((113.29, 351.138), (138.503, 404.984), (237.45, 410.427), (246.53, 320.295)),
((246.53, 320.295), (248.338, 302.355), (248.69, 275.888), (248.466, 249.666)),
]
def __init__(self, size, center, radius_offset=11):
self.size = size
self.center = center
self.target_radius = min(center) - radius_offset
self.frame = 0
self.total_frames = self.SETUP_FRAMES + self.ANIMATION_FRAMES + self.HOLD_FRAMES
# Transform SVG coords → display coords
scale = self.target_radius / 240.0
svg_cx, svg_cy = 252.565, 249.335
dx = self.center[0] - svg_cx * scale
dy = self.center[1] - svg_cy * scale
self.p_points = self._sample_path(self._P_SEGMENTS, scale, dx, dy)
self.j_points = self._sample_path(self._J_SEGMENTS, scale, dx, dy)
@staticmethod
def _sample_path(segments, scale, dx, dy, samples_per_segment=30):
points = []
for p0, p1, p2, p3 in segments:
for i in range(samples_per_segment + 1):
if i == 0 and points:
continue
t = i / samples_per_segment
u = 1 - t
x = u**3*p0[0] + 3*u**2*t*p1[0] + 3*u*t**2*p2[0] + t**3*p3[0]
y = u**3*p0[1] + 3*u**2*t*p1[1] + 3*u*t**2*p2[1] + t**3*p3[1]
points.append((x * scale + dx, y * scale + dy))
return points
@staticmethod
def _ease_in_out(t):
if t < 0.5:
return 2 * t * t
return 1 - (-2 * t + 2) ** 2 / 2
def get_frame(self):
"""Return (PIL Image, is_done) advancing the animation by one frame."""
if self.frame >= self.total_frames:
return self._render(1.0), True
if self.frame >= self.SETUP_FRAMES + self.ANIMATION_FRAMES:
progress = 1.0
elif self.frame < self.SETUP_FRAMES:
progress = 0.0
else:
progress = self._ease_in_out((self.frame - self.SETUP_FRAMES) / self.ANIMATION_FRAMES)
im = self._render(progress)
self.frame += 1
return im, False
def _render(self, progress):
im = Image.new('1', self.size, 'white')
draw = ImageDraw.Draw(im)
# Circle outline — same as CircleView
draw.circle(self.center, self.target_radius, outline='black')
# P path: reveal from start forward (center appears first, extends toward bump)
p_count = int(progress * len(self.p_points))
if p_count > 1:
pts = self.p_points[:p_count]
for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill='black', width=2)
# J path: reveal from end backward (center appears first, extends toward hook)
j_count = int(progress * len(self.j_points))
if j_count > 1:
start = len(self.j_points) - j_count
pts = self.j_points[start:]
for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill='black', width=2)
return im

View File

@@ -6,7 +6,7 @@ from PIL import ImageChops
from ..config import DISPLAY_TYPES, DISPLAY_MODES from ..config import DISPLAY_TYPES, DISPLAY_MODES
from .draw_utils import draw_clock, draw_bluetooth_icon from .draw_utils import draw_clock, draw_bluetooth_icon
from . import NumberView, CircleView, TimerView, TextView from . import NumberView, CircleView, TimerView, TextView, LoadingView
from .button_interface import ButtonInterface from .button_interface import ButtonInterface
from .buttons_manager import ButtonsManager from .buttons_manager import ButtonsManager
@@ -24,7 +24,7 @@ class MainView(tk.Frame, ButtonInterface):
bt_stop_pairing_command=None, bt_stop_pairing_command=None,
**kwargs): **kwargs):
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self.curr_mode = DISPLAY_MODES.MAIN self.curr_mode = DISPLAY_MODES.LOADING
self.views = [] self.views = []
self.timer_view = None # Timer view is always active self.timer_view = None # Timer view is always active
self.tare_command = tare_command self.tare_command = tare_command
@@ -51,6 +51,7 @@ class MainView(tk.Frame, ButtonInterface):
self.text_view = TextView(self.actions, self.im_size, self.center) self.text_view = TextView(self.actions, self.im_size, self.center)
self.buttons = ButtonsManager(self, self.im_size, self.center, self.buttons = ButtonsManager(self, self.im_size, self.center,
curr_view=self) curr_view=self)
self.loading_view = LoadingView(self.im_size, self.center)
self.recipes_manager = RecipeManager() self.recipes_manager = RecipeManager()
self.current_steps = [None] * 3 self.current_steps = [None] * 3
self.tare_buffer = [] self.tare_buffer = []
@@ -280,7 +281,13 @@ class MainView(tk.Frame, ButtonInterface):
def refresh(self, weight: float): def refresh(self, weight: float):
ims = [] ims = []
if self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE: if self.curr_mode == DISPLAY_MODES.LOADING:
frame, done = self.loading_view.get_frame()
ims.append(frame)
if done:
self.curr_mode = DISPLAY_MODES.MAIN
elif self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE:
# Always include timer and button view # Always include timer and button view
if self.curr_mode == DISPLAY_MODES.MAIN or \ if self.curr_mode == DISPLAY_MODES.MAIN or \
(self.current_steps[1] is not None and \ (self.current_steps[1] is not None and \