Skip to content

Commit 9b33ada

Browse files
authored
Add Frame and FrameDesigner app_utils classes (#10)
1 parent f9a0915 commit 9b33ada

File tree

3 files changed

+476
-0
lines changed

3 files changed

+476
-0
lines changed

src/arduino/app_utils/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .image import *
1212
from .jsonparser import *
1313
from .logger import *
14+
from .ledmatrix import *
1415
from .slidingwindowbuffer import *
1516
from .userinput import *
1617

@@ -22,6 +23,8 @@
2223
"call",
2324
"provide",
2425
"FolderWatcher",
26+
"Frame",
27+
"FrameDesigner",
2528
"HttpClient",
2629
"draw_bounding_boxes",
2730
"get_image_bytes",

src/arduino/app_utils/ledmatrix.py

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA <http://www.arduino.cc>
2+
#
3+
# SPDX-License-Identifier: MPL-2.0
4+
5+
from __future__ import annotations
6+
import numpy as np
7+
from typing import Any
8+
9+
10+
class Frame:
11+
"""Represents a brightness matrix for the LED matrix.
12+
13+
Internally stores a numpy array of shape (8, 13) with integer
14+
brightness levels in range [0, brightness_levels-1].
15+
"""
16+
17+
def __init__(self, arr: np.ndarray, brightness_levels: int = 256):
18+
"""Create a Frame from a numpy array.
19+
20+
Args:
21+
arr (numpy.ndarray): numpy array of shape (8, 13) with integer values
22+
brightness_levels (int): number of brightness levels (default 255)
23+
"""
24+
self.height = 8
25+
self.width = 13
26+
self.brightness_levels = int(brightness_levels)
27+
self._arr = arr
28+
self._validate()
29+
30+
def __repr__(self):
31+
"""Return the array as representation of the Frame."""
32+
return self.arr.__repr__()
33+
34+
def __setattr__(self, name: str, value: Any) -> None:
35+
"""Intercept setting of certain attributes.
36+
37+
Public `arr` is exposed as a read-only property; to replace the
38+
array please use `set_array(...)` which performs validation and assigns
39+
to private attribute `_arr`.
40+
"""
41+
# allow direct assignment for internal storage
42+
if name == "_arr":
43+
super().__setattr__(name, value)
44+
self._validate_array_input()
45+
self._assert_array_in_range() if getattr(self, "brightness_levels", None) is not None else None
46+
return
47+
if name == "brightness_levels":
48+
super().__setattr__(name, int(value))
49+
self._validate_brightness_levels()
50+
if getattr(self, "_arr", None) is not None:
51+
self._assert_array_in_range()
52+
return
53+
54+
super().__setattr__(name, value)
55+
56+
@property
57+
def shape(self):
58+
"""Return the (height, width) shape of the frame as a tuple of ints."""
59+
return self.arr.shape
60+
61+
@property
62+
def arr(self) -> np.ndarray:
63+
"""Public read-only view of the internal array.
64+
65+
Returns a numpy ndarray view with the writeable flag turned off so
66+
callers cannot mutate the internal storage in-place. Use
67+
`set_array` to replace the whole array.
68+
"""
69+
if getattr(self, "_arr", None) is None:
70+
return None
71+
v = self._arr.view()
72+
try:
73+
v.flags.writeable = False
74+
except Exception:
75+
return self._arr.copy()
76+
return v
77+
78+
# -- factory methods ----------------------------------------------
79+
@classmethod
80+
def from_rows(cls, rows: list[list[int]] | list[str], brightness_levels: int = 256) -> "Frame":
81+
"""Create a Frame from frontend rows.
82+
83+
Args:
84+
rows (list[list[int]] | list[str]): Either a list of 8 lists each with 13 ints, or a list of 8
85+
CSV strings with 13 numeric values each.
86+
brightness_levels (int): Number of discrete brightness levels for the
87+
resulting Frame (2..256).
88+
89+
Returns:
90+
frame: A validated `Frame` instance.
91+
92+
Raises:
93+
ValueError: on malformed rows or out-of-range values.
94+
"""
95+
brightness_levels = int(brightness_levels)
96+
if not (2 <= brightness_levels <= 256):
97+
raise ValueError("brightness_levels must be in 2..256")
98+
99+
if rows is None:
100+
raise ValueError("rows missing")
101+
# Expect exactly 8 rows and 13 columns
102+
if not isinstance(rows, list) or len(rows) != 8:
103+
raise ValueError("rows must be a list of 8 rows")
104+
105+
# Case: comma-separated numeric strings
106+
if isinstance(rows[0], str):
107+
parsed = []
108+
for i, row in enumerate(rows):
109+
if not isinstance(row, str):
110+
raise ValueError(f"row {i} is not a string")
111+
parts = [p.strip() for p in row.split(",")]
112+
if len(parts) != 13:
113+
raise ValueError(f"row {i} must contain 13 comma-separated values")
114+
try:
115+
nums = [int(p) for p in parts]
116+
except Exception as e:
117+
raise ValueError(f"row {i} contains non-integer value: {e}")
118+
parsed.append(nums)
119+
np_arr = np.asarray(parsed, dtype=int)
120+
121+
# Case: list of lists
122+
elif isinstance(rows[0], list):
123+
# ensure every row is a list of length 13
124+
for i, row in enumerate(rows):
125+
if not isinstance(row, list) or len(row) != 13:
126+
raise ValueError(f"row {i} must be a list of 13 values")
127+
np_arr = np.asarray(rows, dtype=int)
128+
# Validate values are within declared brightness range
129+
if np.any(np_arr < 0) or np.any(np_arr >= brightness_levels):
130+
raise ValueError(f"row values must be in 0..{brightness_levels - 1}")
131+
else:
132+
raise ValueError("unsupported rows format")
133+
134+
return Frame(arr=np_arr, brightness_levels=brightness_levels)
135+
136+
def set_value(self, row: int, col: int, value: int) -> None:
137+
"""Set a specific value in the frame array.
138+
139+
Args:
140+
row (int): Row index (0-(height-1)).
141+
col (int): Column index (0-(width-1)).
142+
value (int): Brightness value to set (0 to brightness_levels-1).
143+
"""
144+
if not (0 <= row < self.height):
145+
raise ValueError(f"row index out of range (0-{self.height - 1})")
146+
if not (0 <= col < self.width):
147+
raise ValueError(f"column index out of range (0-{self.width - 1})")
148+
if not (0 <= value < self.brightness_levels):
149+
raise ValueError(f"value out of range (0-{self.brightness_levels - 1})")
150+
self._arr[row, col] = value
151+
152+
def get_value(self, row: int, col: int) -> int:
153+
"""Get a specific value from the frame array.
154+
155+
Args:
156+
row (int): Row index (0-(height-1)).
157+
col (int): Column index (0-(width-1)).
158+
Returns:
159+
int: The brightness value at the specified position.
160+
"""
161+
if not (0 <= row < self.height):
162+
raise ValueError(f"row index out of range (0-{self.height - 1})")
163+
if not (0 <= col < self.width):
164+
raise ValueError(f"column index out of range (0-{self.width - 1})")
165+
return int(self._arr[row, col])
166+
167+
def set_array(self, arr: np.ndarray) -> Frame:
168+
"""Set the internal array to a new numpy array in-place.
169+
Args:
170+
arr (numpy.ndarray): numpy array of shape (height, width) with integer values
171+
Returns:
172+
Frame: the same Frame instance after modification.
173+
"""
174+
prev = self._arr
175+
try:
176+
np_arr = np.asarray(arr)
177+
self._arr = np_arr.copy()
178+
self._validate()
179+
except Exception:
180+
# rollback
181+
self._arr = prev
182+
raise
183+
return self
184+
185+
# -- export methods -------------------------------------------------
186+
def to_board_bytes(self) -> bytes:
187+
"""Return the byte buffer (row-major) representing this frame.
188+
189+
Values are scaled to 0..255 for board consumption.
190+
191+
Returns:
192+
Raw bytes (length height*width) suitable for the firmware.
193+
"""
194+
scaled = self.rescale_quantized_frame(scale_max=255)
195+
flat = [int(x) for x in scaled.flatten().tolist()]
196+
return bytes(flat)
197+
198+
# -- validation helpers ----------------------------------------------
199+
def _validate(self) -> None:
200+
"""Validate the current Frame instance in-place (internal)."""
201+
self._validate_brightness_levels()
202+
self._validate_array_input()
203+
self._assert_array_in_range()
204+
205+
def _validate_brightness_levels(self) -> None:
206+
"""Ensure :attr:`brightness_levels` is an int in 2..256.
207+
208+
Raises:
209+
ValueError: if the attribute is not a valid integer in range.
210+
"""
211+
if not (isinstance(self.brightness_levels, int) and 2 <= self.brightness_levels <= 256):
212+
raise ValueError("brightness_levels must be an integer in 2..256")
213+
214+
def _validate_array_input(self) -> None:
215+
"""Validate an input array-like of shape (2-D) and integer dtype.
216+
217+
This method performs validation in-place and does not return the
218+
provided array. It raises on invalid input.
219+
220+
Raises:
221+
TypeError, ValueError on invalid input.
222+
"""
223+
if getattr(self, "_arr", None) is None:
224+
raise TypeError("array is not set")
225+
if not isinstance(self._arr, np.ndarray):
226+
raise TypeError("array must be a numpy.ndarray")
227+
if self._arr.ndim != 2:
228+
raise ValueError("array must be 2-dimensional")
229+
if self._arr.shape != (self.height, self.width):
230+
raise ValueError(f"array must have shape ({self.height}, {self.width})")
231+
if not np.issubdtype(self._arr.dtype, np.integer):
232+
raise TypeError("array must have integer dtype")
233+
234+
def _assert_array_in_range(self) -> None:
235+
"""Assert that array values are within 0..brightness_levels-1.
236+
237+
Raises:
238+
ValueError: if any value is out of the allowed range.
239+
"""
240+
if getattr(self, "_arr", None) is None:
241+
raise TypeError("array is not set")
242+
maxv = int(self.brightness_levels) - 1
243+
if np.any(self._arr < 0) or np.any(self._arr > maxv):
244+
a_min = int(np.min(self._arr))
245+
a_max = int(np.max(self._arr))
246+
raise ValueError(f"array values out of range 0..{maxv} (found min={a_min}, max={a_max})")
247+
248+
# -- utility methods -------------------------------------------------
249+
def rescale_quantized_frame(self, scale_max: int = 255) -> np.ndarray:
250+
"""Return a scaled numpy array with values mapped from [0, brightness_levels-1] -> [0, scale_max].
251+
252+
This does not mutate self.arr; it returns a new numpy array of dtype
253+
uint8 suitable for sending to the board or for further formatting.
254+
"""
255+
# If no scaling requested, return integer copy
256+
if scale_max is None:
257+
return self.arr
258+
259+
# Enforce board max: scale_max cannot exceed 255 (also min 1)
260+
if scale_max < 1 or scale_max > 255:
261+
raise ValueError("scale_max cannot be greater than 255 (board max) or less than 1")
262+
263+
# Use brightness_levels to determine the input maximum value.
264+
# brightness_levels is the number of discrete levels (e.g. 256 -> 0..255)
265+
src_max = max(1, int(self.brightness_levels) - 1)
266+
267+
# Fast path: if input already uses the target range, just cast
268+
if src_max == scale_max:
269+
out = self.arr
270+
return out.astype(np.uint8)
271+
272+
# Compute scaling factor from [0..src_max] -> [0..scale_max]
273+
scale = float(scale_max) / float(src_max) if src_max > 0 else 0.0
274+
out = (self.arr.astype(float) * scale).round().astype(np.int32)
275+
return out.astype(np.uint8)
276+
277+
278+
class FrameDesigner:
279+
"""Utilities to create LED matrix frames for the target board.
280+
281+
FrameDesigner centralizes the LED matrix target specification and
282+
provides helpers to make transformations of a `Frame` instance.
283+
"""
284+
285+
def __init__(self):
286+
"""Initialize the FrameDesigner instance with board defaults.
287+
288+
These attributes define brightness levels used by application helpers.
289+
"""
290+
self.width = 13 # led matrix width
291+
self.height = 8 # led matrix height
292+
293+
# -- transformations (in-place) ------------------------------------
294+
def invert(self, frame: "Frame") -> "Frame":
295+
"""Invert brightness values in-place on a Frame.
296+
Args:
297+
frame (Frame): Frame instance to mutate.
298+
Returns:
299+
Frame: the same Frame instance after modification.
300+
"""
301+
maxv = int(frame.brightness_levels) - 1
302+
new_arr = (maxv - frame.arr).astype(int)
303+
frame.set_array(new_arr)
304+
return frame
305+
306+
def invert_not_null(self, frame: "Frame") -> "Frame":
307+
"""Invert non-zero brightness values in-place on a Frame.
308+
Args:
309+
frame (Frame): Frame instance to mutate.
310+
Returns:
311+
Frame: the same Frame instance after modification.
312+
"""
313+
maxv = int(frame.brightness_levels) - 1
314+
arr = frame.arr.copy()
315+
mask = arr > 0
316+
arr[mask] = (maxv - arr[mask]).astype(int)
317+
frame.set_array(arr)
318+
return frame
319+
320+
def rotate180(self, frame: "Frame") -> "Frame":
321+
"""Rotate a Frame by 180 degrees in-place.
322+
Args:
323+
frame (Frame): Frame instance to mutate.
324+
Returns:
325+
Frame: the same Frame instance after modification.
326+
"""
327+
new_arr = np.rot90(frame.arr, k=2)
328+
frame.set_array(new_arr)
329+
return frame
330+
331+
def flip_horizontally(self, frame: "Frame") -> "Frame":
332+
"""Flip a Frame horizontally in-place.
333+
Args:
334+
frame (Frame): Frame instance to mutate.
335+
Returns:
336+
Frame: the same Frame instance after modification.
337+
"""
338+
new_arr = np.fliplr(frame.arr)
339+
frame.set_array(new_arr)
340+
return frame
341+
342+
def flip_vertically(self, frame: "Frame") -> "Frame":
343+
"""Flip a Frame vertically in-place.
344+
Args:
345+
frame (Frame): Frame instance to mutate.
346+
Returns:
347+
Frame: the same Frame instance after modification.
348+
"""
349+
new_arr = np.flipud(frame.arr)
350+
frame.set_array(new_arr)
351+
return frame

0 commit comments

Comments
 (0)