add the loading screen
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -169,4 +169,5 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
frames
|
||||
34
README.md
34
README.md
@@ -18,6 +18,8 @@ stateDiagram-v2
|
||||
[*] --> 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 "Do Recipe<br>(step-by-step)" as DoRecipe
|
||||
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 – Goal Time Entry<br>(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]<br>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]<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
73
export_loading_frames.py
Normal 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()
|
||||
@@ -16,6 +16,7 @@ class DISPLAY_TYPES(Flag):
|
||||
CIRCLE = 2
|
||||
|
||||
class DISPLAY_MODES(Enum):
|
||||
LOADING = 0
|
||||
MAIN = 1
|
||||
SETTINGS = 2
|
||||
RECIPE_SELECTION = 3
|
||||
|
||||
@@ -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
|
||||
98
frontend/views/loading.py
Normal file
98
frontend/views/loading.py
Normal 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
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user