Skip to content

Commit e93b3d8

Browse files
committed
add image processing
This is memory efficient enough to operate on 240x208 pixel images. (An earlier iteration which used float32s was not) Typically an algorithm takes 1 to 4 seconds to run on an image. Channel operations such as solarize are faster, while convolution operations like sharpen are slower. A range of algorithms are provided and there are building blocks to create others.
1 parent 336fd04 commit e93b3d8

File tree

2 files changed

+317
-123
lines changed

2 files changed

+317
-123
lines changed

adafruit_pycamera/imageprocessing.py

Lines changed: 145 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1-
import sys
1+
# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""Routines for performing image manipulation"""
5+
26
import struct
3-
import displayio
47

5-
try:
6-
import numpy as np
7-
except:
8-
import ulab.numpy as np
8+
import ulab.numpy as np
99

1010

1111
def _bytes_per_row(source_width: int) -> int:
12+
"""Internal function to determine bitmap bytes per row"""
1213
pixel_bytes = 3 * source_width
1314
padding_bytes = (4 - (pixel_bytes % 4)) % 4
1415
return pixel_bytes + padding_bytes
1516

1617

17-
def _write_bmp_header(output_file: BufferedWriter, filesize: int) -> None:
18+
def _write_bmp_header(output_file, filesize):
19+
"""Internal function to write bitmap header"""
1820
output_file.write(bytes("BM", "ascii"))
1921
output_file.write(struct.pack("<I", filesize))
2022
output_file.write(b"\00\x00")
2123
output_file.write(b"\00\x00")
2224
output_file.write(struct.pack("<I", 54))
2325

2426

25-
def _write_dib_header(output_file: BufferedWriter, width: int, height: int) -> None:
27+
def _write_dib_header(output_file, width: int, height: int) -> None:
28+
"""Internal function to write bitmap "dib" header"""
2629
output_file.write(struct.pack("<I", 40))
2730
output_file.write(struct.pack("<I", width))
2831
output_file.write(struct.pack("<I", height))
@@ -32,31 +35,33 @@ def _write_dib_header(output_file: BufferedWriter, width: int, height: int) -> N
3235
output_file.write(b"\x00")
3336

3437

35-
def components_to_file_rgb565(output_file, r, g, b):
38+
def components_to_bitmap(output_file, r, g, b):
39+
"""Write image components to an uncompressed 24-bit .bmp format file"""
3640
height, width = r.shape
3741
pixel_bytes = 3 * width
3842
padding_bytes = (4 - (pixel_bytes % 4)) % 4
3943
filesize = 54 + height * (pixel_bytes + padding_bytes)
4044
_write_bmp_header(output_file, filesize)
4145
_write_dib_header(output_file, width, height)
42-
p = b"\0" * padding_bytes
43-
m = memoryview(buffer_from_components_rgb888(r, g, b))
44-
for i in range(0, len(m), pixel_bytes)[::-1]:
45-
output_file.write(m[i : i + pixel_bytes])
46-
output_file.write(p)
46+
pad = b"\0" * padding_bytes
47+
view = memoryview(buffer_from_components_rgb888(r, g, b))
48+
# Write out image data in reverse order with padding between rows
49+
for i in range(0, len(view), pixel_bytes)[::-1]:
50+
output_file.write(view[i : i + pixel_bytes])
51+
output_file.write(pad)
4752

4853

49-
def np_convolve_same(a, v):
50-
"""Perform the np.convolve(mode=same) operation
54+
def _np_convolve_same(arr, coeffs):
55+
"""Internal function to perform the np.convolve(arr, coeffs, mode="same") operation
5156
5257
This is not directly supported on ulab, so we have to slice the "full" mode result
5358
"""
54-
if len(a) < len(v):
55-
a, v = v, a
56-
tmp = np.convolve(a, v)
57-
n = len(a)
58-
c = (len(v) - 1) // 2
59-
result = tmp[c : c + n]
59+
if len(arr) < len(coeffs):
60+
arr, coeffs = coeffs, arr
61+
tmp = np.convolve(arr, coeffs)
62+
n = len(arr)
63+
offset = (len(coeffs) - 1) // 2
64+
result = tmp[offset : offset + n]
6065
return result
6166

6267

@@ -66,188 +71,205 @@ def np_convolve_same(a, v):
6671

6772

6873
def bitmap_as_array(bitmap):
69-
### XXX todo: work on blinka
74+
"""Create an array object that accesses the bitmap data"""
7075
if bitmap.width % 2:
7176
raise ValueError("Can only work on even-width bitmaps")
72-
return (
73-
np.frombuffer(bitmap, dtype=np.uint16)
74-
.reshape((bitmap.height, bitmap.width))
75-
.byteswap()
76-
)
77+
return np.frombuffer(bitmap, dtype=np.uint16).reshape((bitmap.height, bitmap.width))
78+
79+
80+
def array_cast(arr, dtype):
81+
"""Cast an array to a given type and shape. The new type must match the original
82+
type's size in bytes."""
83+
return np.frombuffer(arr, dtype=dtype).reshape(arr.shape)
7784

