From 336fd0487521de074aed6d3cd2bcb9eed52787d9 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 16:01:56 -0600 Subject: [PATCH 1/8] add image processing, operates in float space which uses too much memory! --- adafruit_pycamera/imageprocessing.py | 253 +++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 adafruit_pycamera/imageprocessing.py diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py new file mode 100644 index 0000000..c8d5fc2 --- /dev/null +++ b/adafruit_pycamera/imageprocessing.py @@ -0,0 +1,253 @@ +import sys +import struct +import displayio + +try: + import numpy as np +except: + import ulab.numpy as np + + +def _bytes_per_row(source_width: int) -> int: + pixel_bytes = 3 * source_width + padding_bytes = (4 - (pixel_bytes % 4)) % 4 + return pixel_bytes + padding_bytes + + +def _write_bmp_header(output_file: BufferedWriter, filesize: int) -> None: + output_file.write(bytes("BM", "ascii")) + output_file.write(struct.pack(" None: + output_file.write(struct.pack(" threshold) + arr * (arr <= threshold) + + +def solarize(bitmap, threshold=0.5): + """Apply a solarize filter to an image""" + return bitmap_channel_filter1( + bitmap, + lambda r: solarize_channel(r, threshold), + lambda g: solarize_channel(r, threshold), + lambda b: solarize_channel(b, threshold), + ) + + +def sepia(bitmap): + """Apply a sepia filter to an image + + based on some coefficients I found on the internet""" + return bitmap_channel_filter3( + bitmap, + lambda r, g, b: 0.393 * r + 0.769 * g + 0.189 * b, + lambda r, g, b: 0.349 * r + 0.686 * g + 0.168 * b, + lambda r, g, b: 0.272 * r + 0.534 * g + 0.131 * b, + ) + + +def greyscale(bitmap): + """Convert an image to greyscale""" + r, g, b = bitmap_to_components_rgb565(bitmap) + l = 0.2989 * r + 0.5870 * g + 0.1140 * b + return bitmap_from_components_rgb565(l, l, l) + + +def red_cast(bitmap): + return bitmap_channel_filter1( + bitmap, lambda r: r, lambda g: g * 0.5, lambda b: b * 0.5 + ) + + +def green_cast(bitmap): + return bitmap_channel_filter1( + bitmap, lambda r: r * 0.5, lambda g: g, lambda b: b * 0.5 + ) + + +def blue_cast(bitmap): + return bitmap_channel_filter1( + bitmap, lambda r: r * 0.5, lambda g: g * 0.5, lambda b: b + ) + + +def blur(bitmap): + return bitmap_separable_filter(bitmap, np.array([0.25, 0.5, 0.25])) + + +def sharpen(bitmap): + y = 1 / 5 + return bitmap_separable_filter(bitmap, np.array([-y, -y, 2 - y, -y, -y])) + + +def edgedetect(bitmap): + coefficients = np.array([-1, 0, 1]) + r, g, b = bitmap_to_components_rgb565(bitmap) + r = separable_filter(r, coefficients, coefficients) + 0.5 + g = separable_filter(g, coefficients, coefficients) + 0.5 + b = separable_filter(b, coefficients, coefficients) + 0.5 + return bitmap_from_components_rgb565(r, g, b) From e93b3d8ef422804b1ed4d7b9a90395947d58ffca Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 18:44:43 -0600 Subject: [PATCH 2/8] 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. --- adafruit_pycamera/imageprocessing.py | 268 +++++++++++++++------------ examples/filter/code.py | 172 +++++++++++++++++ 2 files changed, 317 insertions(+), 123 deletions(-) create mode 100644 examples/filter/code.py diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py index c8d5fc2..425f8c3 100644 --- a/adafruit_pycamera/imageprocessing.py +++ b/adafruit_pycamera/imageprocessing.py @@ -1,20 +1,22 @@ -import sys +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""Routines for performing image manipulation""" + import struct -import displayio -try: - import numpy as np -except: - import ulab.numpy as np +import ulab.numpy as np def _bytes_per_row(source_width: int) -> int: + """Internal function to determine bitmap bytes per row""" pixel_bytes = 3 * source_width padding_bytes = (4 - (pixel_bytes % 4)) % 4 return pixel_bytes + padding_bytes -def _write_bmp_header(output_file: BufferedWriter, filesize: int) -> None: +def _write_bmp_header(output_file, filesize): + """Internal function to write bitmap header""" output_file.write(bytes("BM", "ascii")) output_file.write(struct.pack(" None: output_file.write(struct.pack(" None: +def _write_dib_header(output_file, width: int, height: int) -> None: + """Internal function to write bitmap "dib" header""" output_file.write(struct.pack(" N output_file.write(b"\x00") -def components_to_file_rgb565(output_file, r, g, b): +def components_to_bitmap(output_file, r, g, b): + """Write image components to an uncompressed 24-bit .bmp format file""" height, width = r.shape pixel_bytes = 3 * width padding_bytes = (4 - (pixel_bytes % 4)) % 4 filesize = 54 + height * (pixel_bytes + padding_bytes) _write_bmp_header(output_file, filesize) _write_dib_header(output_file, width, height) - p = b"\0" * padding_bytes - m = memoryview(buffer_from_components_rgb888(r, g, b)) - for i in range(0, len(m), pixel_bytes)[::-1]: - output_file.write(m[i : i + pixel_bytes]) - output_file.write(p) + pad = b"\0" * padding_bytes + view = memoryview(buffer_from_components_rgb888(r, g, b)) + # Write out image data in reverse order with padding between rows + for i in range(0, len(view), pixel_bytes)[::-1]: + output_file.write(view[i : i + pixel_bytes]) + output_file.write(pad) -def np_convolve_same(a, v): - """Perform the np.convolve(mode=same) operation +def _np_convolve_same(arr, coeffs): + """Internal function to perform the np.convolve(arr, coeffs, mode="same") operation This is not directly supported on ulab, so we have to slice the "full" mode result """ - if len(a) < len(v): - a, v = v, a - tmp = np.convolve(a, v) - n = len(a) - c = (len(v) - 1) // 2 - result = tmp[c : c + n] + if len(arr) < len(coeffs): + arr, coeffs = coeffs, arr + tmp = np.convolve(arr, coeffs) + n = len(arr) + offset = (len(coeffs) - 1) // 2 + result = tmp[offset : offset + n] return result @@ -66,58 +71,60 @@ def np_convolve_same(a, v): def bitmap_as_array(bitmap): - ### XXX todo: work on blinka + """Create an array object that accesses the bitmap data""" if bitmap.width % 2: raise ValueError("Can only work on even-width bitmaps") - return ( - np.frombuffer(bitmap, dtype=np.uint16) - .reshape((bitmap.height, bitmap.width)) - .byteswap() - ) + return np.frombuffer(bitmap, dtype=np.uint16).reshape((bitmap.height, bitmap.width)) + + +def array_cast(arr, dtype): + """Cast an array to a given type and shape. The new type must match the original + type's size in bytes.""" + return np.frombuffer(arr, dtype=dtype).reshape(arr.shape) def bitmap_to_components_rgb565(bitmap): - """Convert a RGB65_BYTESWAPPED image to float32 components in the [0,1] inclusive range""" - arr = bitmap_as_array(bitmap) + """Convert a RGB65_BYTESWAPPED image to int16 components in the [0,255] inclusive range - r = np.right_shift(arr, 11) * (1.0 / FIVE_BITS) - g = (np.right_shift(arr, 5) & SIX_BITS) * (1.0 / SIX_BITS) - b = (arr & FIVE_BITS) * (1.0 / FIVE_BITS) + This requires higher memory than uint8, but allows more arithmetic on pixel values; + converting back to bitmap clamps values to the appropriate range.""" + arr = bitmap_as_array(bitmap) + arr.byteswap(inplace=True) + r = array_cast(np.right_shift(arr, 8) & 0xF8, np.int16) + g = array_cast(np.right_shift(arr, 3) & 0xFC, np.int16) + b = array_cast(np.left_shift(arr, 3) & 0xF8, np.int16) + arr.byteswap(inplace=True) return r, g, b -def bitmap_from_components_rgb565(r, g, b): - """Convert the float32 components to a bitmap""" - h, w = r.shape - result = displayio.Bitmap(w, h, 65535) - return bitmap_from_components_inplace_rgb565(result, r, g, b) +def bitmap_from_components_inplace_rgb565( + bitmap, r, g, b +): # pylint: disable=invalid-name + """Update a bitmap in-place with new RGB values""" + dest = bitmap_as_array(bitmap) + r = array_cast(np.maximum(np.minimum(r, 255), 0), np.uint16) + g = array_cast(np.maximum(np.minimum(g, 255), 0), np.uint16) + b = array_cast(np.maximum(np.minimum(b, 255), 0), np.uint16) + dest[:] = np.left_shift(r & 0xF8, 8) + dest[:] |= np.left_shift(g & 0xFC, 3) + dest[:] |= np.right_shift(b, 3) + dest.byteswap(inplace=True) + return bitmap -def bitmap_from_components_inplace_rgb565(bitmap, r, g, b): - arr = bitmap_as_array(bitmap) - r = np.array(np.maximum(np.minimum(r, 1.0), 0.0) * FIVE_BITS, dtype=np.uint16) - g = np.array(np.maximum(np.minimum(g, 1.0), 0.0) * SIX_BITS, dtype=np.uint16) - b = np.array(np.maximum(np.minimum(b, 1.0), 0.0) * FIVE_BITS, dtype=np.uint16) - arr = np.left_shift(r, 11) - arr[:] |= np.left_shift(g, 5) - arr[:] |= b - arr = arr.byteswap().flatten() - dest = np.frombuffer(bitmap, dtype=np.uint16) - dest[:] = arr - return bitmap +def as_flat(arr): + """Flatten an array, ensuring no copy is made""" + return np.frombuffer(arr, arr.dtype) def buffer_from_components_rgb888(r, g, b): - """Convert the float32 components to a RGB888 buffer in memory""" - r = np.array( - np.maximum(np.minimum(r, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8 - ).flatten() - g = np.array( - np.maximum(np.minimum(g, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8 - ).flatten() - b = np.array( - np.maximum(np.minimum(b, 1.0), 0.0) * EIGHT_BITS, dtype=np.uint8 - ).flatten() + """Convert the individual color components to a single RGB888 buffer in memory""" + r = as_flat(r) + g = as_flat(g) + b = as_flat(b) + r = np.maximum(np.minimum(r, 0x3F), 0) + g = np.maximum(np.minimum(g, 0x3F), 0) + b = np.maximum(np.minimum(b, 0x3F), 0) result = np.zeros(3 * len(r), dtype=np.uint8) result[2::3] = r result[1::3] = g @@ -125,129 +132,144 @@ def buffer_from_components_rgb888(r, g, b): return result -def separable_filter(data, vh, vv=None): - """Apply a separable filter to a 2d array. - - If the vertical coefficients ``vv`` are none, the ``vh`` components are - used for vertical too.""" - if vv is None: - vv = vh +def symmetric_filter_inplace(data, coeffs, scale): + """Apply a symmetric separable filter to a 2d array, changing it in place. - result = data[:] + The same filter is applied to image rows and image columns. This is appropriate for + many common kinds of image filters such as blur, sharpen, and edge detect. + Normally, scale is sum(coeffs).""" # First run the filter across each row - n_rows = result.shape[0] + n_rows = data.shape[0] for i in range(n_rows): - result[i, :] = np_convolve_same(result[i, :], vh) + data[i, :] = _np_convolve_same(data[i, :], coeffs) // scale # Run the filter across each column - n_cols = result.shape[1] + n_cols = data.shape[1] for i in range(n_cols): - result[:, i] = np_convolve_same(result[:, i], vv) + data[:, i] = _np_convolve_same(data[:, i], coeffs) // scale - return result + return data -def bitmap_separable_filter(bitmap, vh, vv=None): - """Apply a separable filter to an image, returning a new image""" +def bitmap_symmetric_filter_inplace(bitmap, coeffs, scale): + """Apply a symmetric filter to an image, updating the original image""" r, g, b = bitmap_to_components_rgb565(bitmap) - r = separable_filter(r, vh, vv) - g = separable_filter(g, vh, vv) - b = separable_filter(b, vh, vv) - return bitmap_from_components_rgb565(r, g, b) + symmetric_filter_inplace(r, coeffs, scale) + symmetric_filter_inplace(g, coeffs, scale) + symmetric_filter_inplace(b, coeffs, scale) + return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) -def bitmap_channel_filter3( +def bitmap_channel_filter3_inplace( bitmap, r_func=lambda r, g, b: r, g_func=lambda r, g, b: g, b_func=lambda r, g, b: b ): - """Perform channel filtering where each function recieves all 3 channels""" + """Perform channel filtering in place, updating the original image + + Each callback function recieves all 3 channels""" r, g, b = bitmap_to_components_rgb565(bitmap) r = r_func(r, g, b) g = g_func(r, g, b) b = b_func(r, g, b) - return bitmap_from_components_rgb565(r, g, b) + return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) -def bitmap_channel_filter1( +def bitmap_channel_filter1_inplace( bitmap, r_func=lambda r: r, g_func=lambda g: g, b_func=lambda b: b ): - """Perform channel filtering where each function recieves just one channel""" - return bitmap_channel_filter3( - bitmap, - lambda r, g, b: r_func(r), - lambda r, g, b: g_func(g), - lambda r, g, b: b_func(b), - ) + """Perform channel filtering in place, updating the original image + Each callback function recieves just its own channel data.""" + r, g, b = bitmap_to_components_rgb565(bitmap) + r[:] = r_func(r) + g[:] = g_func(g) + b[:] = b_func(b) + return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) -def solarize_channel(c, threshold=0.5): + +def solarize_channel(data, threshold=128): """Solarize an image channel. If the channel value is above a threshold, it is inverted. Otherwise, it is unchanged. """ - return (-1 * arr) * (arr > threshold) + arr * (arr <= threshold) + return (255 - data) * (data > threshold) + data * (data <= threshold) -def solarize(bitmap, threshold=0.5): - """Apply a solarize filter to an image""" - return bitmap_channel_filter1( - bitmap, - lambda r: solarize_channel(r, threshold), - lambda g: solarize_channel(r, threshold), - lambda b: solarize_channel(b, threshold), - ) +def solarize(bitmap, threshold=128): + """Apply a per-channel solarize filter to an image in place""" + + def do_solarize(channel): + return solarize_channel(channel, threshold) + + return bitmap_channel_filter1_inplace(bitmap, do_solarize, do_solarize, do_solarize) def sepia(bitmap): - """Apply a sepia filter to an image + """Apply a sepia filter to an image in place based on some coefficients I found on the internet""" - return bitmap_channel_filter3( + return bitmap_channel_filter3_inplace( bitmap, - lambda r, g, b: 0.393 * r + 0.769 * g + 0.189 * b, - lambda r, g, b: 0.349 * r + 0.686 * g + 0.168 * b, - lambda r, g, b: 0.272 * r + 0.534 * g + 0.131 * b, + lambda r, g, b: np.right_shift(50 * r + 98 * g + 24 * b, 7), + lambda r, g, b: np.right_shift(44 * r + 88 * g + 42 * b, 7), + lambda r, g, b: np.right_shift(35 * r + 69 * g + 17 * b, 7), ) def greyscale(bitmap): """Convert an image to greyscale""" r, g, b = bitmap_to_components_rgb565(bitmap) - l = 0.2989 * r + 0.5870 * g + 0.1140 * b - return bitmap_from_components_rgb565(l, l, l) + luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) + return bitmap_from_components_inplace_rgb565( + bitmap, luminance, luminance, luminance + ) + + +def _identity(channel): + """An internal function to return a channel unchanged""" + return channel + + +def _half(channel): + """An internal function to divide channel values by two""" + return channel // 2 def red_cast(bitmap): - return bitmap_channel_filter1( - bitmap, lambda r: r, lambda g: g * 0.5, lambda b: b * 0.5 - ) + """Give an image a red cast by dividing G and B channels in half""" + return bitmap_channel_filter1_inplace(bitmap, _identity, _half, _half) def green_cast(bitmap): - return bitmap_channel_filter1( - bitmap, lambda r: r * 0.5, lambda g: g, lambda b: b * 0.5 - ) + """Give an image a green cast by dividing R and B channels in half""" + return bitmap_channel_filter1_inplace(bitmap, _half, _identity, _half) def blue_cast(bitmap): - return bitmap_channel_filter1( - bitmap, lambda r: r * 0.5, lambda g: g * 0.5, lambda b: b - ) + """Give an image a blue cast by dividing R and G channels in half""" + return bitmap_channel_filter1_inplace(bitmap, _half, _half, _identity) def blur(bitmap): - return bitmap_separable_filter(bitmap, np.array([0.25, 0.5, 0.25])) + """Blur a bitmap""" + return bitmap_symmetric_filter_inplace(bitmap, np.array([1, 2, 1]), scale=4) def sharpen(bitmap): - y = 1 / 5 - return bitmap_separable_filter(bitmap, np.array([-y, -y, 2 - y, -y, -y])) + """Sharpen a bitmap""" + return bitmap_symmetric_filter_inplace( + bitmap, np.array([-1, -1, 9, -1, -1]), scale=5 + ) def edgedetect(bitmap): + """Run an edge detection routine on a bitmap""" coefficients = np.array([-1, 0, 1]) r, g, b = bitmap_to_components_rgb565(bitmap) - r = separable_filter(r, coefficients, coefficients) + 0.5 - g = separable_filter(g, coefficients, coefficients) + 0.5 - b = separable_filter(b, coefficients, coefficients) + 0.5 - return bitmap_from_components_rgb565(r, g, b) + symmetric_filter_inplace(r, coefficients, scale=1) + r += 128 + symmetric_filter_inplace(g, coefficients, scale=1) + g += 128 + symmetric_filter_inplace(b, coefficients, scale=1) + b += 128 + return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) diff --git a/examples/filter/code.py b/examples/filter/code.py new file mode 100644 index 0000000..31fdc4d --- /dev/null +++ b/examples/filter/code.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Image viewer + +This will display all *jpeg* format images on the inserted SD card, in random order. +Each time an image is displayed, one of the pre-defined image filters is performed on it. + +Images cycle every DISPLAY_INTERVAL milliseconds (default 8000 = 8 seconds) as long as +they can be processed fast enough. Pressing any of the 4 direction buttons will start a +new image processing as soon as possible. +""" + +import time +import os +import random +import displayio +from jpegio import JpegDecoder +from adafruit_ticks import ticks_less, ticks_ms, ticks_add, ticks_diff +from adafruit_pycamera import PyCameraBase +from adafruit_pycamera import imageprocessing + +effects = [ + imageprocessing.blue_cast, + imageprocessing.blur, + imageprocessing.edgedetect, + imageprocessing.green_cast, + imageprocessing.greyscale, + imageprocessing.red_cast, + imageprocessing.sepia, + imageprocessing.sharpen, + imageprocessing.solarize, +] + + +def random_choice(seq): + return seq[random.randrange(0, len(seq))] + + +DISPLAY_INTERVAL = 8000 # milliseconds + +decoder = JpegDecoder() + +pycam = PyCameraBase() +pycam.init_display() + + +def load_resized_image(bitmap, filename): + print(f"loading {filename}") + bitmap.fill(0b01000_010000_01000) # fill with a middle grey + + bw, bh = bitmap.width, bitmap.height + t0 = ticks_ms() + h, w = decoder.open(filename) + t1 = ticks_ms() + print(f"{ticks_diff(t1, t0)}ms to open") + scale = 0 + print(f"Full image size is {w}x{h}") + print(f"Bitmap is {bw}x{bh} pixels") + while (w >> scale) > bw or (h >> scale) > bh and scale < 3: + scale += 1 + sw = w >> scale + sh = h >> scale + print(f"will load at {scale=}, giving {sw}x{sh} pixels") + + if sw > bw: # left/right sides cut off + x = 0 + x1 = (sw - bw) // 2 + else: # horizontally centered + x = (bw - sw) // 2 + x1 = 0 + + if sh > bh: # top/bottom sides cut off + y = 0 + y1 = (sh - bh) // 2 + else: # vertically centered + y = (bh - sh) // 2 + y1 = 0 + + print(f"{x=} {y=} {x1=} {y1=}") + decoder.decode(bitmap, x=x, y=y, x1=x1, y1=y1, scale=scale) + t1 = ticks_ms() + print(f"{ticks_diff(t1, t0)}ms to decode") + + +def mount_sd(): + if not pycam.card_detect.value: + pycam.display_message("No SD card\ninserted", color=0xFF0000) + return [] + pycam.display_message("Mounting\nSD Card", color=0xFFFFFF) + for _ in range(3): + try: + print("Mounting card") + pycam.mount_sd_card() + print("Success!") + break + except OSError as e: + print("Retrying!", e) + time.sleep(0.5) + else: + pycam.display_message("SD Card\nFailed!", color=0xFF0000) + time.sleep(0.5) + all_images = [ + f"/sd/{filename}" + for filename in os.listdir("/sd") + if filename.lower().endswith(".jpg") + ] + pycam.display_message(f"Found {len(all_images)}\nimages", color=0xFFFFFF) + time.sleep(0.5) + pycam.display.refresh() + return all_images + + +def main(): + deadline = ticks_ms() + all_images = mount_sd() + + bitmap = displayio.Bitmap(pycam.display.width, pycam.display.height - 32, 65535) + + while True: + pycam.keys_debounce() + if pycam.card_detect.fell: + print("SD card removed") + pycam.unmount_sd_card() + pycam.display_message("SD Card\nRemoved", color=0xFFFFFF) + time.sleep(0.5) + pycam.display.refresh() + all_images = [] + + now = ticks_ms() + if pycam.card_detect.rose: + print("SD card inserted") + all_images = mount_sd() + deadline = now + + if all_images: + if pycam.up.fell: + deadline = now + + if pycam.down.fell: + deadline = now + + if pycam.left.fell: + deadline = now + + if pycam.right.fell: + deadline = now + + if ticks_less(deadline, now): + print(now, deadline, ticks_less(deadline, now), all_images) + deadline = ticks_add(deadline, DISPLAY_INTERVAL) + filename = random_choice(all_images) + effect = random.choice(effects) + try: + load_resized_image(bitmap, filename) + except Exception as e: # pylint: disable=broad-exception-caught + pycam.display_message(f"Failed to read\n{filename}", color=0xFF0000) + print(e) + deadline = ticks_add(now, 0) + try: + print(f"applying {effect=}") + t0 = ticks_ms() + bitmap = effect(bitmap) + t1 = ticks_ms() + print(f"{ticks_diff(t1, t0)}ms to apply effect") + except MemoryError as e: + print(e) + pycam.blit(bitmap) + + +main() From bfb86dc1d8bb7f26b2dab446684c29a0f9b8966c Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 19:01:28 -0600 Subject: [PATCH 3/8] Enhance documentation & edge filtering --- adafruit_pycamera/imageprocessing.py | 81 ++++++++++++++++++---------- docs/api.rst | 2 + docs/conf.py | 1 + examples/filter/code.py | 10 ++++ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py index 425f8c3..b280c11 100644 --- a/adafruit_pycamera/imageprocessing.py +++ b/adafruit_pycamera/imageprocessing.py @@ -70,29 +70,32 @@ def _np_convolve_same(arr, coeffs): EIGHT_BITS = 0b11111111 -def bitmap_as_array(bitmap): +def _bitmap_as_array(bitmap): """Create an array object that accesses the bitmap data""" if bitmap.width % 2: raise ValueError("Can only work on even-width bitmaps") return np.frombuffer(bitmap, dtype=np.uint16).reshape((bitmap.height, bitmap.width)) -def array_cast(arr, dtype): +def _array_cast(arr, dtype): """Cast an array to a given type and shape. The new type must match the original type's size in bytes.""" return np.frombuffer(arr, dtype=dtype).reshape(arr.shape) def bitmap_to_components_rgb565(bitmap): - """Convert a RGB65_BYTESWAPPED image to int16 components in the [0,255] inclusive range + """Convert a RGB565_BYTESWAPPED image to int16 components in the [0,255] inclusive range This requires higher memory than uint8, but allows more arithmetic on pixel values; - converting back to bitmap clamps values to the appropriate range.""" - arr = bitmap_as_array(bitmap) + converting back to bitmap clamps values to the appropriate range. + + This only works on images whose width is a multiple of 2 pixels. + """ + arr = _bitmap_as_array(bitmap) arr.byteswap(inplace=True) - r = array_cast(np.right_shift(arr, 8) & 0xF8, np.int16) - g = array_cast(np.right_shift(arr, 3) & 0xFC, np.int16) - b = array_cast(np.left_shift(arr, 3) & 0xF8, np.int16) + r = _array_cast(np.right_shift(arr, 8) & 0xF8, np.int16) + g = _array_cast(np.right_shift(arr, 3) & 0xFC, np.int16) + b = _array_cast(np.left_shift(arr, 3) & 0xF8, np.int16) arr.byteswap(inplace=True) return r, g, b @@ -101,10 +104,10 @@ def bitmap_from_components_inplace_rgb565( bitmap, r, g, b ): # pylint: disable=invalid-name """Update a bitmap in-place with new RGB values""" - dest = bitmap_as_array(bitmap) - r = array_cast(np.maximum(np.minimum(r, 255), 0), np.uint16) - g = array_cast(np.maximum(np.minimum(g, 255), 0), np.uint16) - b = array_cast(np.maximum(np.minimum(b, 255), 0), np.uint16) + dest = _bitmap_as_array(bitmap) + r = _array_cast(np.maximum(np.minimum(r, 255), 0), np.uint16) + g = _array_cast(np.maximum(np.minimum(g, 255), 0), np.uint16) + b = _array_cast(np.maximum(np.minimum(b, 255), 0), np.uint16) dest[:] = np.left_shift(r & 0xF8, 8) dest[:] |= np.left_shift(g & 0xFC, 3) dest[:] |= np.right_shift(b, 3) @@ -112,16 +115,16 @@ def bitmap_from_components_inplace_rgb565( return bitmap -def as_flat(arr): - """Flatten an array, ensuring no copy is made""" +def _as_flat(arr): + """Internal routine to flatten an array, ensuring no copy is made""" return np.frombuffer(arr, arr.dtype) def buffer_from_components_rgb888(r, g, b): """Convert the individual color components to a single RGB888 buffer in memory""" - r = as_flat(r) - g = as_flat(g) - b = as_flat(b) + r = _as_flat(r) + g = _as_flat(g) + b = _as_flat(b) r = np.maximum(np.minimum(r, 0x3F), 0) g = np.maximum(np.minimum(g, 0x3F), 0) b = np.maximum(np.minimum(b, 0x3F), 0) @@ -139,21 +142,26 @@ def symmetric_filter_inplace(data, coeffs, scale): many common kinds of image filters such as blur, sharpen, and edge detect. Normally, scale is sum(coeffs).""" - # First run the filter across each row + row_filter_inplace(data, coeffs, scale) + column_filter_inplace(data, coeffs, scale) + + +def row_filter_inplace(data, coeffs, scale): + """Apply a filter to data in rows, changing it in place""" n_rows = data.shape[0] for i in range(n_rows): data[i, :] = _np_convolve_same(data[i, :], coeffs) // scale - # Run the filter across each column + +def column_filter_inplace(data, coeffs, scale): + """Apply a filter to data in columns, changing it in place""" n_cols = data.shape[1] for i in range(n_cols): data[:, i] = _np_convolve_same(data[:, i], coeffs) // scale - return data - def bitmap_symmetric_filter_inplace(bitmap, coeffs, scale): - """Apply a symmetric filter to an image, updating the original image""" + """Apply the same filter to an image by rows and then by columns, updating the original image""" r, g, b = bitmap_to_components_rgb565(bitmap) symmetric_filter_inplace(r, coeffs, scale) symmetric_filter_inplace(g, coeffs, scale) @@ -262,14 +270,31 @@ def sharpen(bitmap): ) +def _edge_filter_component(data, coefficients): + """Internal filter to apply H+V edge detection to an image component""" + data_copy = data[:] + row_filter_inplace(data, coefficients, scale=1) + column_filter_inplace(data_copy, coefficients, scale=1) + data += data_copy + data += 128 + + def edgedetect(bitmap): """Run an edge detection routine on a bitmap""" coefficients = np.array([-1, 0, 1]) r, g, b = bitmap_to_components_rgb565(bitmap) - symmetric_filter_inplace(r, coefficients, scale=1) - r += 128 - symmetric_filter_inplace(g, coefficients, scale=1) - g += 128 - symmetric_filter_inplace(b, coefficients, scale=1) - b += 128 + _edge_filter_component(r, coefficients) + _edge_filter_component(g, coefficients) + _edge_filter_component(b, coefficients) return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) + + +def edgedetect_greyscale(bitmap): + """Run an edge detection routine on a bitmap in greyscale""" + coefficients = np.array([-1, 0, 1]) + r, g, b = bitmap_to_components_rgb565(bitmap) + luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) + _edge_filter_component(luminance, coefficients) + return bitmap_from_components_inplace_rgb565( + bitmap, luminance, luminance, luminance + ) diff --git a/docs/api.rst b/docs/api.rst index 811e43d..06d67f8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,3 +6,5 @@ .. automodule:: adafruit_pycamera :members: +.. automodule:: adafruit_pycamera.imageprocessing + :members: diff --git a/docs/conf.py b/docs/conf.py index 66f51d2..447aa6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,6 +44,7 @@ "sdcardio", "storage", "terminalio", + "ulab", ] autodoc_preserve_defaults = True diff --git a/examples/filter/code.py b/examples/filter/code.py index 31fdc4d..7792315 100644 --- a/examples/filter/code.py +++ b/examples/filter/code.py @@ -25,6 +25,7 @@ imageprocessing.blue_cast, imageprocessing.blur, imageprocessing.edgedetect, + imageprocessing.edgedetect_grayscale, imageprocessing.green_cast, imageprocessing.greyscale, imageprocessing.red_cast, @@ -47,6 +48,15 @@ def random_choice(seq): def load_resized_image(bitmap, filename): + """Load an image at the best scale into a given bitmap + + If the image can be scaled down until it fits within the bitmap, this routine + does so, leaving equal space at the sides of the image (known as letterboxing + or pillarboxing). + + If the image cannot be scaled down, the most central part of the image is loaded + into the bitmap.""" + print(f"loading {filename}") bitmap.fill(0b01000_010000_01000) # fill with a middle grey From 9021fd06d649ab60c0cc1546daf3938ec057b9f9 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 19:14:58 -0600 Subject: [PATCH 4/8] fix sepia effect --- adafruit_pycamera/imageprocessing.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py index b280c11..52b4778 100644 --- a/adafruit_pycamera/imageprocessing.py +++ b/adafruit_pycamera/imageprocessing.py @@ -213,14 +213,14 @@ def do_solarize(channel): def sepia(bitmap): - """Apply a sepia filter to an image in place - - based on some coefficients I found on the internet""" - return bitmap_channel_filter3_inplace( + """Apply a sepia filter to an image in place""" + r, g, b = bitmap_to_components_rgb565(bitmap) + luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) + return bitmap_from_components_inplace_rgb565( bitmap, - lambda r, g, b: np.right_shift(50 * r + 98 * g + 24 * b, 7), - lambda r, g, b: np.right_shift(44 * r + 88 * g + 42 * b, 7), - lambda r, g, b: np.right_shift(35 * r + 69 * g + 17 * b, 7), + luminance, + np.right_shift(luminance * 113, 7), + np.right_shift(luminance * 88, 7), ) From a27968d419026af3b2193ac04a1d9b2561bce64b Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Fri, 5 Jan 2024 10:05:02 -0600 Subject: [PATCH 5/8] Slight performance improvements of image processing np.minimum / maximum are surprisingly slow! --- adafruit_pycamera/imageprocessing.py | 61 ++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py index 52b4778..751bf57 100644 --- a/adafruit_pycamera/imageprocessing.py +++ b/adafruit_pycamera/imageprocessing.py @@ -4,9 +4,37 @@ """Routines for performing image manipulation""" import struct +from adafruit_ticks import ticks_ms, ticks_diff +from micropython import const import ulab.numpy as np +# Optionally enable reporting of time taken inside tagged functions +_DO_TIME_REPORT = const(0) + +if _DO_TIME_REPORT: + + def _timereport(func): + """Report time taken within the function""" + name = str(func).split()[1] + + def inner(*args, **kw): + start = ticks_ms() + try: + return func(*args, **kw) + finally: + end = ticks_ms() + duration = ticks_diff(end, start) + print(f"{name}: {duration}ms") + + return inner + +else: + + def _timereport(func): + """A do-nothing decorator for when timing report is not desired""" + return func + def _bytes_per_row(source_width: int) -> int: """Internal function to determine bitmap bytes per row""" @@ -83,11 +111,14 @@ def _array_cast(arr, dtype): return np.frombuffer(arr, dtype=dtype).reshape(arr.shape) +@_timereport def bitmap_to_components_rgb565(bitmap): """Convert a RGB565_BYTESWAPPED image to int16 components in the [0,255] inclusive range This requires higher memory than uint8, but allows more arithmetic on pixel values; - converting back to bitmap clamps values to the appropriate range. + but values are masked (not clamped) back down to the 0-255 range, so while intermediate + values can be -32768..32767 the values passed into bitmap_from_components_inplace_rgb565 + muts be 0..255 This only works on images whose width is a multiple of 2 pixels. """ @@ -100,17 +131,20 @@ def bitmap_to_components_rgb565(bitmap): return r, g, b +@_timereport def bitmap_from_components_inplace_rgb565( bitmap, r, g, b ): # pylint: disable=invalid-name """Update a bitmap in-place with new RGB values""" dest = _bitmap_as_array(bitmap) - r = _array_cast(np.maximum(np.minimum(r, 255), 0), np.uint16) - g = _array_cast(np.maximum(np.minimum(g, 255), 0), np.uint16) - b = _array_cast(np.maximum(np.minimum(b, 255), 0), np.uint16) - dest[:] = np.left_shift(r & 0xF8, 8) - dest[:] |= np.left_shift(g & 0xFC, 3) - dest[:] |= np.right_shift(b, 3) + r = _array_cast(r, np.uint16) + g = _array_cast(g, np.uint16) + b = _array_cast(b, np.uint16) + dest[:] = ( + np.left_shift(r & 0xF8, 8) + | np.left_shift(g & 0xFC, 3) + | np.right_shift(b & 0xF8, 3) + ) dest.byteswap(inplace=True) return bitmap @@ -125,13 +159,10 @@ def buffer_from_components_rgb888(r, g, b): r = _as_flat(r) g = _as_flat(g) b = _as_flat(b) - r = np.maximum(np.minimum(r, 0x3F), 0) - g = np.maximum(np.minimum(g, 0x3F), 0) - b = np.maximum(np.minimum(b, 0x3F), 0) result = np.zeros(3 * len(r), dtype=np.uint8) - result[2::3] = r - result[1::3] = g - result[0::3] = b + result[2::3] = r & 0xFF + result[1::3] = g & 0xFF + result[0::3] = b & 0xFF return result @@ -146,6 +177,7 @@ def symmetric_filter_inplace(data, coeffs, scale): column_filter_inplace(data, coeffs, scale) +@_timereport def row_filter_inplace(data, coeffs, scale): """Apply a filter to data in rows, changing it in place""" n_rows = data.shape[0] @@ -153,6 +185,7 @@ def row_filter_inplace(data, coeffs, scale): data[i, :] = _np_convolve_same(data[i, :], coeffs) // scale +@_timereport def column_filter_inplace(data, coeffs, scale): """Apply a filter to data in columns, changing it in place""" n_cols = data.shape[1] @@ -169,6 +202,7 @@ def bitmap_symmetric_filter_inplace(bitmap, coeffs, scale): return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) +@_timereport def bitmap_channel_filter3_inplace( bitmap, r_func=lambda r, g, b: r, g_func=lambda r, g, b: g, b_func=lambda r, g, b: b ): @@ -182,6 +216,7 @@ def bitmap_channel_filter3_inplace( return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) +@_timereport def bitmap_channel_filter1_inplace( bitmap, r_func=lambda r: r, g_func=lambda g: g, b_func=lambda b: b ): From 6234432a1ec7d37b46f7d3b7343ef38ef688dddd Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Fri, 5 Jan 2024 10:54:06 -0600 Subject: [PATCH 6/8] mock adafruit ticks during doc build --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 447aa6e..b78c877 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,6 +32,7 @@ "adafruit_debouncer", "adafruit_display_text", "adafruit_lis3dh", + "adafruit_ticks", "bitmaptools", "busdisplay", "busio", From 3689a8783e29889265c4e59d3a9150530b7feb72 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Fri, 26 Jan 2024 11:06:20 -0600 Subject: [PATCH 7/8] Update image processing demo for newest CircuitPython --- LICENSES/CC0-1.0.txt | 121 +++++++ adafruit_pycamera/imageprocessing.py | 339 +++--------------- adafruit_pycamera/ironbow.py | 50 +++ examples/filter/code.py | 221 ++++-------- examples/filter/cornell_box_208x208.jpg | Bin 0 -> 10161 bytes .../filter/cornell_box_208x208.jpg.license | 3 + 6 files changed, 294 insertions(+), 440 deletions(-) create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 adafruit_pycamera/ironbow.py create mode 100644 examples/filter/cornell_box_208x208.jpg create mode 100644 examples/filter/cornell_box_208x208.jpg.license diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py index 751bf57..d7fa770 100644 --- a/adafruit_pycamera/imageprocessing.py +++ b/adafruit_pycamera/imageprocessing.py @@ -3,333 +3,82 @@ # SPDX-License-Identifier: MIT """Routines for performing image manipulation""" -import struct -from adafruit_ticks import ticks_ms, ticks_diff +import bitmapfilter -from micropython import const -import ulab.numpy as np +from adafruit_pycamera.ironbow import ironbow_palette -# Optionally enable reporting of time taken inside tagged functions -_DO_TIME_REPORT = const(0) +sepia_weights = bitmapfilter.ChannelMixer( + 0.393, 0.769, 0.189, 0.349, 0.686, 0.168, 0.272, 0.534, 0.131 +) -if _DO_TIME_REPORT: - def _timereport(func): - """Report time taken within the function""" - name = str(func).split()[1] - - def inner(*args, **kw): - start = ticks_ms() - try: - return func(*args, **kw) - finally: - end = ticks_ms() - duration = ticks_diff(end, start) - print(f"{name}: {duration}ms") - - return inner - -else: - - def _timereport(func): - """A do-nothing decorator for when timing report is not desired""" - return func - - -def _bytes_per_row(source_width: int) -> int: - """Internal function to determine bitmap bytes per row""" - pixel_bytes = 3 * source_width - padding_bytes = (4 - (pixel_bytes % 4)) % 4 - return pixel_bytes + padding_bytes - - -def _write_bmp_header(output_file, filesize): - """Internal function to write bitmap header""" - output_file.write(bytes("BM", "ascii")) - output_file.write(struct.pack(" None: - """Internal function to write bitmap "dib" header""" - output_file.write(struct.pack(" threshold) + data * (data <= threshold) - - -def solarize(bitmap, threshold=128): - """Apply a per-channel solarize filter to an image in place""" - - def do_solarize(channel): - return solarize_channel(channel, threshold) - - return bitmap_channel_filter1_inplace(bitmap, do_solarize, do_solarize, do_solarize) +def negative(bitmap, mask=None): + """Invert an image""" + bitmapfilter.mix(bitmap, negative_weights, mask=mask) + return bitmap -def sepia(bitmap): - """Apply a sepia filter to an image in place""" - r, g, b = bitmap_to_components_rgb565(bitmap) - luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) - return bitmap_from_components_inplace_rgb565( - bitmap, - luminance, - np.right_shift(luminance * 113, 7), - np.right_shift(luminance * 88, 7), - ) +greyscale_weights = bitmapfilter.ChannelMixer( + 0.299, 0.587, 0.114, 0.299, 0.587, 0.114, 0.299, 0.587, 0.114 +) -def greyscale(bitmap): +def greyscale(bitmap, mask=None): """Convert an image to greyscale""" - r, g, b = bitmap_to_components_rgb565(bitmap) - luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) - return bitmap_from_components_inplace_rgb565( - bitmap, luminance, luminance, luminance - ) - - -def _identity(channel): - """An internal function to return a channel unchanged""" - return channel - - -def _half(channel): - """An internal function to divide channel values by two""" - return channel // 2 + bitmapfilter.mix(bitmap, greyscale_weights, mask=mask) + return bitmap -def red_cast(bitmap): +def red_cast(bitmap, mask=None): """Give an image a red cast by dividing G and B channels in half""" - return bitmap_channel_filter1_inplace(bitmap, _identity, _half, _half) + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(1, 0.5, 0.5), mask=mask) + return bitmap -def green_cast(bitmap): +def green_cast(bitmap, mask=None): """Give an image a green cast by dividing R and B channels in half""" - return bitmap_channel_filter1_inplace(bitmap, _half, _identity, _half) + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(0.5, 1, 0.5), mask=mask) + return bitmap -def blue_cast(bitmap): +def blue_cast(bitmap, mask=None): """Give an image a blue cast by dividing R and G channels in half""" - return bitmap_channel_filter1_inplace(bitmap, _half, _half, _identity) + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(0.5, 0.5, 1), mask=mask) + return bitmap -def blur(bitmap): +def blur(bitmap, mask=None): """Blur a bitmap""" - return bitmap_symmetric_filter_inplace(bitmap, np.array([1, 2, 1]), scale=4) + bitmapfilter.morph(bitmap, (1, 2, 1, 2, 4, 2, 1, 2, 1), mask=mask) + return bitmap -def sharpen(bitmap): +def sharpen(bitmap, mask=None): """Sharpen a bitmap""" - return bitmap_symmetric_filter_inplace( - bitmap, np.array([-1, -1, 9, -1, -1]), scale=5 - ) + bitmapfilter.morph(bitmap, (-1, -2, -1, -2, 13, -2, -1, -2, -1), mask=mask) + return bitmap -def _edge_filter_component(data, coefficients): - """Internal filter to apply H+V edge detection to an image component""" - data_copy = data[:] - row_filter_inplace(data, coefficients, scale=1) - column_filter_inplace(data_copy, coefficients, scale=1) - data += data_copy - data += 128 +def emboss(bitmap, mask=None): + """Run an emboss filter on the bitmap""" + bitmapfilter.morph(bitmap, (-2, -1, 0, -1, 0, 1, 0, 1, 2), add=0.5, mask=mask) -def edgedetect(bitmap): - """Run an edge detection routine on a bitmap""" - coefficients = np.array([-1, 0, 1]) - r, g, b = bitmap_to_components_rgb565(bitmap) - _edge_filter_component(r, coefficients) - _edge_filter_component(g, coefficients) - _edge_filter_component(b, coefficients) - return bitmap_from_components_inplace_rgb565(bitmap, r, g, b) +def emboss_greyscale(bitmap, mask=None): + """Run an emboss filter on the bitmap in greyscale""" + greyscale(bitmap, mask=mask) + return emboss(bitmap, mask=mask) -def edgedetect_greyscale(bitmap): - """Run an edge detection routine on a bitmap in greyscale""" - coefficients = np.array([-1, 0, 1]) - r, g, b = bitmap_to_components_rgb565(bitmap) - luminance = np.right_shift(38 * r + 75 * g + 15 * b, 7) - _edge_filter_component(luminance, coefficients) - return bitmap_from_components_inplace_rgb565( - bitmap, luminance, luminance, luminance - ) +def ironbow(bitmap, mask=None): + """Convert an image to false color using the 'ironbow palette'""" + return bitmapfilter.false_color(bitmap, ironbow_palette, mask=mask) diff --git a/adafruit_pycamera/ironbow.py b/adafruit_pycamera/ironbow.py new file mode 100644 index 0000000..adea56f --- /dev/null +++ b/adafruit_pycamera/ironbow.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""The 'ironbow' palette used to convert images to false color""" + +import displayio + +ironbow_palette = displayio.Palette(256) + +# fmt: off +for i, pi in enumerate( + [ + 0xFFFFFF, 0xFFFFFF, 0xFFFEFF, 0xF7FEF7, 0xF7FDF7, 0xF7FDF7, 0xF7FCF7, 0xEFFCEF, + 0xEFFFEF, 0xEFFFEF, 0xEFFAEF, 0xE7FAE7, 0xE7FDE7, 0xE7FDE7, 0xE7F8E7, 0xDEF8DE, + 0xDEFFDE, 0xDEFFDE, 0xDEFEDE, 0xD6FED6, 0xD6F5D6, 0xD6F5D6, 0xD6F4D6, 0xCEF4CE, + 0xCEFFCE, 0xCEFFCE, 0xCEFACE, 0xC6FAC6, 0xC6F5C6, 0xC6F5C6, 0xC6F0C6, 0xBDF0BD, + 0xBDBFBD, 0xBDBFBD, 0xBDBEBD, 0xB5BEB5, 0xB5BDB5, 0xB5BDB5, 0xB5BCB5, 0xB5BCB5, + 0xADAFAD, 0xADAFAD, 0xADAAAD, 0xADAAAD, 0xA5ADA5, 0xA5ADA5, 0xA5A8A5, 0xA5A8A5, + 0x9CBF9C, 0x9CBF9C, 0x9CBE9C, 0x9CBE9C, 0x94B594, 0x94B594, 0x94B494, 0x94B494, + 0x8CAF8C, 0x8CAF8C, 0x8CAA8C, 0x8CAA8C, 0x84A584, 0x84A584, 0x84A084, 0x84A084, + 0x7B7F7B, 0x7B7F7B, 0x7B7E7B, 0x7B7E7B, 0x737D73, 0x737D73, 0x737C73, 0x737C73, + 0x6B7F6B, 0x6B7F6B, 0x6B7A6B, 0x6B7A6B, 0x637D63, 0x637D63, 0x637863, 0x637863, + 0x5A5F5A, 0x5A5F5A, 0x5A5E5A, 0x5A5E5A, 0x525552, 0x525552, 0x525452, 0x525452, + 0x4A5F4A, 0x4A5F4A, 0x4A5A4A, 0x4A5A4A, 0x4A554A, 0x425542, 0x425042, 0x425042, + 0x423F42, 0x393F39, 0x393E39, 0x393E39, 0x393D39, 0x313D31, 0x313C31, 0x313C31, + 0x312F31, 0x292F29, 0x292A29, 0x292A29, 0x292D29, 0x212D21, 0x212821, 0x212821, + 0x211F21, 0x181F18, 0x181E18, 0x181E18, 0x181518, 0x101510, 0x101410, 0x101410, + 0x100F10, 0x080F08, 0x080A08, 0x080A08, 0x080508, 0x000500, 0x000000, 0x000000, + 0x000008, 0x000010, 0x000018, 0x080021, 0x080029, 0x080029, 0x080031, 0x100039, + 0x100042, 0x10004A, 0x180052, 0x18005A, 0x180063, 0x18006B, 0x21006B, 0x210073, + 0x21007B, 0x29007B, 0x31007B, 0x31007B, 0x39007B, 0x39007B, 0x42007B, 0x4A007B, + 0x4A0084, 0x520084, 0x520084, 0x5A0084, 0x630084, 0x630084, 0x6B0084, 0x6B0084, + 0x73008C, 0x7B008C, 0x7B008C, 0x84008C, 0x84058C, 0x8C058C, 0x94058C, 0x94058C, + 0x9C058C, 0x9C058C, 0xA5058C, 0xA5058C, 0xAD058C, 0xB5058C, 0xB50A8C, 0xBD0A8C, + 0xBD0A8C, 0xBD0F84, 0xC6147B, 0xC6157B, 0xC61573, 0xC61E6B, 0xCE1F6B, 0xCE2863, + 0xCE2863, 0xCE2D5A, 0xD62A52, 0xD62F52, 0xD62F4A, 0xDE3C42, 0xDE3D42, 0xDE3E39, + 0xDE3E31, 0xDE3F31, 0xDE5029, 0xE75529, 0xE75A29, 0xE75A21, 0xE75F21, 0xE75421, + 0xE75521, 0xE75E18, 0xE75F18, 0xE75F18, 0xEF7810, 0xEF7D10, 0xEF7A10, 0xEF7F08, + 0xEF7C08, 0xEF7D08, 0xEF7D08, 0xEF7E08, 0xEF7F08, 0xEFA008, 0xEFA508, 0xF7AA08, + 0xF7AF10, 0xF7B410, 0xF7B510, 0xF7BE10, 0xF7BF10, 0xF7A810, 0xF7A810, 0xF7AD10, + 0xF7AA10, 0xF7AF10, 0xF7BC10, 0xF7BD10, 0xF7BE10, 0xF7BF10, 0xF7BF10, 0xFFF018, + 0xFFF518, 0xFFFA18, 0xFFFF18, 0xFFF418, 0xFFF518, 0xFFFE18, 0xFFFE21, 0xFFFF21, + 0xFFF829, 0xFFFD31, 0xFFFD42, 0xFFFA52, 0xFFFA63, 0xFFFA6B, 0xFFFF7B, 0xFFFF8C, + 0xFFFC94, 0xFFFCA5, 0xFFFDB5, 0xFFFDBD, 0xFFFECE, 0xFFFEDE, 0xFFFFEF, 0xFFFF18, + ] +): + ironbow_palette[i] = pi +del i +del pi +del displayio diff --git a/examples/filter/code.py b/examples/filter/code.py index 7792315..94d90f3 100644 --- a/examples/filter/code.py +++ b/examples/filter/code.py @@ -2,44 +2,63 @@ # # SPDX-License-Identifier: Unlicense -"""Image viewer +"""Effects Demonstration -This will display all *jpeg* format images on the inserted SD card, in random order. -Each time an image is displayed, one of the pre-defined image filters is performed on it. +This will apply a nubmer of effects to a single image. -Images cycle every DISPLAY_INTERVAL milliseconds (default 8000 = 8 seconds) as long as -they can be processed fast enough. Pressing any of the 4 direction buttons will start a -new image processing as soon as possible. +Press any of the directional buttons to immediately apply a new effect. + +Otherwise, effects cycle every DISPLAY_INTERVAL milliseconds (default 2000 = 2 seconds) """ -import time -import os -import random import displayio from jpegio import JpegDecoder +from adafruit_display_text.label import Label from adafruit_ticks import ticks_less, ticks_ms, ticks_add, ticks_diff -from adafruit_pycamera import PyCameraBase +from font_free_mono_bold_24 import FONT +import bitmapfilter + from adafruit_pycamera import imageprocessing +from adafruit_pycamera import PyCameraBase effects = [ - imageprocessing.blue_cast, - imageprocessing.blur, - imageprocessing.edgedetect, - imageprocessing.edgedetect_grayscale, - imageprocessing.green_cast, - imageprocessing.greyscale, - imageprocessing.red_cast, - imageprocessing.sepia, - imageprocessing.sharpen, - imageprocessing.solarize, + ("blue cast", imageprocessing.blue_cast), + ("blur", imageprocessing.blur), + ("bright", lambda b: bitmapfilter.mix(b, bitmapfilter.ChannelScale(2.0, 2.0, 2.0))), + ("emboss", imageprocessing.emboss), + ("green cast", imageprocessing.green_cast), + ("greyscale", imageprocessing.greyscale), + ("ironbow", imageprocessing.ironbow), + ( + "low contrast", + lambda b: bitmapfilter.mix( + b, bitmapfilter.ChannelScaleOffset(0.5, 0.5, 0.5, 0.5, 0.5, 0.5) + ), + ), + ("negative", imageprocessing.negative), + ("red cast", imageprocessing.red_cast), + ("sepia", imageprocessing.sepia), + ("sharpen", imageprocessing.sharpen), + ("solarize", bitmapfilter.solarize), + ( + "swap r/b", + lambda b: bitmapfilter.mix( + b, bitmapfilter.ChannelMixer(0, 0, 1, 0, 1, 0, 1, 0, 0) + ), + ), ] -def random_choice(seq): - return seq[random.randrange(0, len(seq))] +def cycle(seq): + while True: + for s in seq: + yield s + +effects_cycle = iter(cycle(effects)) -DISPLAY_INTERVAL = 8000 # milliseconds + +DISPLAY_INTERVAL = 2000 # milliseconds decoder = JpegDecoder() @@ -47,136 +66,48 @@ def random_choice(seq): pycam.init_display() -def load_resized_image(bitmap, filename): - """Load an image at the best scale into a given bitmap - - If the image can be scaled down until it fits within the bitmap, this routine - does so, leaving equal space at the sides of the image (known as letterboxing - or pillarboxing). - - If the image cannot be scaled down, the most central part of the image is loaded - into the bitmap.""" - - print(f"loading {filename}") - bitmap.fill(0b01000_010000_01000) # fill with a middle grey - - bw, bh = bitmap.width, bitmap.height - t0 = ticks_ms() - h, w = decoder.open(filename) - t1 = ticks_ms() - print(f"{ticks_diff(t1, t0)}ms to open") - scale = 0 - print(f"Full image size is {w}x{h}") - print(f"Bitmap is {bw}x{bh} pixels") - while (w >> scale) > bw or (h >> scale) > bh and scale < 3: - scale += 1 - sw = w >> scale - sh = h >> scale - print(f"will load at {scale=}, giving {sw}x{sh} pixels") - - if sw > bw: # left/right sides cut off - x = 0 - x1 = (sw - bw) // 2 - else: # horizontally centered - x = (bw - sw) // 2 - x1 = 0 - - if sh > bh: # top/bottom sides cut off - y = 0 - y1 = (sh - bh) // 2 - else: # vertically centered - y = (bh - sh) // 2 - y1 = 0 - - print(f"{x=} {y=} {x1=} {y1=}") - decoder.decode(bitmap, x=x, y=y, x1=x1, y1=y1, scale=scale) - t1 = ticks_ms() - print(f"{ticks_diff(t1, t0)}ms to decode") - - -def mount_sd(): - if not pycam.card_detect.value: - pycam.display_message("No SD card\ninserted", color=0xFF0000) - return [] - pycam.display_message("Mounting\nSD Card", color=0xFFFFFF) - for _ in range(3): - try: - print("Mounting card") - pycam.mount_sd_card() - print("Success!") - break - except OSError as e: - print("Retrying!", e) - time.sleep(0.5) - else: - pycam.display_message("SD Card\nFailed!", color=0xFF0000) - time.sleep(0.5) - all_images = [ - f"/sd/{filename}" - for filename in os.listdir("/sd") - if filename.lower().endswith(".jpg") - ] - pycam.display_message(f"Found {len(all_images)}\nimages", color=0xFFFFFF) - time.sleep(0.5) - pycam.display.refresh() - return all_images +def main(): + filename = "/cornell_box_208x208.jpg" + bitmap = displayio.Bitmap(208, 208, 65535) + bitmap0 = displayio.Bitmap(208, 208, 65535) + decoder.open(filename) + decoder.decode(bitmap0) + + label = Label(font=FONT, x=0, y=8) + pycam.display.root_group = label + pycam.display.refresh() -def main(): deadline = ticks_ms() - all_images = mount_sd() + while True: + now = ticks_ms() + if pycam.up.fell: + deadline = now - bitmap = displayio.Bitmap(pycam.display.width, pycam.display.height - 32, 65535) + if pycam.down.fell: + deadline = now - while True: - pycam.keys_debounce() - if pycam.card_detect.fell: - print("SD card removed") - pycam.unmount_sd_card() - pycam.display_message("SD Card\nRemoved", color=0xFFFFFF) - time.sleep(0.5) - pycam.display.refresh() - all_images = [] + if pycam.left.fell: + deadline = now - now = ticks_ms() - if pycam.card_detect.rose: - print("SD card inserted") - all_images = mount_sd() + if pycam.right.fell: deadline = now - if all_images: - if pycam.up.fell: - deadline = now - - if pycam.down.fell: - deadline = now - - if pycam.left.fell: - deadline = now - - if pycam.right.fell: - deadline = now - - if ticks_less(deadline, now): - print(now, deadline, ticks_less(deadline, now), all_images) - deadline = ticks_add(deadline, DISPLAY_INTERVAL) - filename = random_choice(all_images) - effect = random.choice(effects) - try: - load_resized_image(bitmap, filename) - except Exception as e: # pylint: disable=broad-exception-caught - pycam.display_message(f"Failed to read\n{filename}", color=0xFF0000) - print(e) - deadline = ticks_add(now, 0) - try: - print(f"applying {effect=}") - t0 = ticks_ms() - bitmap = effect(bitmap) - t1 = ticks_ms() - print(f"{ticks_diff(t1, t0)}ms to apply effect") - except MemoryError as e: - print(e) - pycam.blit(bitmap) + if ticks_less(deadline, now): + memoryview(bitmap)[:] = memoryview(bitmap0) + deadline = ticks_add(deadline, DISPLAY_INTERVAL) + + effect_name, effect = next(effects_cycle) # random.choice(effects) + print(effect) + print(f"applying {effect=}") + t0 = ticks_ms() + effect(bitmap) + t1 = ticks_ms() + dt = ticks_diff(t1, t0) + print(f"{dt}ms to apply effect") + pycam.blit(bitmap, x_offset=16) + label.text = f"{dt:4}ms: {effect_name}" + pycam.display.refresh() main() diff --git a/examples/filter/cornell_box_208x208.jpg b/examples/filter/cornell_box_208x208.jpg new file mode 100644 index 0000000000000000000000000000000000000000..20ab2c541ccd168cfb7eeb86f089b8d89b1b1997 GIT binary patch literal 10161 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 ZSwgqspdZe9W&yBn$6|oJO6Yy*e*sm$qFev~ literal 0 HcmV?d00001 diff --git a/examples/filter/cornell_box_208x208.jpg.license b/examples/filter/cornell_box_208x208.jpg.license new file mode 100644 index 0000000..2aa27ab --- /dev/null +++ b/examples/filter/cornell_box_208x208.jpg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2007 SeeSchloss + +SPDX-License-Identifier: CC0-1.0 From d68e862f41fae95dfaf55df843d1e8c4ef0318c9 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Fri, 26 Jan 2024 11:12:22 -0600 Subject: [PATCH 8/8] Fix doc building a non-default mock of displayio.Bitmap is provided for doc building --- adafruit_pycamera/ironbow.py | 1 + docs/api.rst | 2 ++ docs/conf.py | 3 ++- docs/mock/displayio.py | 11 +++++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 docs/mock/displayio.py diff --git a/adafruit_pycamera/ironbow.py b/adafruit_pycamera/ironbow.py index adea56f..11ce719 100644 --- a/adafruit_pycamera/ironbow.py +++ b/adafruit_pycamera/ironbow.py @@ -6,6 +6,7 @@ import displayio ironbow_palette = displayio.Palette(256) +"""A palette often used to convert images to false color""" # fmt: off for i, pi in enumerate( diff --git a/docs/api.rst b/docs/api.rst index 06d67f8..4e86a8c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -8,3 +8,5 @@ :members: .. automodule:: adafruit_pycamera.imageprocessing :members: +.. automodule:: adafruit_pycamera.ironbow + :members: diff --git a/docs/conf.py b/docs/conf.py index b78c877..3b6c67e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,6 +9,7 @@ import sys sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath("mock")) # -- General configuration ------------------------------------------------ @@ -34,10 +35,10 @@ "adafruit_lis3dh", "adafruit_ticks", "bitmaptools", + "bitmapfilter", "busdisplay", "busio", "digitalio", - "displayio", "espcamera", "fourwire", "micropython", diff --git a/docs/mock/displayio.py b/docs/mock/displayio.py new file mode 100644 index 0000000..b4fd1c5 --- /dev/null +++ b/docs/mock/displayio.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + + +class Palette: + def __init__(self, i): + self._data = [0] * i + + def __setitem__(self, idx, value): + self._data[idx] = value