Skip to content

Commit 515a522

Browse files
rjtokenringBeanRepodsammarugastefanotorneo91volt
committed
[SW] App Lab release 0.3 (arduino#34)
* [app_bricks] Add WaveGenerator brick (arduino#15) * add external Speaker instance handling * fix mixer selection in Speaker * improve volume handling in WaveGenerator and add logging * add external Speaker instance handling * fix mixer selection in Speaker * Enable `cloud_llm` brick (arduino#26) * Update Cloud LLM brick docs (arduino#27) * Fix audio clipping and add low-latency WaveGenerator configuration (arduino#30) * app_bricks/audio-classification: use the brick without init a microphone (arduino#24) * Fix bricks name model list (arduino#33) --------- Co-authored-by: Dario Sammaruga <[email protected]> Co-authored-by: Dario Sammaruga <[email protected]> Co-authored-by: Stefano Torneo <[email protected]> Co-authored-by: Stefano Torneo <[email protected]> Co-authored-by: Ernesto Voltaggio <[email protected]>
1 parent 3207275 commit 515a522

File tree

11 files changed

+190
-52
lines changed

11 files changed

+190
-52
lines changed

models/models-list.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,7 @@ models:
11311131
source-model-url: "https://studio.edgeimpulse.com/studio/757509/live"
11321132
private: true
11331133
bricks:
1134-
- arduino:keyword_spotter
1134+
- arduino:keyword_spotting
11351135
- updown-wave-motion-detection:
11361136
runner: brick
11371137
name : "Continuous motion detection"
@@ -1182,4 +1182,4 @@ models:
11821182
source-model-url: "https://studio.edgeimpulse.com/public/749446/live"
11831183
private: true
11841184
bricks:
1185-
- arduino:audio_classifier
1185+
- arduino:audio_classification

src/arduino/app_bricks/audio_classification/__init__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def stop(self):
6666
"""
6767
super().stop()
6868

69-
def classify_from_file(self, audio_path: str, confidence: int = None) -> dict | None:
69+
@staticmethod
70+
def classify_from_file(audio_path: str, confidence: float = 0.8) -> dict | None:
7071
"""Classify audio content from a WAV file.
7172
7273
Supported sample widths:
@@ -77,9 +78,8 @@ def classify_from_file(self, audio_path: str, confidence: int = None) -> dict |
7778
7879
Args:
7980
audio_path (str): Path to the `.wav` audio file to classify.
80-
confidence (int, optional): Confidence threshold (0–1). If None,
81-
the default confidence level specified during initialization
82-
will be applied.
81+
confidence (float, optional): Minimum confidence threshold (0.0–1.0) required
82+
for a detection to be considered valid. Defaults to 0.8 (80%).
8383
8484
Returns:
8585
dict | None: A dictionary with keys:
@@ -121,9 +121,8 @@ def classify_from_file(self, audio_path: str, confidence: int = None) -> dict |
121121
features = list(struct.unpack(fmt, frames))
122122
else:
123123
raise ValueError(f"Unsupported sample width: {samp_width} bytes. Cannot process this WAV file.")
124-
125-
classification = super().infer_from_features(features[: int(self.model_info.input_features_count)])
126-
best_match = super().get_best_match(classification, confidence)
124+
classification = AudioClassification.infer_from_features(features)
125+
best_match = AudioDetector.get_best_match(classification, confidence)
127126
if not best_match:
128127
return None
129128
keyword, confidence = best_match

src/arduino/app_bricks/audio_classification/examples/2_glass_breaking_from_file.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,5 @@
66
# EXAMPLE_REQUIRES = "Requires an audio file with the glass breaking sound."
77
from arduino.app_bricks.audio_classification import AudioClassification
88

9-
classifier = AudioClassification()
10-
11-
classification = classifier.classify_from_file("glass_breaking.wav")
9+
classification = AudioClassification.classify_from_file("glass_breaking.wav")
1210
print("Result:", classification)

src/arduino/app_bricks/motion_detection/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def _detection_loop(self):
133133
return
134134

135135
try:
136-
ret = super().infer_from_features(features[: int(self._model_info.input_features_count)].flatten().tolist())
136+
ret = self.infer_from_features(features.flatten().tolist())
137137
spotted_movement = self._movement_spotted(ret)
138138
if spotted_movement is not None:
139139
keyword, confidence, complete_detection = spotted_movement

src/arduino/app_bricks/vibration_anomaly_detection/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def loop(self):
133133
if features is None or len(features) == 0:
134134
return
135135

136-
ret = super().infer_from_features(features[: int(self._model_info.input_features_count)].flatten().tolist())
136+
ret = self.infer_from_features(features.flatten().tolist())
137137
logger.debug(f"Inference result: {ret}")
138138
spotted_anomaly = self._extract_anomaly_score(ret)
139139
if spotted_anomaly is not None:

src/arduino/app_internal/core/audio.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,24 @@ def stop(self):
8585
self._mic.stop()
8686
self._buffer.flush()
8787

88-
def get_best_match(self, item: dict, confidence: int = None) -> tuple[str, float] | None:
88+
@staticmethod
89+
def get_best_match(item: dict, confidence: float) -> tuple[str, float] | None:
8990
"""Extract the best matched keyword from the classification results.
9091
9192
Args:
9293
item (dict): The classification result from the inference.
93-
confidence (int): The confidence threshold for classification. If None, uses the instance's confidence level.
94+
confidence (float): The confidence threshold for classification.
9495
9596
Returns:
9697
tuple[str, float] | None: The best matched keyword and its confidence, or None if no match is found.
98+
99+
Raises:
100+
ValueError: If confidence level is not provided.
97101
"""
98-
classification = _extract_classification(item, confidence or self.confidence)
102+
if confidence is None:
103+
raise ValueError("Confidence level must be provided.")
104+
105+
classification = _extract_classification(item, confidence)
99106
if not classification:
100107
return None
101108

@@ -141,8 +148,8 @@ def _inference_loop(self):
141148

142149
logger.debug(f"Processing sensor data with {len(features)} features.")
143150
try:
144-
ret = super().infer_from_features(features[: int(self.model_info.input_features_count)].tolist())
145-
spotted_keyword = self.get_best_match(ret)
151+
ret = self.infer_from_features(features.tolist())
152+
spotted_keyword = self.get_best_match(ret, self.confidence)
146153
if spotted_keyword:
147154
keyword, confidence = spotted_keyword
148155
keyword = keyword.lower()

src/arduino/app_internal/core/ei.py

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,13 @@ class EdgeImpulseRunnerFacade:
4242
"""Facade for Edge Impulse Object Detection and Classification."""
4343

4444
def __init__(self):
45-
"""Initialize the EdgeImpulseRunnerFacade with the API path."""
46-
infra = load_brick_compose_file(self.__class__)
47-
for k, v in infra["services"].items():
48-
self.host = k
49-
self.infra = v
50-
break # Only one service is expected
51-
52-
self.host = resolve_address(self.host)
45+
"""Initialize the EdgeImpulseRunnerFacade with the API path.
5346
54-
self.port = 1337 # Default EI HTTP port
55-
self.url = f"http://{self.host}:{self.port}"
56-
logger.warning(f"[{self.__class__.__name__}] Host: {self.host} - Ports: {self.port} - URL: {self.url}")
47+
Raises:
48+
RuntimeError: If the Edge Impulse runner address cannot be resolved.
49+
"""
50+
self.url = self._get_ei_url()
51+
logger.info(f"[{self.__class__.__name__}] URL: {self.url}")
5752

5853
def infer_from_file(self, image_path: str) -> dict | None:
5954
if not image_path or image_path == "":
@@ -124,47 +119,58 @@ def process(self, item):
124119
logger.error(f"[{self.__class__}] Error processing file {item}: {e}")
125120
return None
126121

127-
def infer_from_features(self, features: list) -> dict | None:
128-
"""Infer from features using the Edge Impulse API.
122+
@classmethod
123+
def infer_from_features(cls, features: list) -> dict | None:
124+
"""
125+
Infer from features using the Edge Impulse API.
129126
130127
Args:
128+
cls: The class method caller.
131129
features (list): A list of features to send to the Edge Impulse API.
132130
133131
Returns:
134132
dict | None: The response from the Edge Impulse API as a dictionary, or None if an error occurs.
135133
"""
136134
try:
137-
response = requests.post(f"{self.url}/api/features", json={"features": features})
135+
url = cls._get_ei_url()
136+
model_info = cls.get_model_info(url)
137+
features = features[: int(model_info.input_features_count)]
138+
139+
response = requests.post(f"{url}/api/features", json={"features": features})
138140
if response.status_code == 200:
139141
return response.json()
140142
else:
141-
logger.warning(f"[{self.__class__}] error: {response.status_code}. Message: {response.text}")
143+
logger.warning(f"[{cls.__name__}] error: {response.status_code}. Message: {response.text}")
142144
return None
143145
except Exception as e:
144-
logger.error(f"[{self.__class__.__name__}] Error: {e}")
146+
logger.error(f"[{cls.__name__}] Error: {e}")
145147
return None
146148

147-
def get_model_info(self) -> EdgeImpulseModelInfo | None:
149+
@classmethod
150+
def get_model_info(cls, url: str = None) -> EdgeImpulseModelInfo | None:
148151
"""Get model information from the Edge Impulse API.
149152
153+
Args:
154+
cls: The class method caller.
155+
url (str): The base URL of the Edge Impulse API. If None, it will be determined automatically.
156+
150157
Returns:
151158
model_info (EdgeImpulseModelInfo | None): An instance of EdgeImpulseModelInfo containing model details, None if an error occurs.
152159
"""
153-
if not self.host or not self.port:
154-
logger.error(f"[{self.__class__}] Host or port not set. Cannot fetch model info.")
155-
return None
160+
if not url:
161+
url = cls._get_ei_url()
156162

157163
http_client = HttpClient(total_retries=6) # Initialize the HTTP client with retry logic
158164
try:
159-
response = http_client.request_with_retry(f"{self.url}/api/info")
165+
response = http_client.request_with_retry(f"{url}/api/info")
160166
if response.status_code == 200:
161-
logger.debug(f"[{self.__class__.__name__}] Fetching model info from {self.url}/api/info -> {response.status_code} {response.json}")
167+
logger.debug(f"[{cls.__name__}] Fetching model info from {url}/api/info -> {response.status_code} {response.json}")
162168
return EdgeImpulseModelInfo(response.json())
163169
else:
164-
logger.warning(f"[{self.__class__}] Error fetching model info: {response.status_code}. Message: {response.text}")
170+
logger.warning(f"[{cls}] Error fetching model info: {response.status_code}. Message: {response.text}")
165171
return None
166172
except Exception as e:
167-
logger.error(f"[{self.__class__}] Error fetching model info: {e}")
173+
logger.error(f"[{cls}] Error fetching model info: {e}")
168174
return None
169175
finally:
170176
http_client.close() # Close the HTTP client session
@@ -237,3 +243,19 @@ def _extract_anomaly_score(self, item: dict):
237243
return class_results["anomaly"]
238244

239245
return None
246+
247+
@classmethod
248+
def _get_ei_url(cls):
249+
infra = load_brick_compose_file(cls)
250+
if not infra or "services" not in infra:
251+
raise RuntimeError("Cannot load Brick Compose file to resolve Edge Impulse runner address.")
252+
host = None
253+
for k, v in infra["services"].items():
254+
host = k
255+
break
256+
if not host:
257+
raise RuntimeError("Cannot resolve Edge Impulse runner address from Brick Compose file.")
258+
addr = resolve_address(host)
259+
if not addr:
260+
raise RuntimeError("Host address resolution failed for Edge Impulse runner.")
261+
return f"http://{addr}:1337"

tests/arduino/app_bricks/motion_detection/test_motion_detection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ def app_instance(monkeypatch):
2424
@pytest.fixture(autouse=True)
2525
def mock_dependencies(monkeypatch: pytest.MonkeyPatch):
2626
"""Mock out docker-compose lookups and image helpers."""
27-
fake_compose = {"services": {"models-runner": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-8100}:8100"]}}}
28-
monkeypatch.setattr("arduino.app_internal.core.load_brick_compose_file", lambda cls: fake_compose)
27+
fake_compose = {"services": {"ei-inference": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-1337}:1337"]}}}
28+
monkeypatch.setattr("arduino.app_internal.core.ei.load_brick_compose_file", lambda cls: fake_compose)
2929
monkeypatch.setattr("arduino.app_internal.core.resolve_address", lambda host: "127.0.0.1")
30-
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "8200")])
30+
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "1337")])
3131

3232
class FakeResp:
3333
status_code = 200

tests/arduino/app_bricks/objectdetection/test_objectdetection.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io
88
from PIL import Image
99
from arduino.app_bricks.object_detection import ObjectDetection
10+
from arduino.app_utils import HttpClient
1011

1112

1213
class ModelInfo:
@@ -20,12 +21,65 @@ def mock_dependencies(monkeypatch: pytest.MonkeyPatch):
2021
2122
This is needed to avoid network calls and other side effects.
2223
"""
23-
fake_compose = {"services": {"models-runner": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-8100}:8100"]}}}
24-
monkeypatch.setattr("arduino.app_internal.core.load_brick_compose_file", lambda cls: fake_compose)
24+
fake_compose = {"services": {"ei-inference": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-1337}:1337"]}}}
25+
monkeypatch.setattr("arduino.app_internal.core.ei.load_brick_compose_file", lambda cls: fake_compose)
2526
monkeypatch.setattr("arduino.app_internal.core.resolve_address", lambda host: "127.0.0.1")
26-
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "8100")])
27+
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "1337")])
2728
monkeypatch.setattr("arduino.app_bricks.object_detection.ObjectDetection.get_model_info", lambda self: ModelInfo("object-detection"))
2829