7885

7986
def bitmap_to_components_rgb565(bitmap):
80-
"""Convert a RGB65_BYTESWAPPED image to float32 components in the [0,1] inclusive range"""
81-
arr = bitmap_as_array(bitmap)
87+
"""Convert a RGB65_BYTESWAPPED image to int16 components in the [0,255] inclusive range
8288
83-
r = np.right_shift(arr, 11) * (1.0 / FIVE_BITS)
84-
g = (np.right_shift(arr, 5) & SIX_BITS) * (1.0 / SIX_BITS)
85-
b = (arr & FIVE_BITS) * (1.0 / FIVE_BITS)
89+
This requires higher memory than uint8, but allows more arithmetic on pixel values;
90+
converting back to bitmap clamps values to the appropriate range."""
91+
arr = bitmap_as_array(bitmap)
92+
arr.byteswap(inplace=True)
93+
r = array_cast(np.right_shift(arr, 8) & 0xF8, np.int16)
94+
g = array_cast(np.right_shift(arr, 3) & 0xFC, np.int16)
95+
b = array_cast(np.left_shift(arr, 3) & 0xF8, np.int16)
96+
arr.byteswap(inplace=True)
8697
return r, g, b
8798

8899

89-
def bitmap_from_components_rgb565(r, g, b):
90-
"""Convert the float32 components to a bitmap"""
91-
h, w = r.shape
92-
result = displayio.Bitmap(w, h, 65535)
93-
return bitmap_from_components_inplace_rgb565(result, r, g, b)
100+
def bitmap_from_components_inplace_rgb565(
101+
bitmap, r, g, b
102+
): # pylint: disable=invalid-name
103+
"""Update a bitmap in-place with new RGB values"""
104+
dest = bitmap_as_array(bitmap)
105+
r = array_cast(np.maximum(np.minimum(r, 255), 0), np.uint16)
106+
g = array_cast(np.maximum(np.minimum(g, 255), 0), np.uint16)
107+
b = array_cast(np.maximum(np.minimum(b, 255), 0), np.uint16)
108+
dest[:] = np.left_shift(r & 0xF8, 8)
109+
dest[:] |= np.left_shift(g & 0xFC, 3)
110+
dest[:] |= np.right_shift(b, 3)
111+
dest.byteswap(inplace=True)
112+
return bitmap
94113

95114

96-
def bitmap_from_components_inplace_rgb565(bitmap, r, g, b):
97-
arr = bitmap_as_array(bitmap)
98-
r = np.array(np.maximum(np.minimum(r, 1.0), 0.0) * FIVE_BITS, dtype=np.uint16)
99-
g = np.array(np.maximum(np.minimum(g, 1.0), 0.0) * SIX_BITS, dtype=np.uint16)
100-
b = np.array(np.maximum(np.minimum(b, 1.0), 0.0) * FIVE_BITS, dtype=np.uint16)
101-
arr = np.left_shift(r, 11)
102-
arr[:] |= np.left_shift(g, 5)
103-
arr[:] |= b
104-
arr = arr.byteswap().flatten()
105-
dest = np.frombuffer(bitmap, dtype=np.uint16)
106-
dest[:] = arr
107-
return bitmap
115+
def as_flat(arr):
116+
"""Flatten an array, ensuring no copy is made"""
117+
return np.frombuffer(arr, arr.dtype)
108118

109119

