From bec76d67962bd3e55e87e730f5a7ebeed5c5b149 Mon Sep 17 00:00:00 2001 From: Jannes Date: Thu, 12 Mar 2026 23:52:07 +0100 Subject: [PATCH] add the loading screen --- .gitignore | 3 +- README.md | 34 ++++++++++++- export_loading_frames.py | 73 +++++++++++++++++++++++++++ frontend/config.py | 1 + frontend/views/__init__.py | 1 + frontend/views/loading.py | 98 +++++++++++++++++++++++++++++++++++++ frontend/views/main_view.py | 13 +++-- 7 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 export_loading_frames.py create mode 100644 frontend/views/loading.py diff --git a/.gitignore b/.gitignore index 88e2958..9181fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,5 @@ cython_debug/ #.idea/ .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +frames \ No newline at end of file diff --git a/README.md b/README.md index 4e2765a..3129be9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ stateDiagram-v2 [*] --> Main state "Main View
(timer + weight)" as Main + state "Menu
(carousel)" as Menu + state "Bluetooth Pairing
(waiting for connection)" as BTPairing state "Recipe Selection
(carousel)" as RecipeSelection state "Do Recipe
(step-by-step)" as DoRecipe state "Edit Recipe
(step list)" as EditRecipe @@ -26,8 +28,14 @@ stateDiagram-v2 state "Edit Step – Value Entry
(morse input)" as ValueEntry state "Edit Step – Goal Time Entry
(morse input)" as GoalTime + Main --> Menu : both 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 --> DoRecipe : right long press [on recipe → run] RecipeSelection --> EditRecipe : both long press [on recipe → edit]
or right long press [on + → add] @@ -48,4 +56,28 @@ stateDiagram-v2 ValueEntry --> GoalTime : left long press [WEIGH_WITH_TIMER] GoalTime --> EditRecipe : left long press [save]
or left press [cancel] -``` \ No newline at end of file +``` + +## 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 ] +``` + +| 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. diff --git a/export_loading_frames.py b/export_loading_frames.py new file mode 100644 index 0000000..1e78c67 --- /dev/null +++ b/export_loading_frames.py @@ -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() diff --git a/frontend/config.py b/frontend/config.py index f51fbba..d075ce3 100644 --- a/frontend/config.py +++ b/frontend/config.py @@ -16,6 +16,7 @@ class DISPLAY_TYPES(Flag): CIRCLE = 2 class DISPLAY_MODES(Enum): + LOADING = 0 MAIN = 1 SETTINGS = 2 RECIPE_SELECTION = 3 diff --git a/frontend/views/__init__.py b/frontend/views/__init__.py index 166c028..1b8e645 100644 --- a/frontend/views/__init__.py +++ b/frontend/views/__init__.py @@ -2,4 +2,5 @@ from .number import NumberView from .circle import CircleView from .timer import TimerView from .text import TextView +from .loading import LoadingView from .main_view import MainView \ No newline at end of file diff --git a/frontend/views/loading.py b/frontend/views/loading.py new file mode 100644 index 0000000..ef2311c --- /dev/null +++ b/frontend/views/loading.py @@ -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 diff --git a/frontend/views/main_view.py b/frontend/views/main_view.py index 6e62aaf..52e3b05 100644 --- a/frontend/views/main_view.py +++ b/frontend/views/main_view.py @@ -6,7 +6,7 @@ from PIL import ImageChops from ..config import DISPLAY_TYPES, DISPLAY_MODES 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 .buttons_manager import ButtonsManager @@ -24,7 +24,7 @@ class MainView(tk.Frame, ButtonInterface): bt_stop_pairing_command=None, **kwargs): super().__init__(parent, **kwargs) - self.curr_mode = DISPLAY_MODES.MAIN + self.curr_mode = DISPLAY_MODES.LOADING self.views = [] self.timer_view = None # Timer view is always active 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.buttons = ButtonsManager(self, self.im_size, self.center, curr_view=self) + self.loading_view = LoadingView(self.im_size, self.center) self.recipes_manager = RecipeManager() self.current_steps = [None] * 3 self.tare_buffer = [] @@ -280,7 +281,13 @@ class MainView(tk.Frame, ButtonInterface): def refresh(self, weight: float): 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 if self.curr_mode == DISPLAY_MODES.MAIN or \ (self.current_steps[1] is not None and \