30+
class FakeResp:
31+
status_code = 200
32+
33+
def json(self):
34+
return {
35+
"project": {
36+
"deploy_version": 11,
37+
"id": 774707,
38+
"impulse_id": 1,
39+
"impulse_name": "Time series data, Spectral Analysis, Classification (Keras), Anomaly Detection (K-means)",
40+
"name": "Fan Monitoring - Advanced Anomaly Detection",
41+
"owner": "Arduino",
42+
},
43+
"modelParameters": {
44+
"has_visual_anomaly_detection": False,
45+
"axis_count": 3,
46+
"frequency": 100,
47+
"has_anomaly": 1,
48+
"has_object_tracking": False,
49+
"has_performance_calibration": False,
50+
"image_channel_count": 0,
51+
"image_input_frames": 0,
52+
"image_input_height": 0,
53+
"image_input_width": 0,
54+
"image_resize_mode": "none",
55+
"inferencing_engine": 4,
56+
"input_features_count": 600,
57+
"interval_ms": 10,
58+
"label_count": 2,
59+
"labels": ["nominal", "off"],
60+
"model_type": "classification",
61+
"sensor": 2,
62+
"slice_size": 50,
63+
"thresholds": [],
64+
"use_continuous_mode": False,
65+
"sensorType": "accelerometer",
66+
},
67+
}
68+
69+
def fake_get(
70+
self,
71+
url: str,
72+
method: str = "GET",
73+
data: dict | str = None,
74+
json: dict = None,
75+
headers: dict = None,
76+
timeout: int = 5,
77+
):
78+
return FakeResp()
79+
80+
# Mock the requests.get method to return a fake response
81+
monkeypatch.setattr(HttpClient, "request_with_retry", fake_get)
82+
2983

3084
@pytest.fixture
3185
def detector():

tests/arduino/app_bricks/vibration_anomaly_detection/test_vibration_anomaly_detection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ def app_instance(monkeypatch):
2424
@pytest.fixture(autouse=True)
2525
def mock_dependencies(monkeypatch: pytest.MonkeyPatch):
2626
"""Mock out docker-compose lookups and image helpers."""
27-
fake_compose = {"services": {"models-runner": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-8100}:8100"]}}}
28-
monkeypatch.setattr("arduino.app_internal.core.load_brick_compose_file", lambda cls: fake_compose)
27+
fake_compose = {"services": {"ei-inference": {"ports": ["${BIND_ADDRESS:-127.0.0.1}:${BIND_PORT:-1337}:1337"]}}}
28+
monkeypatch.setattr("arduino.app_internal.core.ei.load_brick_compose_file", lambda cls: fake_compose)
2929
monkeypatch.setattr("arduino.app_internal.core.resolve_address", lambda host: "127.0.0.1")
30-
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "8200")])
30+
monkeypatch.setattr("arduino.app_internal.core.parse_docker_compose_variable", lambda x: [(None, None), (None, "1337")])
3131

3232
class FakeResp:
3333
status_code = 200

0 commit comments

Comments
 (0)