110120
def buffer_from_components_rgb888(r, g, b):
111-
"""Convert the float32 components to a RGB888 buffer in memory"""
112-
r = np.array(
113-
np.maximum(np.minimum(r, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8
114-
).flatten()
115-
g = np.array(
116-
np.maximum(np.minimum(g, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8
117-
).flatten()
118-
b = np.array(
119-
np.maximum(np.minimum(b, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8
120-
).flatten()
121+
"""Convert the individual color components to a single RGB888 buffer in memory"""
122+
r = as_flat(r)
123+
g = as_flat(g)
124+
b = as_flat(b)
125+
r = np.maximum(np.minimum(r, 0x3F), 0)
126+
g = np.maximum(np.minimum(g, 0x3F), 0)
127+
b = np.maximum(np.minimum(b, 0x3F), 0)
121128
result = np.zeros(3 * len(r), dtype=np.uint8)
122129
result[2::3] = r
123130
result[1::3] = g
124131
result[0::3] = b
125132
return result
126133

127134

128-
def separable_filter(data, vh, vv=None):
129-
"""Apply a separable filter to a 2d array.
130-
131-
If the vertical coefficients ``vv`` are none, the ``vh`` components are
132-
used for vertical too."""
133-
if vv is None:
134-
vv = vh
135+
def symmetric_filter_inplace(data, coeffs, scale):
136+
"""Apply a symmetric separable filter to a 2d array, changing it in place.
135137
136-
result = data[:]
138+
The same filter is applied to image rows and image columns. This is appropriate for
139+
many common kinds of image filters such as blur, sharpen, and edge detect.
137140
141+
Normally, scale is sum(coeffs)."""
138142
# First run the filter across each row
139-
n_rows = result.shape[0]
143+
n_rows = data.shape[0]
140144
for i in range(n_rows):
141-
result[i, :] = np_convolve_same(result[i, :], vh)
145+
data[i, :] = _np_convolve_same(data[i, :], coeffs) // scale
142146

143147
# Run the filter across each column
144-
n_cols = result.shape[1]
148+
n_cols = data.shape[1]
145149
for i in range(n_cols):
146-
result[:, i] = np_convolve_same(result[:, i], vv)
150+
data[:, i] = _np_convolve_same(data[:, i], coeffs) // scale
147151

148-
return result
152+
return data
149153

150154

151-
def bitmap_separable_filter(bitmap, vh, vv=None):
152-
"""Apply a separable filter to an image, returning a new image"""
155+
def bitmap_symmetric_filter_inplace(bitmap, coeffs, scale):
156+
"""Apply a symmetric filter to an image, updating the original image"""
153157
r, g, b = bitmap_to_components_rgb565(bitmap)
154-
r = separable_filter(r, vh, vv)
155-
g = separable_filter(g, vh, vv)
156-
b = separable_filter(b, vh, vv)
157-
return bitmap_from_components_rgb565(r, g, b)
158+
symmetric_filter_inplace(r, coeffs, scale)
159+
symmetric_filter_inplace(g, coeffs, scale)
160+
symmetric_filter_inplace(b, coeffs, scale)
161+
return bitmap_from_components_inplace_rgb565(bitmap, r, g, b)
158162

159163

160-
def bitmap_channel_filter3(
164+
def bitmap_channel_filter3_inplace(
161165
bitmap, r_func=lambda r, g, b: r, g_func=lambda r, g, b: g, b_func=lambda r, g, b: b
162166
):
163-
"""Perform channel filtering where each function recieves all 3 channels"""
167+
"""Perform channel filtering in place, updating the original image
168+
169+
Each callback function recieves all 3 channels"""
164170
r, g, b = bitmap_to_components_rgb565(bitmap)
165171
r = r_func(r, g, b)
166172
g = g_func(r, g, b)
167173
b = b_func(r, g, b)
168-
return bitmap_from_components_rgb565(r, g, b)
174+
return bitmap_from_components_inplace_rgb565(bitmap, r, g, b)
169175

170176

171-
def bitmap_channel_filter1(
177+
def bitmap_channel_filter1_inplace(
172178
bitmap, r_func=lambda r: r, g_func=lambda g: g, b_func=lambda b: b
173179
):
174-
"""Perform channel filtering where each function recieves just one channel"""
175-
return bitmap_channel_filter3(
176-
bitmap,
177-
lambda r, g, b: r_func(r),
178-
lambda r, g, b: g_func(g),
179-
lambda r, g, b: b_func(b),
180-
)
180+
"""Perform channel filtering in place, updating the original image
181181
182+
Each callback function recieves just its own channel data."""
183+
r, g, b = bitmap_to_components_rgb565(bitmap)
184+
r[:] = r_func(r)
185+
g[:] = g_func(g)
186+
b[:] = b_func(b)
187+
return bitmap_from_components_inplace_rgb565(bitmap, r, g, b)
182188

183-
def solarize_channel(c, threshold=0.5):
189+
190+
def solarize_channel(data, threshold=128):
184191
"""Solarize an image channel.
185192
186193
If the channel value is above a threshold, it is inverted. Otherwise, it is unchanged.
187194
"""
188-
return (-1 * arr) * (arr > threshold) + arr * (arr <= threshold)
195+
return (255 - data) * (data > threshold) + data * (data <= threshold)
189196

190197

191-
def solarize(bitmap, threshold=0.5):
192-
"""Apply a solarize filter to an image"""
193-
return bitmap_channel_filter1(
194-
bitmap,
195-
lambda r: solarize_channel(r, threshold),
196-
lambda g: solarize_channel(r, threshold),
197-
lambda b: solarize_channel(b, threshold),
198-
)
198+
def solarize(bitmap, threshold=128):
199+
"""Apply a per-channel solarize filter to an image in place"""
200+
201+
def do_solarize(channel):
202+
return solarize_channel(channel, threshold)
203+
204+
return bitmap_channel_filter1_inplace(bitmap, do_solarize, do_solarize, do_solarize)
199205

200206

201207
def sepia(bitmap):
202-
"""Apply a sepia filter to an image
208+
"""Apply a sepia filter to an image in place
203209
204210
based on some coefficients I found on the internet"""
205-
return bitmap_channel_filter3(
211+
return bitmap_channel_filter3_inplace(
206212
bitmap,
207-
lambda r, g, b: 0.393 * r + 0.769 * g + 0.189 * b,
208-
lambda r, g, b: 0.349 * r + 0.686 * g + 0.168 * b,
209-
lambda r, g, b: 0.272 * r + 0.534 * g + 0.131 * b,
213+
lambda r, g, b: np.right_shift(50 * r + 98 * g + 24 * b, 7),
214+
lambda r, g, b: np.right_shift(44 * r + 88 * g + 42 * b, 7),
215+
lambda r, g, b: np.right_shift(35 * r + 69 * g + 17 * b, 7),
210216
)
211217

212218

213219
def greyscale(bitmap):
214220
"""Convert an image to greyscale"""
215221
r, g, b = bitmap_to_components_rgb565(bitmap)
216-
l = 0.2989 * r + 0.5870 * g + 0.1140 * b
217-
return bitmap_from_components_rgb565(l, l, l)
222+
luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7)
223+
return bitmap_from_components_inplace_rgb565(
224+
bitmap, luminance, luminance, luminance
225+
)
226+
227+
228+
def _identity(channel):
229+
"""An internal function to return a channel unchanged"""
230+
return channel
231+
232+
233+
def _half(channel):
234+
"""An internal function to divide channel values by two"""
235+
return channel // 2
218236

219237

220238
def red_cast(bitmap):
221-
return bitmap_channel_filter1(
222-
bitmap, lambda r: r, lambda g: g * 0.5, lambda b: b * 0.5
223-
)
239+
"""Give an image a red cast by dividing G and B channels in half"""
240+
return bitmap_channel_filter1_inplace(bitmap, _identity, _half, _half)
224241

225242

226243
def green_cast(bitmap):
227-
return bitmap_channel_filter1(
228-
bitmap, lambda r: r * 0.5, lambda g: g, lambda b: b * 0.5
229-
)
244+
"""Give an image a green cast by dividing R and B channels in half"""
245+
return bitmap_channel_filter1_inplace(bitmap, _half, _identity, _half)
230246

231247

232248
def blue_cast(bitmap):
233-
return bitmap_channel_filter1(
234-
bitmap, lambda r: r * 0.5, lambda g: g * 0.5, lambda b: b
235-
)
249+
"""Give an image a blue cast by dividing R and G channels in half"""
250+
return bitmap_channel_filter1_inplace(bitmap, _half, _half, _identity)
236251

237252

238253
def blur(bitmap):
239-
return bitmap_separable_filter(bitmap, np.array([0.25, 0.5, 0.25]))
254+
"""Blur a bitmap"""
255+
return bitmap_symmetric_filter_inplace(bitmap, np.array([1, 2, 1]), scale=4)
240256

241257

242258
def sharpen(bitmap):
243-
y = 1 / 5
244-
return bitmap_separable_filter(bitmap, np.array([-y, -y, 2 - y, -y, -y]))
259+
"""Sharpen a bitmap"""
260+
return bitmap_symmetric_filter_inplace(
261+
bitmap, np.array([-1, -1, 9, -1, -1]), scale=5
262+
)
245263

246264

247265
def edgedetect(bitmap):
266+
"""Run an edge detection routine on a bitmap"""
248267
coefficients = np.array([-1, 0, 1])
249268
r, g, b = bitmap_to_components_rgb565(bitmap)
250-
r = separable_filter(r, coefficients, coefficients) + 0.5
251-
g = separable_filter(g, coefficients, coefficients) + 0.5
252-
b = separable_filter(b, coefficients, coefficients) + 0.5
253-
return bitmap_from_components_rgb565(r, g, b)
269+
symmetric_filter_inplace(r, coefficients, scale=1)
270+
r += 128
271+
symmetric_filter_inplace(g, coefficients, scale=1)
272+
g += 128
273+
symmetric_filter_inplace(b, coefficients, scale=1)
274+
b += 128
275+
return bitmap_from_components_inplace_rgb565(bitmap, r, g, b)

0 commit comments

Comments
 (0)