Skip to content

Commit bb49a7e

Browse files
authored
Adds resize helper functions
Also, adds an ability to use run_classifier with debug = True. (debug mode)
1 parent f1d7fa8 commit bb49a7e

File tree

12 files changed

+212
-36
lines changed

12 files changed

+212
-36
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ build
55
*.jpg
66
.DS_Store
77
.venv/
8+
.vscode
9+
*.eim

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ This library lets you run machine learning models and collect sensor data on Lin
2727
```
2828
2929
For the computer vision examples you'll want `opencv-python>=4.5.1.48`
30+
Note on macOS on apple silicon, you will need to use a later version,
31+
4.10.0.84 tested and installs cleanly
3032
3133
## Collecting data
3234

edge_impulse_linux/audio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ def __init__(self, model_path: str):
130130
self.window_size = 0
131131
self.labels = []
132132

133-
def init(self):
134-
model_info = super(AudioImpulseRunner, self).init()
133+
def init(self, debug=False):
134+
model_info = super(AudioImpulseRunner, self).init(debug)
135135
if model_info['model_parameters']['frequency'] == 0:
136136
raise Exception('Model file "' + self._model_path + '" is not suitable for audio recognition')
137137

edge_impulse_linux/image.py

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,20 @@ def __init__(self, model_path: str):
1414
self.dim = (0, 0)
1515
self.videoCapture = cv2.VideoCapture()
1616
self.isGrayscale = False
17+
self.resizeMode = ''
1718

18-
def init(self):
19-
model_info = super(ImageImpulseRunner, self).init()
20-
width = model_info['model_parameters']['image_input_width'];
21-
height = model_info['model_parameters']['image_input_height'];
19+
def init(self, debug=False):
20+
model_info = super(ImageImpulseRunner, self).init(debug)
21+
width = model_info['model_parameters']['image_input_width']
22+
height = model_info['model_parameters']['image_input_height']
2223

2324
if width == 0 or height == 0:
2425
raise Exception('Model file "' + self._model_path + '" is not suitable for image recognition')
2526

2627
self.dim = (width, height)
2728
self.labels = model_info['model_parameters']['labels']
28-
self.isGrayscale = model_info['model_parameters']['image_channel_count'] == 1
29+
self.isGrayscale = model_info['model_parameters']['image_channel_count'] == 1
30+
self.resizeMode = model_info['model_parameters'].get('image_resize_mode', 'not-reported')
2931
return model_info
3032

3133
def __enter__(self):
@@ -69,7 +71,7 @@ def classifier(self, videoDeviceId = 0):
6971
res = self.classify(features)
7072
yield res, cropped
7173

72-
# This expects images in RGB format (not BGR)
74+
# This expects images in RGB format (not BGR), DEPRECATED, use get_features_from_image_auto_studio_setings
7375
def get_features_from_image(self, img, crop_direction_x='center', crop_direction_y='center'):
7476
features = []
7577

@@ -129,3 +131,107 @@ def get_features_from_image(self, img, crop_direction_x='center', crop_direction
129131
features.append((r << 16) + (g << 8) + b)
130132

131133
return features, cropped
134+
135+
def get_features_from_image_auto_studio_setings(self, img):
136+
if self.resizeMode == '':
137+
raise Exception(
138+
'Runner has not initialized, please call init() first')
139+
if self.resizeMode == 'not-reported':
140+
raise Exception(
141+
'Model file "' + self._model_path + '" does not report the image resize mode\n'
142+
'Please update the model file via edge-impulse-linux-runner --download')
143+
return get_features_from_image_with_studio_mode(img, self.resizeMode, self.dim[0], self.dim[1], self.isGrayscale)
144+
145+
146+
def resize_with_letterbox(image, target_width, target_height):
147+
"""Resize an image while maintaining aspect ratio using letterboxing.
148+
149+
Args:
150+
image: The input image as a NumPy array.
151+
target_size: A tuple (width, height) specifying the desired output size.
152+
153+
Returns:
154+
The resized image as a NumPy array and the letterbox dimensions.
155+
"""
156+
157+
height, width = image.shape[:2]
158+
159+
# Calculate scale factors to preserve aspect ratio
160+
scale_x = target_width / width
161+
scale_y = target_height / height
162+
scale = min(scale_x, scale_y)
163+
164+
# Calculate new dimensions and padding
165+
new_width = int(width * scale)
166+
new_height = int(height * scale)
167+
top_pad = (target_height - new_height) // 2
168+
bottom_pad = target_height - new_height - top_pad
169+
left_pad = (target_width - new_width) // 2
170+
right_pad = target_width - new_width - left_pad
171+
172+
# Resize image and add padding
173+
resized_image = cv2.resize(image, (new_width, new_height))
174+
padded_image = cv2.copyMakeBorder(resized_image, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_CONSTANT, value=0)
175+
176+
return padded_image
177+
178+
179+
def get_features_from_image_with_studio_mode(img, mode, output_width, output_height, is_grayscale):
180+
"""
181+
Extract features from an image using different resizing modes suitable for Edge Impulse Studio.
182+
183+
Args:
184+
img (numpy.ndarray): The input image as a NumPy array.
185+
mode (str): The resizing mode to use. Options are 'fit-shortest', 'fit-longest', and 'squash'.
186+
output_width (int): The desired output width of the image.
187+
output_height (int): The desired output height of the image.
188+
is_grayscale (bool): Whether the output image should be converted to grayscale.
189+
190+
Returns:
191+
tuple: A tuple containing:
192+
- features (list): A list of pixel values in the format (R << 16) + (G << 8) + B for color images,
193+
or (P << 16) + (P << 8) + P for grayscale images.
194+
- resized_img (numpy.ndarray): The resized image as a NumPy array.
195+
"""
196+
features = []
197+
198+
in_frame_cols = img.shape[1]
199+
in_frame_rows = img.shape[0]
200+
201+
if mode == 'fit-shortest':
202+
aspect_ratio = output_width / output_height
203+
if in_frame_cols / in_frame_rows > aspect_ratio:
204+
# Image is wider than target aspect ratio
205+
new_width = int(in_frame_rows * aspect_ratio)
206+
offset = (in_frame_cols - new_width) // 2
207+
cropped_img = img[:, offset:offset + new_width]
208+
else:
209+
# Image is taller than target aspect ratio
210+
new_height = int(in_frame_cols / aspect_ratio)
211+
offset = (in_frame_rows - new_height) // 2
212+
cropped_img = img[offset:offset + new_height, :]
213+
214+
resized_img = cv2.resize(cropped_img, (output_width, output_height), interpolation=cv2.INTER_AREA)
215+
elif mode == 'fit-longest':
216+
resized_img = resize_with_letterbox(img, output_width, output_height)
217+
elif mode == 'squash':
218+
resized_img = cv2.resize(img, (output_width, output_height), interpolation=cv2.INTER_AREA)
219+
else:
220+
raise ValueError(f"Unsupported mode: {mode}")
221+
222+
if is_grayscale:
223+
resized_img = cv2.cvtColor(resized_img, cv2.COLOR_BGR2GRAY)
224+
pixels = np.array(resized_img).flatten().tolist()
225+
226+
for p in pixels:
227+
features.append((p << 16) + (p << 8) + p)
228+
else:
229+
pixels = np.array(resized_img).flatten().tolist()
230+
231+
for ix in range(0, len(pixels), 3):
232+
r = pixels[ix + 0]
233+
g = pixels[ix + 1]
234+
b = pixels[ix + 2]
235+
features.append((r << 16) + (g << 8) + b)
236+
237+
return features, resized_img

edge_impulse_linux/runner.py

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,83 @@
77
import socket
88
import json
99

10+
1011
def now():
1112
return round(time.time() * 1000)
1213

14+
1315
class ImpulseRunner:
1416
def __init__(self, model_path: str):
1517
self._model_path = model_path
1618
self._tempdir = None
1719
self._runner = None
1820
self._client = None
1921
self._ix = 0
22+
self._debug = False
2023

21-
def init(self):
22-
if (not os.path.exists(self._model_path)):
23-
raise Exception('Model file does not exist: ' + self._model_path)
24+
def init(self, debug=False):
25+
if not os.path.exists(self._model_path):
26+
raise Exception("Model file does not exist: " + self._model_path)
2427

25-
if (not os.access(self._model_path, os.X_OK)):
28+
if not os.access(self._model_path, os.X_OK):
2629
raise Exception('Model file "' + self._model_path + '" is not executable')
2730

31+
self._debug = debug
2832
self._tempdir = tempfile.mkdtemp()
29-
socket_path = os.path.join(self._tempdir, 'runner.sock')
30-
self._runner = subprocess.Popen([self._model_path, socket_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
31-
32-
while not os.path.exists(socket_path) or not self._runner.poll() is None:
33+
socket_path = os.path.join(self._tempdir, "runner.sock")
34+
cmd = [self._model_path, socket_path]
35+
if debug:
36+
self._runner = subprocess.Popen(cmd)
37+
else:
38+
self._runner = subprocess.Popen(
39+
cmd,
40+
stdout=subprocess.PIPE,
41+
stderr=subprocess.PIPE,
42+
)
43+
44+
while not os.path.exists(socket_path) or self._runner.poll() is not None:
3345
time.sleep(0.1)
3446

35-
if not self._runner.poll() is None:
36-
raise Exception('Failed to start runner (' + str(self._runner.poll()) + ')')
47+
if self._runner.poll() is not None:
48+
raise Exception("Failed to start runner (" + str(self._runner.poll()) + ")")
3749

3850
self._client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
3951
self._client.connect(socket_path)
4052

4153
return self.hello()
4254

4355
def stop(self):
44-
if (self._tempdir):
56+
if self._tempdir:
4557
shutil.rmtree(self._tempdir)
4658

47-
if (self._client):
59+
if self._client:
4860
self._client.close()
4961

50-
if (self._runner):
62+
if self._runner:
5163
os.kill(self._runner.pid, signal.SIGINT)
5264
# todo: in Node we send a SIGHUP after 0.5sec if process has not died, can we do this somehow here too?
5365

5466
def hello(self):
55-
msg = { "hello": 1 }
67+
msg = {"hello": 1}
5668
return self.send_msg(msg)
5769

5870
def classify(self, data):
59-
msg = { "classify": data }
71+
msg = {"classify": data}
72+
if self._debug:
73+
msg["debug"] = True
6074
return self.send_msg(msg)
6175

6276
def send_msg(self, msg):
6377
t_send_msg = now()
6478

6579
if not self._client:
66-
raise Exception('ImpulseRunner is not initialized')
80+
raise Exception("ImpulseRunner is not initialized (call init())")
6781

6882
self._ix = self._ix + 1
6983
ix = self._ix
7084

7185
msg["id"] = ix
72-
self._client.send(json.dumps(msg).encode('utf-8'))
86+
self._client.send(json.dumps(msg).encode("utf-8"))
7387

7488
t_sent_msg = now()
7589

@@ -81,29 +95,29 @@ def send_msg(self, msg):
8195

8296
braces_open = 0
8397
braces_closed = 0
84-
line = ''
98+
line = ""
8599
resp = None
86100

87-
for c in data.decode('utf-8'):
88-
if c == '{':
101+
for c in data.decode("utf-8"):
102+
if c == "{":
89103
line = line + c
90104
braces_open = braces_open + 1
91-
elif c == '}':
105+
elif c == "}":
92106
line = line + c
93107
braces_closed = braces_closed + 1
94-
if (braces_closed == braces_open):
108+
if braces_closed == braces_open:
95109
resp = json.loads(line)
96110
elif braces_open > 0:
97111
line = line + c
98112

99-
if (not resp is None):
113+
if resp is not None:
100114
break
101115

102-
if (resp is None):
103-
raise Exception('No data or corrupted data received')
116+
if resp is None:
117+
raise Exception("No data or corrupted data received")
104118

105-
if (resp["id"] != ix):
106-
raise Exception('Wrong id, expected: ' + str(ix) + ' but got ' + resp["id"])
119+
if resp["id"] != ix:
120+
raise Exception("Wrong id, expected: " + str(ix) + " but got " + resp["id"])
107121

108122
if not resp["success"]:
109123
raise Exception(resp["error"])

examples/audio/classify.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def main(argv):
4141
with AudioImpulseRunner(modelfile) as runner:
4242
try:
4343
model_info = runner.init()
44+
# model_info = runner.init(debug=True) # to get debug print out
4445
labels = model_info['model_parameters']['labels']
4546
print('Loaded runner for "' + model_info['project']['owner'] + ' / ' + model_info['project']['name'] + '"')
4647

examples/custom/classify.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def main(argv):
5454
runner = ImpulseRunner(modelfile)
5555
try:
5656
model_info = runner.init()
57+
# model_info = runner.init(debug=True) # to get debug print out
58+
5759
print('Loaded runner for "' + model_info['project']['owner'] + ' / ' + model_info['project']['name'] + '"')
5860

5961
res = runner.classify(features)

examples/image/classify-full-frame.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def main(argv):
6767
with ImageImpulseRunner(modelfile) as runner:
6868
try:
6969
model_info = runner.init()
70+
# model_info = runner.init(debug=True) # to get debug print out
71+
7072
print('Loaded runner for "' + model_info['project']['owner'] + ' / ' + model_info['project']['name'] + '"')
7173
labels = model_info['model_parameters']['labels']
7274
if len(args)>= 2:

examples/image/classify-image.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def main(argv):
3939
with ImageImpulseRunner(modelfile) as runner:
4040
try:
4141
model_info = runner.init()
42+
# model_info = runner.init(debug=True) # to get debug print out
43+
4244
print('Loaded runner for "' + model_info['project']['owner'] + ' / ' + model_info['project']['name'] + '"')
4345
labels = model_info['model_parameters']['labels']
4446

@@ -51,7 +53,10 @@ def main(argv):
5153
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
5254

5355
# get_features_from_image also takes a crop direction arguments in case you don't have square images
54-
features, cropped = runner.get_features_from_image(img)
56+
# features, cropped = runner.get_features_from_image(img)
57+
58+
# this mode uses the same settings used in studio to crop and resize the input
59+
features, cropped = runner.get_features_from_image_auto_studio_setings(img)
5560

5661
res = runner.classify(features)
5762

examples/image/classify-video.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def main(argv):
4545
with ImageImpulseRunner(modelfile) as runner:
4646
try:
4747
model_info = runner.init()
48+
# model_info = runner.init(debug=True) # to get debug print out
4849
print('Loaded runner for "' + model_info['project']['owner'] + ' / ' + model_info['project']['name'] + '"')
4950
labels = model_info['model_parameters']['labels']
5051

0 commit comments

Comments
 (0)