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
+
2
6
import struct
3
- import displayio
4
7
5
- try :
6
- import numpy as np
7
- except :
8
- import ulab .numpy as np
8
+ import ulab .numpy as np
9
9
10
10
11
11
def _bytes_per_row (source_width : int ) -> int :
12
+ """Internal function to determine bitmap bytes per row"""
12
13
pixel_bytes = 3 * source_width
13
14
padding_bytes = (4 - (pixel_bytes % 4 )) % 4
14
15
return pixel_bytes + padding_bytes
15
16
16
17
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"""
18
20
output_file .write (bytes ("BM" , "ascii" ))
19
21
output_file .write (struct .pack ("<I" , filesize ))
20
22
output_file .write (b"\00 \x00 " )
21
23
output_file .write (b"\00 \x00 " )
22
24
output_file .write (struct .pack ("<I" , 54 ))
23
25
24
26
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"""
26
29
output_file .write (struct .pack ("<I" , 40 ))
27
30
output_file .write (struct .pack ("<I" , width ))
28
31
output_file .write (struct .pack ("<I" , height ))
@@ -32,31 +35,33 @@ def _write_dib_header(output_file: BufferedWriter, width: int, height: int) -> N
32
35
output_file .write (b"\x00 " )
33
36
34
37
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"""
36
40
height , width = r .shape
37
41
pixel_bytes = 3 * width
38
42
padding_bytes = (4 - (pixel_bytes % 4 )) % 4
39
43
filesize = 54 + height * (pixel_bytes + padding_bytes )
40
44
_write_bmp_header (output_file , filesize )
41
45
_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 )
47
52
48
53
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
51
56
52
57
This is not directly supported on ulab, so we have to slice the "full" mode result
53
58
"""
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 ]
60
65
return result
61
66
62
67
@@ -66,188 +71,205 @@ def np_convolve_same(a, v):
66
71
67
72
68
73
def bitmap_as_array (bitmap ):
69
- ### XXX todo: work on blinka
74
+ """Create an array object that accesses the bitmap data"""
70
75
if bitmap .width % 2 :
71
76
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 )
77
84
78
85
79
86
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
82
88
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 )
86
97
return r , g , b
87
98
88
99
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
94
113
95
114
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 )
108
118
109
119
110
120
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 )
121
128
result = np .zeros (3 * len (r ), dtype = np .uint8 )
122
129
result [2 ::3 ] = r
123
130
result [1 ::3 ] = g
124
131
result [0 ::3 ] = b
125
132
return result
126
133
127
134
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.
135
137
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.
137
140
141
+ Normally, scale is sum(coeffs)."""
138
142
# First run the filter across each row
139
- n_rows = result .shape [0 ]
143
+ n_rows = data .shape [0 ]
140
144
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
142
146
143
147
# Run the filter across each column
144
- n_cols = result .shape [1 ]
148
+ n_cols = data .shape [1 ]
145
149
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
147
151
148
- return result
152
+ return data
149
153
150
154
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"""
153
157
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 )
158
162
159
163
160
- def bitmap_channel_filter3 (
164
+ def bitmap_channel_filter3_inplace (
161
165
bitmap , r_func = lambda r , g , b : r , g_func = lambda r , g , b : g , b_func = lambda r , g , b : b
162
166
):
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"""
164
170
r , g , b = bitmap_to_components_rgb565 (bitmap )
165
171
r = r_func (r , g , b )
166
172
g = g_func (r , g , b )
167
173
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 )
169
175
170
176
171
- def bitmap_channel_filter1 (
177
+ def bitmap_channel_filter1_inplace (
172
178
bitmap , r_func = lambda r : r , g_func = lambda g : g , b_func = lambda b : b
173
179
):
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
181
181
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 )
182
188
183
- def solarize_channel (c , threshold = 0.5 ):
189
+
190
+ def solarize_channel (data , threshold = 128 ):
184
191
"""Solarize an image channel.
185
192
186
193
If the channel value is above a threshold, it is inverted. Otherwise, it is unchanged.
187
194
"""
188
- return (- 1 * arr ) * (arr > threshold ) + arr * (arr <= threshold )
195
+ return (255 - data ) * (data > threshold ) + data * (data <= threshold )
189
196
190
197
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 )
199
205
200
206
201
207
def sepia (bitmap ):
202
- """Apply a sepia filter to an image
208
+ """Apply a sepia filter to an image in place
203
209
204
210
based on some coefficients I found on the internet"""
205
- return bitmap_channel_filter3 (
211
+ return bitmap_channel_filter3_inplace (
206
212
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 ) ,
210
216
)
211
217
212
218
213
219
def greyscale (bitmap ):
214
220
"""Convert an image to greyscale"""
215
221
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
218
236
219
237
220
238
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 )
224
241
225
242
226
243
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 )
230
246
231
247
232
248
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 )
236
251
237
252
238
253
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 )
240
256
241
257
242
258
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
+ )
245
263
246
264
247
265
def edgedetect (bitmap ):
266
+ """Run an edge detection routine on a bitmap"""
248
267
coefficients = np .array ([- 1 , 0 , 1 ])
249
268
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