From 0aa18147f88966f4cdf36d1ce2781f91d3f71561 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 11:16:45 -0600 Subject: [PATCH 1/7] Factor out a PyCameraBase class This gives the coder control over what is initialized. For the image review app, for instance, the camera & autofocus would not be initialized. Support for the display backlight is also added. --- adafruit_pycamera/__init__.py | 199 +++++++++++++++++++--------------- examples/camera/code.py | 1 - examples/ipcam2/code.py | 1 - 3 files changed, 113 insertions(+), 88 deletions(-) diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index aad2237..04a2763 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -71,8 +71,10 @@ _NVM_MODE = const(3) -class PyCamera: # pylint: disable=too-many-instance-attributes,too-many-public-methods - """Wrapper class for the PyCamera hardware""" +class PyCameraBase: # pylint: disable=too-many-instance-attributes,too-many-public-methods + """Base class for PyCamera hardware""" + + """Wrapper class for the PyCamera hardware with lots of smarts""" _finalize_firmware_load = ( 0x3022, @@ -176,59 +178,41 @@ class PyCamera: # pylint: disable=too-many-instance-attributes,too-many-public- b"\x29\x80\x05" # _DISPON and Delay 5ms ) - def i2c_scan(self): - """Print an I2C bus scan""" - while not self._i2c.try_lock(): - pass - - try: - print( - "I2C addresses found:", - [hex(device_address) for device_address in self._i2c.scan()], - ) - finally: # unlock the i2c bus when ctrl-c'ing out of the loop - self._i2c.unlock() - def __init__(self) -> None: # pylint: disable=too-many-statements - self._timestamp = time.monotonic() + displayio.release_displays() self._i2c = board.I2C() self._spi = board.SPI() - self.deinit_display() - - self.splash = displayio.Group() - self._sd_label = label.Label( - terminalio.FONT, text="SD ??", color=0x0, x=150, y=10, scale=2 - ) - self._effect_label = label.Label( - terminalio.FONT, text="EFFECT", color=0xFFFFFF, x=4, y=10, scale=2 - ) - self._mode_label = label.Label( - terminalio.FONT, text="MODE", color=0xFFFFFF, x=150, y=10, scale=2 - ) + self._timestamp = time.monotonic() + self._bigbuf = None + self._botbar = None + self._camera_device = None + self._display_bus = None + self._effect_label = None + self._image_counter = 0 + self._mode_label = None + self._res_label = None + self._sd_label = None + self._topbar = None + self.accel = None + self.camera = None + self.display = None + self.pixels = None + self.sdcard = None + self.splash = None - # turn on the display first, its reset line may be shared with the IO expander(?) - if not self.display: - self.init_display() + # Reset display and I/O expander + self._tft_aw_reset = DigitalInOut(board.TFT_RESET) + self._tft_aw_reset.switch_to_output(False) + time.sleep(0.05) + self._tft_aw_reset.switch_to_output(True) self.shutter_button = DigitalInOut(board.BUTTON) self.shutter_button.switch_to_input(Pull.UP) self.shutter = Button(self.shutter_button) - print("reset camera") self._cam_reset = DigitalInOut(board.CAMERA_RESET) self._cam_pwdn = DigitalInOut(board.CAMERA_PWDN) - self._cam_reset.switch_to_output(False) - self._cam_pwdn.switch_to_output(True) - time.sleep(0.01) - self._cam_pwdn.switch_to_output(False) - time.sleep(0.01) - self._cam_reset.switch_to_output(True) - time.sleep(0.01) - - print("pre cam @", time.monotonic() - self._timestamp) - self.i2c_scan() - # AW9523 GPIO expander self._aw = adafruit_aw9523.AW9523(self._i2c, address=0x58) print("Found AW9523") @@ -260,17 +244,40 @@ def make_debounced_expander_pin(pin_no): self.mute = make_expander_output(_AW_MUTE, False) - self.sdcard = None - try: - self.mount_sd_card() - except RuntimeError: - pass # no card found, its ok! - print("sdcard done @", time.monotonic() - self._timestamp) + def make_camera_ui(self): + """Create displayio widgets for the standard camera UI""" + self.splash = displayio.Group() + self._sd_label = label.Label( + terminalio.FONT, text="SD ??", color=0x0, x=150, y=10, scale=2 + ) + self._effect_label = label.Label( + terminalio.FONT, text="EFFECT", color=0xFFFFFF, x=4, y=10, scale=2 + ) + self._mode_label = label.Label( + terminalio.FONT, text="MODE", color=0xFFFFFF, x=150, y=10, scale=2 + ) + self._topbar = displayio.Group() + self._res_label = label.Label( + terminalio.FONT, text="", color=0xFFFFFF, x=0, y=10, scale=2 + ) + self._topbar.append(self._res_label) + self._topbar.append(self._sd_label) + + self._botbar = displayio.Group(x=0, y=210) + self._botbar.append(self._effect_label) + self._botbar.append(self._mode_label) + + self.splash.append(self._topbar) + self.splash.append(self._botbar) + def init_accelerometer(self): + """Initialize the accelerometer""" # lis3dh accelerometer self.accel = adafruit_lis3dh.LIS3DH_I2C(self._i2c, address=0x19) self.accel.range = adafruit_lis3dh.RANGE_2_G + def init_neopixel(self): + """Initialize the neopixels (onboard & ring)""" # main board neopixel neopix = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.1) neopix.fill(0) @@ -282,6 +289,17 @@ def make_debounced_expander_pin(pin_no): ) self.pixels.fill(0) + def init_camera(self, init_autofocus=True) -> None: + """Initialize the camera, by default including autofocus""" + print("reset camera") + self._cam_reset.switch_to_output(False) + self._cam_pwdn.switch_to_output(True) + time.sleep(0.01) + self._cam_pwdn.switch_to_output(False) + time.sleep(0.01) + self._cam_reset.switch_to_output(True) + time.sleep(0.01) + print("Initializing camera") self.camera = espcamera.Camera( data_pins=board.CAMERA_DATA, @@ -305,33 +323,12 @@ def make_debounced_expander_pin(pin_no): self.camera.address, ) ) - print("camera done @", time.monotonic() - self._timestamp) - print(dir(self.camera)) self._camera_device = I2CDevice(self._i2c, self.camera.address) - # display.auto_refresh = False self.camera.hmirror = False self.camera.vflip = True - self._bigbuf = None - - self._topbar = displayio.Group() - self._res_label = label.Label( - terminalio.FONT, text="", color=0xFFFFFF, x=0, y=10, scale=2 - ) - self._topbar.append(self._res_label) - self._topbar.append(self._sd_label) - - self._botbar = displayio.Group(x=0, y=210) - self._botbar.append(self._effect_label) - self._botbar.append(self._mode_label) - - self.splash.append(self._topbar) - self.splash.append(self._botbar) - self.display.root_group = self.splash - self.display.refresh() - self.led_color = 0 self.led_level = 0 @@ -340,6 +337,10 @@ def make_debounced_expander_pin(pin_no): self.camera.saturation = 3 self.resolution = microcontroller.nvm[_NVM_RESOLUTION] self.mode = microcontroller.nvm[_NVM_MODE] + + if init_autofocus: + self.autofocus_init() + print("init done @", time.monotonic() - self._timestamp) def autofocus_init_from_file(self, filename): @@ -526,7 +527,7 @@ def resolution(self, res): self._res_label.text = self.resolutions[res] self.display.refresh() - def init_display(self, reset=True): + def init_display(self): """Initialize the TFT display""" # construct displayio by hand displayio.release_displays() @@ -534,7 +535,7 @@ def init_display(self, reset=True): self._spi, command=board.TFT_DC, chip_select=board.TFT_CS, - reset=board.TFT_RESET if reset else None, + reset=None, baudrate=60_000_000, ) self.display = board.DISPLAY @@ -546,6 +547,7 @@ def init_display(self, reset=True): height=240, colstart=80, auto_refresh=False, + backlight_pin=board.TFT_BACKLIGHT, ) self.display.root_group = self.splash self.display.refresh() @@ -562,7 +564,7 @@ def display_message(self, message, color=0xFF0000, scale=3): text_area = label.Label(terminalio.FONT, text=message, color=color, scale=scale) text_area.anchor_point = (0.5, 0.5) if not self.display: - self.init_display(None) + self.init_display() text_area.anchored_position = (self.display.width / 2, self.display.height / 2) # Show it @@ -572,10 +574,11 @@ def display_message(self, message, color=0xFF0000, scale=3): def mount_sd_card(self): """Attempt to mount the SD card""" - self._sd_label.text = "NO SD" - self._sd_label.color = 0xFF0000 + if self._sd_label is not None: + self._sd_label.text = "NO SD" + self._sd_label.color = 0xFF0000 if not self.card_detect.value: - raise RuntimeError("SD card detection failed") + raise RuntimeError("No SD card inserted") if self.sdcard: self.sdcard.deinit() # depower SD card @@ -585,6 +588,7 @@ def mount_sd_card(self): # deinit display and SPI bus because we need to drive all SD pins LOW # to ensure nothing, not even an I/O pin, could possibly power the SD # card + had_display = self.display is not None self.deinit_display() self._spi.deinit() sckpin = DigitalInOut(board.SCK) @@ -604,14 +608,18 @@ def mount_sd_card(self): self._card_power.value = False card_cs.deinit() print("sdcard init @", time.monotonic() - self._timestamp) - self.sdcard = sdcardio.SDCard(self._spi, board.CARD_CS, baudrate=20_000_000) - vfs = storage.VfsFat(self.sdcard) - print("mount vfs @", time.monotonic() - self._timestamp) - storage.mount(vfs, "/sd") - self.init_display(None) - self._image_counter = 0 - self._sd_label.text = "SD OK" - self._sd_label.color = 0x00FF00 + try: + self.sdcard = sdcardio.SDCard(self._spi, board.CARD_CS, baudrate=20_000_000) + vfs = storage.VfsFat(self.sdcard) + print("mount vfs @", time.monotonic() - self._timestamp) + storage.mount(vfs, "/sd") + self._image_counter = 0 + if self._sd_label is not None: + self._sd_label.text = "SD OK" + self._sd_label.color = 0x00FF00 + finally: + if had_display: + self.init_display() def unmount_sd_card(self): """Unmount the SD card, if mounted""" @@ -619,8 +627,9 @@ def unmount_sd_card(self): storage.umount("/sd") except OSError: pass - self._sd_label.text = "NO SD" - self._sd_label.color = 0xFF0000 + if self._sd_label is not None: + self._sd_label.text = "NO SD" + self._sd_label.color = 0xFF0000 def keys_debounce(self): """Debounce all keys. @@ -775,3 +784,21 @@ def led_color(self, new_color): self.pixels.fill(colors) else: self.pixels[:] = colors + + +class PyCamera(PyCameraBase): + """Wrapper class for the PyCamera hardware""" + + def __init__(self, init_autofocus=True): + super().__init__() + + self.make_camera_ui() + self.init_accelerometer() + self.init_neopixel() + self.init_display() + self.init_camera(init_autofocus) + try: + self.mount_sd_card() + except Exception as exc: # pylint: disable=broad-exception-caught + # No SD card inserted, it's OK + print(exc) diff --git a/examples/camera/code.py b/examples/camera/code.py index de10475..ddb0d5e 100644 --- a/examples/camera/code.py +++ b/examples/camera/code.py @@ -12,7 +12,6 @@ import adafruit_pycamera pycam = adafruit_pycamera.PyCamera() -pycam.autofocus_init() # pycam.live_preview_mode() settings = (None, "resolution", "effect", "mode", "led_level", "led_color") diff --git a/examples/ipcam2/code.py b/examples/ipcam2/code.py index ce3c2ce..9d93434 100644 --- a/examples/ipcam2/code.py +++ b/examples/ipcam2/code.py @@ -29,7 +29,6 @@ supervisor.runtime.autoreload = False pycam = adafruit_pycamera.PyCamera() -pycam.autofocus_init() if wifi.radio.ipv4_address: # use alt port if web workflow enabled From c3a682adfca7c93ddbcf3a3498f048a7474bec42 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 11:17:22 -0600 Subject: [PATCH 2/7] Speed up autofocus init The firmware can be written in 254-byte chunks, and it's much speedier. --- adafruit_pycamera/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index 04a2763..64bc601 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -384,9 +384,19 @@ def autofocus_init_from_bitstream(self, firmware: bytes): raise RuntimeError(f"Autofocus not supported on {self.camera.sensor_name}") self.write_camera_register(0x3000, 0x20) # reset autofocus coprocessor + time.sleep(0.01) - for addr, val in enumerate(firmware): - self.write_camera_register(0x8000 + addr, val) + arr = bytearray(256) + with self._camera_device as i2c: + for offset in range(0, len(firmware), 254): + num_firmware_bytes = min(254, len(firmware) - offset) + reg = offset + 0x8000 + arr[0] = reg >> 8 + arr[1] = reg & 0xFF + arr[2 : 2 + num_firmware_bytes] = firmware[ + offset : offset + num_firmware_bytes + ] + i2c.write(arr, end=2 + num_firmware_bytes) self.write_camera_list(self._finalize_firmware_load) for _ in range(100): From 512efd86e1abe8d93656771422202c0a61e9400e Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 12:08:27 -0600 Subject: [PATCH 3/7] Ensure the splash group always exists so we can call pycam.display_message even without the rest of the camera ui --- adafruit_pycamera/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index 64bc601..1b91fbc 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -198,7 +198,7 @@ def __init__(self) -> None: # pylint: disable=too-many-statements self.display = None self.pixels = None self.sdcard = None - self.splash = None + self.splash = displayio.Group() # Reset display and I/O expander self._tft_aw_reset = DigitalInOut(board.TFT_RESET) @@ -246,7 +246,6 @@ def make_debounced_expander_pin(pin_no): def make_camera_ui(self): """Create displayio widgets for the standard camera UI""" - self.splash = displayio.Group() self._sd_label = label.Label( terminalio.FONT, text="SD ??", color=0x0, x=150, y=10, scale=2 ) From 82d23355fdc6ffc787e182c0ad34289dda083d99 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 12:08:54 -0600 Subject: [PATCH 4/7] Allow setting X & Y offsets for the direct bitmap blit this is useful for apps that don't have the top/bottom area reserved --- adafruit_pycamera/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index 1b91fbc..4f85208 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -751,7 +751,7 @@ def continuous_capture(self): or the camera's capture mode is changed""" return self.camera.take(1) - def blit(self, bitmap): + def blit(self, bitmap, x_offset=0, y_offset=32): """Display a bitmap direct to the LCD, bypassing displayio This can be more efficient than displaying a bitmap as a displayio @@ -762,8 +762,12 @@ def blit(self, bitmap): for status information. """ - self._display_bus.send(42, struct.pack(">hh", 80, 80 + bitmap.width - 1)) - self._display_bus.send(43, struct.pack(">hh", 32, 32 + bitmap.height - 1)) + self._display_bus.send( + 42, struct.pack(">hh", 80 + x_offset, 80 + x_offset + bitmap.width - 1) + ) + self._display_bus.send( + 43, struct.pack(">hh", y_offset, y_offset + bitmap.height - 1) + ) self._display_bus.send(44, bitmap) @property From 6da6447a899f97e95d456738f7a1855cecd02dac Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 12:20:00 -0600 Subject: [PATCH 5/7] Add an image viewer application --- examples/viewer/code.py | 153 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 examples/viewer/code.py diff --git a/examples/viewer/code.py b/examples/viewer/code.py new file mode 100644 index 0000000..51abc6b --- /dev/null +++ b/examples/viewer/code.py @@ -0,0 +1,153 @@ +# 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. + +Press up or down to move by +- 10 images. +Press left or right to move by +- 1 image. + +Otherwise, images cycle every DISPLAY_INTERVAL milliseconds (default 8000 = 8 seconds) +""" + +import time +import os +import displayio +from jpegio import JpegDecoder +from adafruit_ticks import ticks_less, ticks_ms, ticks_add, ticks_diff +from adafruit_pycamera import PyCameraBase + +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(): + image_counter = 0 + last_image_counter = 0 + deadline = ticks_ms() + all_images = mount_sd() + + bitmap = displayio.Bitmap(pycam.display.width, pycam.display.height, 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() + image_counter = 0 + deadline = now + + if all_images: + if pycam.up.fell: + image_counter = (last_image_counter - 10) % len(all_images) + deadline = now + + if pycam.down.fell: + image_counter = (last_image_counter + 10) % len(all_images) + deadline = now + + if pycam.left.fell: + image_counter = (last_image_counter - 1) % len(all_images) + deadline = now + + if pycam.right.fell: + image_counter = (last_image_counter + 1) % len(all_images) + deadline = now + + if ticks_less(deadline, now): + print(now, deadline, ticks_less(deadline, now), all_images) + deadline = ticks_add(deadline, DISPLAY_INTERVAL) + filename = all_images[image_counter] + last_image_counter = image_counter + image_counter = (image_counter + 1) % len(all_images) + 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, 500) + pycam.blit(bitmap, y_offset=0) + + +main() From 63bef1689ed14269707863333f79c65d2265e2cd Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 13:07:41 -0600 Subject: [PATCH 6/7] Avoid deprecation warnings --- adafruit_pycamera/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index 4f85208..89bb283 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -17,6 +17,8 @@ import bitmaptools import board import displayio +import fourwire +import busdisplay import espcamera import microcontroller import neopixel @@ -540,16 +542,15 @@ def init_display(self): """Initialize the TFT display""" # construct displayio by hand displayio.release_displays() - self._display_bus = displayio.FourWire( + self._display_bus = fourwire.FourWire( self._spi, command=board.TFT_DC, chip_select=board.TFT_CS, reset=None, baudrate=60_000_000, ) - self.display = board.DISPLAY # init specially since we are going to write directly below - self.display = displayio.Display( + self.display = busdisplay.BusDisplay( self._display_bus, self._INIT_SEQUENCE, width=240, From 05dc862452fdb8113f0d91026eb46df7673256f8 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Thu, 4 Jan 2024 13:14:16 -0600 Subject: [PATCH 7/7] fix doc build --- docs/conf.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 03efd7f..66f51d2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,20 +28,22 @@ # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. autodoc_mock_imports = [ - "bitmaptools", "adafruit_aw9523", + "adafruit_debouncer", + "adafruit_display_text", "adafruit_lis3dh", + "bitmaptools", + "busdisplay", + "busio", + "digitalio", "displayio", "espcamera", + "fourwire", + "micropython", "neopixel", "sdcardio", "storage", "terminalio", - "adafruit_debouncer", - "adafruit_display_text", - "digitalio", - "busio", - "micropython", ] autodoc_preserve_defaults = True