diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..21c125c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +.py text eol=lf +.rst text eol=lf +.txt text eol=lf +.yaml text eol=lf +.toml text eol=lf +.license text eol=lf +.md text eol=lf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70ade69..ff19dde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,21 @@ -# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense repos: - - repo: https://github.com/python/black - rev: 23.3.0 - hooks: - - id: black - - repo: https://github.com/fsfe/reuse-tool - rev: v1.1.2 - hooks: - - id: reuse - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/pycqa/pylint - rev: v2.17.4 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 hooks: - - id: pylint - name: pylint (library code) - types: [python] - args: - - --disable=consider-using-f-string - exclude: "^(docs/|examples/|tests/|setup.py$)" - - id: pylint - name: pylint (example code) - description: Run pylint rules on "examples/*.py" files - types: [python] - files: "^examples/" - args: - - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code - - id: pylint - name: pylint (test code) - description: Run pylint rules on "tests/*.py" files - types: [python] - files: "^tests/" - args: - - --disable=missing-docstring,consider-using-f-string,duplicate-code + - id: ruff-format + - id: ruff + args: ["--fix"] + - repo: https://github.com/fsfe/reuse-tool + rev: v3.0.1 + hooks: + - id: reuse diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index f945e92..0000000 --- a/.pylintrc +++ /dev/null @@ -1,399 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - -# Add files or directories to the ignore-list. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the ignore-list. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins=pylint.extensions.no_self_use - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# disable=import-error,raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,deprecated-str-translate-call -disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,import-error,pointless-string-statement,unspecified-encoding - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -# notes=FIXME,XXX,TODO -notes=FIXME,XXX - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=board - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -# expected-line-ending-format= -expected-line-ending-format=LF - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=12 - - -[BASIC] - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class names -# class-rgx=[A-Z_][a-zA-Z0-9]+$ -class-rgx=[A-Z_][a-zA-Z0-9_]+$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -# good-names=i,j,k,ex,Run,_ -good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_ - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Maximum number of attributes for a class (see R0902). -# max-attributes=7 -max-attributes=11 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=builtins.Exception diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b79ec5b..ee38fa0 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,8 +8,11 @@ # Required version: 2 +sphinx: + configuration: docs/conf.py + build: - os: ubuntu-20.04 + os: ubuntu-lts-latest tools: python: "3" diff --git a/LICENSE b/LICENSE index 0a7b93e..00dd992 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) -Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +Copyright (c) 2023 Limor Fried for Adafruit Industries +Copyright (c) 2023 Jeff Epler for Adafruit Industries Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal 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/README.rst b/README.rst index af6a199..c674acc 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Introduction ============ -.. image:: https://readthedocs.org/projects/adafruit-circuitpython-pycamera/badge/?version=latest +.. image:: https://readthedocs.org/projects/circuitpython-pycamera/badge/?version=latest :target: https://docs.circuitpython.org/projects/pycamera/en/latest/ :alt: Documentation Status @@ -17,9 +17,9 @@ Introduction :alt: Build Status -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - :alt: Code Style: Black +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Code Style: Ruff Library for the Adafruit PyCamera diff --git a/README.rst.license b/README.rst.license index dd81d34..57bef7c 100644 --- a/README.rst.license +++ b/README.rst.license @@ -1,5 +1,5 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/adafruit_pycamera/__init__.py b/adafruit_pycamera/__init__.py index 3892a55..e6d63ac 100644 --- a/adafruit_pycamera/__init__.py +++ b/adafruit_pycamera/__init__.py @@ -1,8 +1,10 @@ # SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries # # SPDX-License-Identifier: MIT """Library for the Adafruit PyCamera with OV5640 autofocus module""" +import gc import os import struct import time @@ -16,8 +18,10 @@ import adafruit_lis3dh import bitmaptools import board +import busdisplay import displayio import espcamera +import fourwire import microcontroller import neopixel import pwmio @@ -46,6 +50,8 @@ _OV5640_CMD_TRIGGER_AUTOFOCUS = const(0x03) _OV5640_CMD_AUTO_AUTOFOCUS = const(0x04) _OV5640_CMD_RELEASE_FOCUS = const(0x08) +_OV5640_CMD_AF_SET_VCM_STEP = const(0x1A) +_OV5640_CMD_AF_GET_VCM_STEP = const(0x1B) _OV5640_CMD_MAIN = const(0x3022) _OV5640_CMD_ACK = const(0x3023) @@ -61,18 +67,23 @@ _AW_CARDDET = const(8) _AW_SDPWR = const(9) _AW_DOWN = const(15) -_AW_LEFT = const(14) +_AW_RIGHT = const(14) _AW_UP = const(13) -_AW_RIGHT = const(12) +_AW_LEFT = const(12) _AW_OK = const(11) _NVM_RESOLUTION = const(1) _NVM_EFFECT = const(2) _NVM_MODE = const(3) +_NVM_TIMELAPSE_RATE = const(4) +_NVM_TIMELAPSE_SUBMODE = const(5) -class PyCamera: # pylint: disable=too-many-instance-attributes,too-many-public-methods - """Wrapper class for the PyCamera hardware""" +class PyCameraBase: + """Base class for PyCamera hardware + + Wrapper class for the PyCamera hardware with lots of smarts + """ _finalize_firmware_load = ( 0x3022, @@ -138,7 +149,7 @@ class PyCamera: # pylint: disable=too-many-instance-attributes,too-many-public- espcamera.FrameSize.QVGA, # 320x240 # espcamera.FrameSize.CIF, # 400x296 # espcamera.FrameSize.HVGA, # 480x320 - espcamera.FrameSize.VGA, # 640x480 + espcamera.FrameSize.VGA, # 640x480 espcamera.FrameSize.SVGA, # 800x600 espcamera.FrameSize.XGA, # 1024x768 espcamera.FrameSize.HD, # 1280x720 @@ -164,71 +175,84 @@ class PyCamera: # pylint: disable=too-many-instance-attributes,too-many-public- "Sepia", "Solarize", ) - modes = ("JPEG", "GIF", "GBOY", "STOP") + + timelapse_rates = ( + 5, + 10, + 20, + 30, + 60, + 90, + 60 * 2, + 60 * 3, + 60 * 4, + 60 * 5, + 60 * 10, + 60 * 15, + 60 * 30, + 60 * 60, + ) + + timelapse_submodes = ("HiPwr", "MedPwr", "LowPwr") + + modes = ("JPEG", "GIF", "GBOY", "STOP", "LAPS") _INIT_SEQUENCE = ( b"\x01\x80\x78" # _SWRESET and Delay 120ms b"\x11\x80\x05" # _SLPOUT and Delay 5ms - b"\x3A\x01\x55" # _COLMOD + b"\x3a\x01\x55" # _COLMOD b"\x21\x00" # _INVON Hack b"\x13\x00" # _NORON - b"\x36\x01\xA0" # _MADCTL + b"\x36\x01\xa0" # _MADCTL 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() + def __init__(self) -> None: + displayio.release_displays() self._i2c = board.I2C() self._spi = board.SPI() - self.deinit_display() - + self._timestamp = time.monotonic() + self._bigbuf = None + self._botbar = None + self._timelapsebar = None + self.timelapse_rate_label = None + self._timelapsestatus = None + self.timelapsestatus_label = None + self.timelapse_submode_label = 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._last_saved_image_filename = None + self.decoder = None + self._overlay = None + self.overlay_transparency_color = None + self.overlay_bmp = None + self.combined_bmp = None + self.preview_scale = None + self.overlay_position = [None, None] + self.overlay_scale = 1.0 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 - ) - # 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") @@ -248,7 +272,7 @@ def make_debounced_expander_pin(pin_no): pin.switch_to_input() return Debouncer(make_expander_input(pin_no)) - self.up = make_debounced_expander_pin(_AW_UP) # pylint: disable=invalid-name + self.up = make_debounced_expander_pin(_AW_UP) self.left = make_debounced_expander_pin(_AW_LEFT) self.right = make_debounced_expander_pin(_AW_RIGHT) self.down = make_debounced_expander_pin(_AW_DOWN) @@ -260,28 +284,88 @@ 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) + self.check_for_update_needed() + def check_for_update_needed(self): + """Check whether CIRCUITPY is too big, indicating it was created + by a version of CircuitPython older than 9.0.0 beta 2. + If so display and print a message and hang. + """ + circuitpy_stat = os.statvfs("/") + # CIRCUITPY should be 960KB or so. >1MB is too large + # and indicates an older version of CircuitPython + # created the filesystem. + if circuitpy_stat[1] * circuitpy_stat[2] > 1000000: + message = """\ +CIRCUITPY problem. +Backup. Update CPy +to >= 9.0.0-beta.2. +Reformat: + import storage + storage.erase_ +filesystem() +See Learn Guide.""" + self.display_message(message, color=0xFFFFFF, scale=2, full_screen=True) + print(message) + while True: + pass + + def make_camera_ui(self): + """Create displayio widgets for the standard camera UI""" + self._sd_label = label.Label(terminalio.FONT, text="SD ??", color=0x0, x=170, 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=170, 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._timelapsebar = displayio.Group(x=0, y=180) + self.timelapse_submode_label = label.Label( + terminalio.FONT, text="SubM", color=0xFFFFFF, x=160, y=10, scale=2 + ) + self.timelapse_rate_label = label.Label( + terminalio.FONT, text="Time", color=0xFFFFFF, x=90, y=10, scale=2 + ) + self.timelapsestatus_label = label.Label( + terminalio.FONT, text="Status", color=0xFFFFFF, x=0, y=10, scale=2 + ) + self._timelapsebar.append(self.timelapse_rate_label) + self._timelapsebar.append(self.timelapsestatus_label) + self._timelapsebar.append(self.timelapse_submode_label) + + self.splash.append(self._topbar) + self.splash.append(self._botbar) + self.splash.append(self._timelapsebar) + + 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) neopix.deinit() # front bezel neopixels - self.pixels = neopixel.NeoPixel( - board.A1, 8, brightness=0.1, pixel_order=neopixel.RGBW - ) + self.pixels = neopixel.NeoPixel(board.A1, 8, brightness=0.1, pixel_order=neopixel.RGBW) self.pixels.fill(0) + def init_camera(self, init_autofocus=True) -> None: + """Initialize the camera, by default including autofocus""" + print("Initializing camera") self.camera = espcamera.Camera( data_pins=board.CAMERA_DATA, @@ -289,6 +373,8 @@ def make_debounced_expander_pin(pin_no): pixel_clock_pin=board.CAMERA_PCLK, vsync_pin=board.CAMERA_VSYNC, href_pin=board.CAMERA_HREF, + powerdown_pin=board.CAMERA_PWDN, + reset_pin=board.CAMERA_RESET, pixel_format=espcamera.PixelFormat.RGB565, frame_size=espcamera.FrameSize.HQVGA, i2c=board.I2C(), @@ -305,33 +391,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 +405,12 @@ def make_debounced_expander_pin(pin_no): self.camera.saturation = 3 self.resolution = microcontroller.nvm[_NVM_RESOLUTION] self.mode = microcontroller.nvm[_NVM_MODE] + self.timelapse_rate = microcontroller.nvm[_NVM_TIMELAPSE_RATE] + self.timelapse_submode = microcontroller.nvm[_NVM_TIMELAPSE_SUBMODE] + + if init_autofocus: + self.autofocus_init() + print("init done @", time.monotonic() - self._timestamp) def autofocus_init_from_file(self, filename): @@ -369,13 +440,13 @@ def write_camera_list(self, reg_list: Sequence[int]) -> None: def read_camera_register(self, reg: int) -> int: """Read a 1-byte camera register""" - b = bytearray(2) - b[0] = reg >> 8 - b[1] = reg & 0xFF + b_out = bytearray(2) + b_out[0] = reg >> 8 + b_out[1] = reg & 0xFF + b_in = bytearray(1) with self._camera_device as i2c: - i2c.write(b) - i2c.readinto(b, end=1) - return b[0] + i2c.write_then_readinto(b_out, b_in) + return b_in[0] def autofocus_init_from_bitstream(self, firmware: bytes): """Initialize the autofocus engine from a bytestring""" @@ -383,9 +454,17 @@ 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): @@ -398,9 +477,7 @@ def autofocus_init_from_bitstream(self, firmware: bytes): def autofocus_init(self): """Initialize the autofocus engine from ov5640_autofocus.bin""" if "/" in __file__: - binfile = ( - __file__.rsplit("/", 1)[0].rsplit(".", 1)[0] + "/ov5640_autofocus.bin" - ) + binfile = __file__.rsplit("/", 1)[0].rsplit(".", 1)[0] + "/ov5640_autofocus.bin" else: binfile = "ov5640_autofocus.bin" print(binfile) @@ -411,7 +488,7 @@ def autofocus_status(self): """Read the camera autofocus status register""" return self.read_camera_register(_OV5640_CMD_FW_STATUS) - def _send_autofocus_command(self, command, msg): # pylint: disable=unused-argument + def _send_autofocus_command(self, command, msg): self.write_camera_register(_OV5640_CMD_ACK, 0x01) # clear command ack self.write_camera_register(_OV5640_CMD_MAIN, command) # send command for _ in range(100): @@ -431,24 +508,41 @@ def autofocus(self) -> list[int]: return [False] * 5 if not self._send_autofocus_command(_OV5640_CMD_TRIGGER_AUTOFOCUS, "autofocus"): return [False] * 5 - zone_focus = [ - self.read_camera_register(_OV5640_CMD_PARA0 + i) for i in range(5) - ] + zone_focus = [self.read_camera_register(_OV5640_CMD_PARA0 + i) for i in range(5)] print(f"zones focused: {zone_focus}") return zone_focus + @property + def autofocus_vcm_step(self): + """Get the voice coil motor step location""" + if not self._send_autofocus_command(_OV5640_CMD_AF_GET_VCM_STEP, "get vcm step"): + return None + return self.read_camera_register(_OV5640_CMD_PARA4) + + @autofocus_vcm_step.setter + def autofocus_vcm_step(self, step): + """Get the voice coil motor step location, from 0 to 255""" + if not 0 <= step <= 255: + raise RuntimeError("VCM step must be 0 to 255") + self.write_camera_register(_OV5640_CMD_PARA3, 0x00) + self.write_camera_register(_OV5640_CMD_PARA4, step) + self._send_autofocus_command(_OV5640_CMD_AF_SET_VCM_STEP, "set vcm step") + def select_setting(self, setting_name): """For the point & shoot camera mode, control what setting is being set""" self._effect_label.color = 0xFFFFFF self._effect_label.background_color = 0x0 self._res_label.color = 0xFFFFFF self._res_label.background_color = 0x0 - if self.mode_text in ("GIF", "GBOY"): + if self.mode_text in {"GIF", "GBOY"}: self._res_label.text = "" else: self._res_label.text = self.resolutions[self._resolution] self._mode_label.color = 0xFFFFFF self._mode_label.background_color = 0x0 + self.timelapse_rate_label.color = 0xFFFFFF + self.timelapse_rate_label.background_color = None + if setting_name == "effect": self._effect_label.color = 0x0 self._effect_label.background_color = 0xFFFFFF @@ -466,6 +560,13 @@ def select_setting(self, setting_name): self._res_label.text = "LED CLR" self._res_label.color = 0x0 self._res_label.background_color = 0xFFFFFF + elif setting_name == "led_color": + self._res_label.text = "LED CLR" + self._res_label.color = 0x0 + self._res_label.background_color = 0xFFFFFF + elif setting_name == "timelapse_rate": + self.timelapse_rate_label.color = 0x0 + self.timelapse_rate_label.background_color = 0xFFFFFF self.display.refresh() @property @@ -485,7 +586,7 @@ def mode(self, setting): self._mode_label.text = self.modes[setting] if self.modes[setting] == "STOP": self.stop_motion_frame = 0 - if self.modes[setting] in ("GIF", "GBOY"): + if self.modes[setting] in {"GIF", "GBOY"}: self._res_label.text = "" else: self.resolution = self.resolution # kick it to reset the display @@ -524,28 +625,58 @@ def resolution(self, res): microcontroller.nvm[_NVM_RESOLUTION] = res self._resolution = res self._res_label.text = self.resolutions[res] + _width = int(self.resolutions[self.resolution].split("x")[0]) + self.preview_scale = 240 / _width self.display.refresh() - def init_display(self, reset=True): + @property + def timelapse_rate(self): + """Get or set the amount of time between timelapse shots""" + return self._timelapse_rate + + @timelapse_rate.setter + def timelapse_rate(self, setting): + setting = (setting + len(self.timelapse_rates)) % len(self.timelapse_rates) + self._timelapse_rate = setting + if self.timelapse_rates[setting] < 60: + self.timelapse_rate_label.text = "%d S" % self.timelapse_rates[setting] + else: + self.timelapse_rate_label.text = "%d M" % (self.timelapse_rates[setting] / 60) + microcontroller.nvm[_NVM_TIMELAPSE_RATE] = setting + self.display.refresh() + + @property + def timelapse_submode(self): + """Get or set the power mode for timelapsing""" + return self._timelapse_submode + + @timelapse_submode.setter + def timelapse_submode(self, setting): + setting = (setting + len(self.timelapse_submodes)) % len(self.timelapse_submodes) + self._timelapse_submode = setting + self.timelapse_submode_label.text = self.timelapse_submodes[self._timelapse_submode] + microcontroller.nvm[_NVM_TIMELAPSE_SUBMODE] = setting + + 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=board.TFT_RESET if reset else None, + 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, height=240, colstart=80, auto_refresh=False, + backlight_pin=board.TFT_BACKLIGHT, ) self.display.root_group = self.splash self.display.refresh() @@ -557,13 +688,15 @@ def deinit_display(self): self._display_bus = None self.display = None - def display_message(self, message, color=0xFF0000, scale=3): + def display_message(self, message, color=0xFF0000, scale=3, full_screen=False): """Display a message on the TFT""" text_area = label.Label(terminalio.FONT, text=message, color=color, scale=scale) - text_area.anchor_point = (0.5, 0.5) + text_area.anchor_point = (0, 0) if full_screen else (0.5, 0.5) if not self.display: - self.init_display(None) - text_area.anchored_position = (self.display.width / 2, self.display.height / 2) + self.init_display() + text_area.anchored_position = ( + (0, 0) if full_screen else (self.display.width / 2, self.display.height / 2) + ) # Show it self.splash.append(text_area) @@ -572,10 +705,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 +719,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 +739,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 +758,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. @@ -639,9 +779,7 @@ def keys_debounce(self): def tone(self, frequency, duration=0.1): """Play a tone on the internal speaker""" - with pwmio.PWMOut( - board.SPEAKER, frequency=int(frequency), variable_frequency=False - ) as pwm: + with pwmio.PWMOut(board.SPEAKER, frequency=int(frequency), variable_frequency=False) as pwm: self.mute.value = True pwm.duty_cycle = 0x8000 time.sleep(duration) @@ -656,23 +794,24 @@ def live_preview_mode(self): # self.effect = self._effect self.continuous_capture_start() - def open_next_image(self, extension="jpg"): + def open_next_image(self, extension="jpg", filename_prefix="img"): """Return an opened numbered file on the sdcard, such as "img01234.jpg".""" try: os.stat("/sd") except OSError as exc: # no SD card! raise RuntimeError("No SD card mounted") from exc while True: - filename = "/sd/img%04d.%s" % (self._image_counter, extension) + filename = f"/sd/{filename_prefix}{self._image_counter:04d}.{extension}" self._image_counter += 1 try: os.stat(filename) except OSError: break + self._last_saved_image_filename = filename print("Writing to", filename) return open(filename, "wb") - def capture_jpeg(self): + def capture_jpeg(self, filename_prefix="img"): """Capture a jpeg file and save it to the SD card""" try: os.stat("/sd") @@ -690,7 +829,7 @@ def capture_jpeg(self): print(f"Captured {len(jpeg)} bytes of jpeg data") print("Resolution %d x %d" % (self.camera.width, self.camera.height)) - with self.open_next_image() as dest: + with self.open_next_image(filename_prefix=filename_prefix) as dest: chunksize = 16384 for offset in range(0, len(jpeg), chunksize): dest.write(jpeg[offset : offset + chunksize]) @@ -699,9 +838,115 @@ def capture_jpeg(self): else: print("# frame capture failed") + @property + def overlay(self) -> str: + """ + The overlay file to be used. A filepath string that points + to a .bmp file that has 24bit RGB888 Colorspace. + The overlay image will be shown in the camera preview, + and combined to create a modified version of the + final photo. + """ + return self._overlay + + @overlay.setter + def overlay(self, new_overlay_file: str) -> None: + import adafruit_imageload + import ulab.numpy as np + from displayio import ColorConverter, Colorspace + + if self.overlay_bmp is not None: + self.overlay_bmp.deinit() + self._overlay = new_overlay_file + cc888 = ColorConverter(input_colorspace=Colorspace.RGB888) + self.overlay_bmp, _ = adafruit_imageload.load(new_overlay_file, palette=cc888) + + arr = np.frombuffer(self.overlay_bmp, dtype=np.uint16) + arr.byteswap(inplace=True) + + del arr + + def _init_jpeg_decoder(self): + from jpegio import JpegDecoder + + """ + Initialize the JpegDecoder if it hasn't been already. + Only needed if overlay is used. + """ + if self.decoder is None: + self.decoder = JpegDecoder() + + def blit_overlay_into_last_capture(self): + """ + Create a modified version of the last photo taken that pastes + the overlay image on top of the photo and saves the new version + in a separate but similarly named .bmp file on the SDCard. + """ + if self.overlay_bmp is None: + raise ValueError("Must set overlay before calling blit_overlay_into_last_capture") + from adafruit_bitmapsaver import save_pixels + from displayio import Bitmap, ColorConverter, Colorspace + + self._init_jpeg_decoder() + + width, height = self.decoder.open(self._last_saved_image_filename) + photo_bitmap = Bitmap(width, height, 65535) + + self.decoder.decode(photo_bitmap, scale=0, x=0, y=0) + + bitmaptools.rotozoom( + photo_bitmap, + self.overlay_bmp, + ox=self.overlay_position[0] if self.overlay_position[0] is not None else 0, + oy=self.overlay_position[1] if self.overlay_position[1] is not None else 0, + px=0 if self.overlay_position[0] is not None else None, + py=0 if self.overlay_position[1] is not None else None, + skip_index=self.overlay_transparency_color, + scale=self.overlay_scale, + ) + + cc565_swapped = ColorConverter(input_colorspace=Colorspace.RGB565_SWAPPED) + save_pixels( + self._last_saved_image_filename.replace(".jpg", "_modified.bmp"), + photo_bitmap, + cc565_swapped, + ) + + # RAM cleanup + photo_bitmap.deinit() + del photo_bitmap + del cc565_swapped + gc.collect() + + @property + def last_saved_filename(self) -> str: + """ + The filename of the last image saved. + """ + return self._last_saved_image_filename + def continuous_capture_start(self): """Switch the camera to continuous-capture mode""" - pass # pylint: disable=unnecessary-pass + pass + + def capture_into_jpeg(self): + """Captures an image and returns it in JPEG format. + + Returns: + bytes: The captured image in JPEG format, otherwise None if the capture failed. + """ + self.camera.reconfigure( + pixel_format=espcamera.PixelFormat.JPEG, + frame_size=self.resolution_to_frame_size[self._resolution], + ) + time.sleep(0.1) + jpeg = self.camera.take(1) + if jpeg is not None: + print(f"Captured {len(jpeg)} bytes of jpeg data") + print("Resolution %d x %d" % (self.camera.width, self.camera.height)) + else: + print("JPEG capture failed") + return jpeg def capture_into_bitmap(self, bitmap): """Capture an image and blit it into the given bitmap""" @@ -714,7 +959,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 @@ -724,9 +969,34 @@ def blit(self, bitmap): The default preview capture is 240x176, leaving 32 pixel rows at the top and bottom for status information. """ + from displayio import Bitmap + + if self.overlay_bmp is not None: + if self.combined_bmp is None: + self.combined_bmp = Bitmap(bitmap.width, bitmap.height, 65535) + + bitmaptools.blit(self.combined_bmp, bitmap, 0, 0) + + bitmaptools.rotozoom( + self.combined_bmp, + self.overlay_bmp, + scale=self.preview_scale * self.overlay_scale, + skip_index=self.overlay_transparency_color, + ox=int(self.overlay_position[0] * self.preview_scale) + if self.overlay_position[0] is not None + else None, + oy=int(self.overlay_position[1] * self.preview_scale) + if self.overlay_position[1] is not None + else None, + px=0 if self.overlay_position[0] is not None else None, + py=0 if self.overlay_position[1] is not None else None, + ) + bitmap = self.combined_bmp - 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 @@ -756,3 +1026,95 @@ def led_color(self, new_color): self.pixels.fill(colors) else: self.pixels[:] = colors + + def get_camera_autosettings(self): + """Collect all the settings related to exposure and white balance""" + exposure = ( + (self.read_camera_register(0x3500) << 12) + + (self.read_camera_register(0x3501) << 4) + + (self.read_camera_register(0x3502) >> 4) + ) + white_balance = [ + self.read_camera_register(x) for x in (0x3400, 0x3401, 0x3402, 0x3403, 0x3404, 0x3405) + ] + + settings = { + "gain": self.read_camera_register(0x350B), + "exposure": exposure, + "wb": white_balance, + } + return settings + + def set_camera_wb(self, wb_register_values=None): + """Set the camera white balance. + + The argument of `None` selects auto white balance, while + a list of 6 numbers sets a specific white balance. + + The numbers can come from the datasheet or from + ``get_camera_autosettings()["wb"]``.""" + if wb_register_values is None: + # just set to auto balance + self.camera.whitebal = True + return + + if len(wb_register_values) != 6: + raise RuntimeError("Please pass in 0x3400~0x3405 inclusive!") + + self.write_camera_register(0x3212, 0x03) + self.write_camera_register(0x3406, 0x01) + for i, reg_val in enumerate(wb_register_values): + self.write_camera_register(0x3400 + i, reg_val) + self.write_camera_register(0x3212, 0x13) + self.write_camera_register(0x3212, 0xA3) + + def set_camera_exposure(self, new_exposure=None): + """Set the camera's exposure values + + The argument of `None` selects auto exposure. + + Otherwise, the new exposure data should come from + ``get_camera_autosettings()["exposure"]``.""" + if new_exposure is None: + # just set auto expose + self.camera.exposure_ctrl = True + return + self.camera.exposure_ctrl = False + + self.write_camera_register(0x3500, (new_exposure >> 12) & 0xFF) + self.write_camera_register(0x3501, (new_exposure >> 4) & 0xFF) + self.write_camera_register(0x3502, (new_exposure << 4) & 0xFF) + + def set_camera_gain(self, new_gain=None): + """Set the camera's exposure values + + The argument of `None` selects auto gain control. + + Otherwise, the new exposure data should come from + ``get_camera_autosettings()["gain"]``.""" + if new_gain is None: + # just set auto expose + self.camera.gain_ctrl = True + return + + self.camera.gain_ctrl = False + self.write_camera_register(0x350B, new_gain) + + +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: + # No SD card inserted, it's OK + print(exc) diff --git a/adafruit_pycamera/imageprocessing.py b/adafruit_pycamera/imageprocessing.py new file mode 100644 index 0000000..f218f08 --- /dev/null +++ b/adafruit_pycamera/imageprocessing.py @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""Routines for performing image manipulation""" + +import bitmapfilter + +from adafruit_pycamera.ironbow import ironbow_palette + +sepia_weights = bitmapfilter.ChannelMixer( + 0.393, 0.769, 0.189, 0.349, 0.686, 0.168, 0.272, 0.534, 0.131 +) + + +def sepia(bitmap, mask=None): + """Apply a sepia filter to an image in place""" + bitmapfilter.mix(bitmap, sepia_weights, mask=mask) + return bitmap + + +negative_weights = bitmapfilter.ChannelScaleOffset(-1, 1, -1, 1, -1, 1) + + +def negative(bitmap, mask=None): + """Invert an image""" + bitmapfilter.mix(bitmap, negative_weights, mask=mask) + return bitmap + + +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, mask=None): + """Convert an image to greyscale""" + bitmapfilter.mix(bitmap, greyscale_weights, mask=mask) + return bitmap + + +def red_cast(bitmap, mask=None): + """Give an image a red cast by dividing G and B channels in half""" + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(1, 0.5, 0.5), mask=mask) + return bitmap + + +def green_cast(bitmap, mask=None): + """Give an image a green cast by dividing R and B channels in half""" + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(0.5, 1, 0.5), mask=mask) + return bitmap + + +def blue_cast(bitmap, mask=None): + """Give an image a blue cast by dividing R and G channels in half""" + bitmapfilter.mix(bitmap, bitmapfilter.ChannelScale(0.5, 0.5, 1), mask=mask) + return bitmap + + +def blur(bitmap, mask=None): + """Blur a bitmap""" + bitmapfilter.morph(bitmap, (1, 2, 1, 2, 4, 2, 1, 2, 1), mask=mask) + return bitmap + + +def sharpen(bitmap, mask=None): + """Sharpen a bitmap""" + bitmapfilter.morph(bitmap, (-1, -2, -1, -2, 13, -2, -1, -2, -1), mask=mask) + return bitmap + + +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 emboss_greyscale(bitmap, mask=None): + """Run an emboss filter on the bitmap in greyscale""" + greyscale(bitmap, mask=mask) + return emboss(bitmap, mask=mask) + + +def ironbow(bitmap, mask=None): + """Convert an image to false color using the 'ironbow palette'""" + return bitmapfilter.false_color(bitmap, ironbow_palette, mask=mask) + + +def alphablend_maker(frac, nfrac=None): + """Create an alpha-blending function for a specific fractional value + + The resulting function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``. + """ + if nfrac is None: + nfrac = 1 - frac + + def inner(a, b): + return frac * a + nfrac * b + + return inner + + +def screen_func(a, b): + """The 'screen' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return 1 - (1 - a) * (1 - b) + + +def overlay_func(a, b): + """The 'overlay' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return 2 * a * b if a < 0.5 else 1 - 2 * (1 - a) * (1 - b) + + +def hard_light_func(a, b): + """The 'hard light' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return 2 * a * b if b < 0.5 else 1 - 2 * (1 - a) * (1 - b) + + +# illusions.hu formula version +def soft_light_func(a, b): + """The 'soft light' blend mode. + + There are various soft light blend functions. The "illusions.hu" variant of + soft light is used. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a ** (2 ** (2 * 0.5 - b)) + + +def color_dodge_func(a, b): + """The 'color dodge' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a / (1 - b) if b != 1 else 1 + + +def linear_dodge_func(a, b): + """The 'linear dodge' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a + b + + +def divide_func(a, b): + """The 'divide' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a / b if b else 1 + + +def multiply_func(a, b): + """The 'multiply' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a * b + + +def subtract_func(a, b): + """The 'subtract' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a - b + + +def color_burn_func(a, b): + """The 'color burn' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a * (1 - b) + + +def linear_burn_func(a, b): + """The 'linear burn' blend mode. + + This function can be used with ``bitmapfilter.blend`` and + ``bitmapfilter.blend_precompute``.""" + return a + b - 1 + + +darken_only_func = min +"""The 'darken only' blend mode. + +This function can be used with ``bitmapfilter.blend`` and +``bitmapfilter.blend_precompute``.""" +lighten_only_func = max +"""The 'screen' blend mode. + +This function can be used with ``bitmapfilter.blend`` and +``bitmapfilter.blend_precompute``.""" diff --git a/adafruit_pycamera/ironbow.py b/adafruit_pycamera/ironbow.py new file mode 100644 index 0000000..11ce719 --- /dev/null +++ b/adafruit_pycamera/ironbow.py @@ -0,0 +1,51 @@ +# 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) +"""A palette often used to convert images to false color""" + +# 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/docs/api.rst b/docs/api.rst index 811e43d..ac61409 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,5 +4,12 @@ .. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py) .. use this format as the module name: "adafruit_foo.foo" +API Reference +############# + .. automodule:: adafruit_pycamera :members: +.. automodule:: adafruit_pycamera.imageprocessing + :members: +.. automodule:: adafruit_pycamera.ironbow + :members: diff --git a/docs/api.rst.license b/docs/api.rst.license index 3b17a1d..57bef7c 100644 --- a/docs/api.rst.license +++ b/docs/api.rst.license @@ -1,4 +1,5 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/docs/conf.py b/docs/conf.py index 03efd7f..2ea50a3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries # # SPDX-License-Identifier: MIT @@ -9,6 +7,7 @@ import sys sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath("mock")) # -- General configuration ------------------------------------------------ @@ -28,20 +27,25 @@ # 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", - "displayio", + "adafruit_ticks", + "bitmaptools", + "bitmapfilter", + "busdisplay", + "busio", + "digitalio", "espcamera", + "fourwire", + "jpegio", + "micropython", "neopixel", "sdcardio", "storage", "terminalio", - "adafruit_debouncer", - "adafruit_display_text", - "digitalio", - "busio", - "micropython", + "ulab", ] autodoc_preserve_defaults = True @@ -69,9 +73,7 @@ creation_year = "2023" current_year = str(datetime.datetime.now().year) year_duration = ( - current_year - if current_year == creation_year - else creation_year + " - " + current_year + current_year if current_year == creation_year else creation_year + " - " + current_year ) copyright = year_duration + " Jeff Epler for Adafruit Industries" author = "Jeff Epler for Adafruit Industries" @@ -131,7 +133,6 @@ import sphinx_rtd_theme html_theme = "sphinx_rtd_theme" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/examples.rst.license b/docs/examples.rst.license index 3b17a1d..57bef7c 100644 --- a/docs/examples.rst.license +++ b/docs/examples.rst.license @@ -1,4 +1,5 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/docs/index.rst.license b/docs/index.rst.license index 3b17a1d..57bef7c 100644 --- a/docs/index.rst.license +++ b/docs/index.rst.license @@ -1,4 +1,5 @@ SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries SPDX-License-Identifier: MIT diff --git a/docs/mock/displayio.py b/docs/mock/displayio.py new file mode 100644 index 0000000..f18251d --- /dev/null +++ b/docs/mock/displayio.py @@ -0,0 +1,32 @@ +# 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 + + +class ColorConverter: + def __init__(self, colorspace): + self._colorspace = colorspace + + def convert(self, color_value) -> int: + pass + + +class Bitmap: + def __init__(self, width, height, color_count): + pass + + +class Colorspace: + pass + + +class Display: + pass diff --git a/examples/basic_camera/code.py b/examples/basic_camera/code.py new file mode 100644 index 0000000..ef2fd87 --- /dev/null +++ b/examples/basic_camera/code.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 john park for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""simple point-and-shoot camera example. No bells! Zero whistles!""" + +import time + +import adafruit_pycamera + +pycam = adafruit_pycamera.PyCamera() +pycam.mode = 0 # only mode 0 (JPEG) will work in this example + +# User settings - try changing these: +pycam.resolution = 8 # 0-12 preset resolutions: +# 0: 240x240, 1: 320x240, 2: 640x480, 3: 800x600, 4: 1024x768, +# 5: 1280x720, 6: 1280x1024, 7: 1600x1200, 8: 1920x1080, 9: 2048x1536, +# 10: 2560x1440, 11: 2560x1600, 12: 2560x1920 +pycam.led_level = 1 # 0-4 preset brightness levels +pycam.led_color = 0 # 0-7 preset colors: 0: white, 1: green, 2: yellow, 3: red, +# 4: pink, 5: blue, 6: teal, 7: rainbow +pycam.effect = 0 # 0-7 preset FX: 0: normal, 1: invert, 2: b&w, 3: red, +# 4: green, 5: blue, 6: sepia, 7: solarize + +print("Simple camera ready.") +pycam.tone(800, 0.1) +pycam.tone(1200, 0.05) + +while True: + pycam.blit(pycam.continuous_capture()) + pycam.keys_debounce() + + if pycam.shutter.short_count: + print("Shutter released") + pycam.tone(1200, 0.05) + pycam.tone(1600, 0.05) + try: + pycam.display_message("snap", color=0x00DD00) + pycam.capture_jpeg() + pycam.live_preview_mode() + except TypeError: + pycam.display_message("Failed", color=0xFF0000) + time.sleep(0.5) + pycam.live_preview_mode() + except RuntimeError: + pycam.display_message("Error\nNo SD Card", color=0xFF0000) + time.sleep(0.5) + + if pycam.card_detect.fell: + print("SD card removed") + pycam.unmount_sd_card() + pycam.display.refresh() + + if pycam.card_detect.rose: + print("SD card inserted") + 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 exception: + print("Retrying!", exception) + time.sleep(0.5) + else: + pycam.display_message("SD Card\nFailed!", color=0xFF0000) + time.sleep(0.5) + pycam.display.refresh() diff --git a/examples/camera/boot.py b/examples/camera/boot.py new file mode 100644 index 0000000..4106fc6 --- /dev/null +++ b/examples/camera/boot.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Automatically create the /sd mount point at boot time""" + +import os + +import storage + +storage.remount("/", readonly=False) + +try: + os.mkdir("/sd") +except OSError: + pass # It's probably 'file exists', OK to ignore + +storage.remount("/", readonly=True) diff --git a/examples/camera/code.py b/examples/camera/code.py index de10475..8c89f35 100644 --- a/examples/camera/code.py +++ b/examples/camera/code.py @@ -1,27 +1,80 @@ # SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Limor Fried for Adafruit Industries # # SPDX-License-Identifier: Unlicense - +import os +import ssl import time +import adafruit_ntp +import adafruit_requests import bitmaptools import displayio import gifio +import rtc +import socketpool import ulab.numpy as np +import wifi import adafruit_pycamera +# Wifi details are in settings.toml file, also, +# timezone info should be included to allow local time and DST adjustments +# # UTC_OFFSET, if present, will override TZ and DST and no API query will be done +# UTC_OFFSET=-25200 +# # TZ="America/Phoenix" + +UTC_OFFSET = os.getenv("UTC_OFFSET") +TZ = os.getenv("TZ") + +print(f"Connecting to {os.getenv('CIRCUITPY_WIFI_SSID')}") +SSID = os.getenv("CIRCUITPY_WIFI_SSID") +PASSWORD = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +if SSID and PASSWORD: + wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) + if wifi.radio.connected: + print(f"Connected to {os.getenv('CIRCUITPY_WIFI_SSID')}!") + print("My IP address is", wifi.radio.ipv4_address) + pool = socketpool.SocketPool(wifi.radio) + + if UTC_OFFSET is None: + requests = adafruit_requests.Session(pool, ssl.create_default_context()) + response = requests.get("http://worldtimeapi.org/api/timezone/" + TZ) + response_as_json = response.json() + UTC_OFFSET = response_as_json["raw_offset"] + response_as_json["dst_offset"] + print(f"UTC_OFFSET: {UTC_OFFSET}") + + ntp = adafruit_ntp.NTP(pool, server="pool.ntp.org", tz_offset=UTC_OFFSET // 3600) + + print(f"ntp time: {ntp.datetime}") + rtc.RTC().datetime = ntp.datetime + else: + print("Wifi failed to connect. Time not set.") +else: + print("Wifi config not found in settintgs.toml. Time not set.") + pycam = adafruit_pycamera.PyCamera() -pycam.autofocus_init() # pycam.live_preview_mode() -settings = (None, "resolution", "effect", "mode", "led_level", "led_color") +settings = ( + None, + "resolution", + "effect", + "mode", + "led_level", + "led_color", + "timelapse_rate", +) curr_setting = 0 print("Starting!") # pycam.tone(200, 0.1) last_frame = displayio.Bitmap(pycam.camera.width, pycam.camera.height, 65535) onionskin = displayio.Bitmap(pycam.camera.width, pycam.camera.height, 65535) +timelapse_remaining = None +timelapse_timestamp = None + while True: if pycam.mode_text == "STOP" and pycam.stop_motion_frame != 0: # alpha blend @@ -35,6 +88,43 @@ last_frame, pycam.continuous_capture(), displayio.Colorspace.RGB565_SWAPPED ) pycam.blit(last_frame) + elif pycam.mode_text == "LAPS": + if timelapse_remaining is None: + pycam.timelapsestatus_label.text = "STOP" + else: + timelapse_remaining = timelapse_timestamp - time.time() + pycam.timelapsestatus_label.text = f"{timelapse_remaining}s / " + # Manually updating the label text a second time ensures that the label + # is re-painted over the blitted preview. + pycam.timelapse_rate_label.text = pycam.timelapse_rate_label.text + pycam.timelapse_submode_label.text = pycam.timelapse_submode_label.text + + # only in high power mode do we continuously preview + if (timelapse_remaining is None) or (pycam.timelapse_submode_label.text == "HiPwr"): + pycam.blit(pycam.continuous_capture()) + if pycam.timelapse_submode_label.text == "LowPwr" and (timelapse_remaining is not None): + pycam.display.brightness = 0.05 + else: + pycam.display.brightness = 1 + pycam.display.refresh() + + if timelapse_remaining is not None and timelapse_remaining <= 0: + # no matter what, show what was just on the camera + pycam.blit(pycam.continuous_capture()) + # pycam.tone(200, 0.1) # uncomment to add a beep when a photo is taken + try: + pycam.display_message("Snap!", color=0x0000FF) + pycam.capture_jpeg() + except TypeError: + pycam.display_message("Failed", color=0xFF0000) + time.sleep(0.5) + except RuntimeError: + pycam.display_message("Error\nNo SD Card", color=0xFF0000) + time.sleep(0.5) + pycam.live_preview_mode() + pycam.display.refresh() + pycam.blit(pycam.continuous_capture()) + timelapse_timestamp = time.time() + pycam.timelapse_rates[pycam.timelapse_rate] + 1 else: pycam.blit(pycam.continuous_capture()) # print("\t\t", capture_time, blit_time) @@ -54,10 +144,10 @@ try: pycam.display_message("Snap!", color=0x0000FF) pycam.capture_jpeg() - except TypeError as e: + except TypeError: pycam.display_message("Failed", color=0xFF0000) time.sleep(0.5) - except RuntimeError as e: + except RuntimeError: pycam.display_message("Error\nNo SD Card", color=0xFF0000) time.sleep(0.5) pycam.live_preview_mode() @@ -65,7 +155,7 @@ if pycam.mode_text == "GBOY": try: f = pycam.open_next_image("gif") - except RuntimeError as e: + except RuntimeError: pycam.display_message("Error\nNo SD Card", color=0xFF0000) time.sleep(0.5) continue @@ -82,13 +172,13 @@ if pycam.mode_text == "GIF": try: f = pycam.open_next_image("gif") - except RuntimeError as e: + except RuntimeError: pycam.display_message("Error\nNo SD Card", color=0xFF0000) time.sleep(0.5) continue i = 0 ft = [] - pycam._mode_label.text = "RECORDING" # pylint: disable=protected-access + pycam._mode_label.text = "RECORDING" pycam.display.refresh() with gifio.GifWriter( @@ -108,9 +198,9 @@ ft.append(1 / (t1 - t0)) print(end=".") t0 = t1 - pycam._mode_label.text = "GIF" # pylint: disable=protected-access + pycam._mode_label.text = "GIF" print(f"\nfinal size {f.tell()} for {i} frames") - print(f"average framerate {i/(t1-t00)}fps") + print(f"average framerate {i / (t1 - t00)}fps") print(f"best {max(ft)} worst {min(ft)} std. deviation {np.std(ft)}") f.close() pycam.display.refresh() @@ -121,13 +211,14 @@ pycam.display_message("Snap!", color=0x0000FF) pycam.capture_jpeg() pycam.live_preview_mode() - except TypeError as e: + except TypeError: pycam.display_message("Failed", color=0xFF0000) time.sleep(0.5) pycam.live_preview_mode() - except RuntimeError as e: + except RuntimeError: pycam.display_message("Error\nNo SD Card", color=0xFF0000) time.sleep(0.5) + if pycam.card_detect.fell: print("SD card removed") pycam.unmount_sd_card() @@ -153,27 +244,54 @@ print("UP") key = settings[curr_setting] if key: + print("getting", key, getattr(pycam, key)) setattr(pycam, key, getattr(pycam, key) + 1) if pycam.down.fell: print("DN") key = settings[curr_setting] if key: setattr(pycam, key, getattr(pycam, key) - 1) - if pycam.left.fell: - print("LF") + if pycam.right.fell: + print("RT") curr_setting = (curr_setting + 1) % len(settings) + if pycam.mode_text != "LAPS" and settings[curr_setting] == "timelapse_rate": + curr_setting = (curr_setting + 1) % len(settings) print(settings[curr_setting]) # new_res = min(len(pycam.resolutions)-1, pycam.get_resolution()+1) # pycam.set_resolution(pycam.resolutions[new_res]) pycam.select_setting(settings[curr_setting]) - if pycam.right.fell: - print("RT") + if pycam.left.fell: + print("LF") curr_setting = (curr_setting - 1 + len(settings)) % len(settings) + if pycam.mode_text != "LAPS" and settings[curr_setting] == "timelaps_rate": + curr_setting = (curr_setting + 1) % len(settings) print(settings[curr_setting]) pycam.select_setting(settings[curr_setting]) # new_res = max(1, pycam.get_resolution()-1) # pycam.set_resolution(pycam.resolutions[new_res]) if pycam.select.fell: print("SEL") + if pycam.mode_text == "LAPS": + pycam.timelapse_submode += 1 + pycam.display.refresh() if pycam.ok.fell: print("OK") + if pycam.mode_text == "LAPS": + if timelapse_remaining is None: # stopped + print("Starting timelapse") + timelapse_remaining = pycam.timelapse_rates[pycam.timelapse_rate] + timelapse_timestamp = time.time() + timelapse_remaining + 1 + # dont let the camera take over auto-settings + saved_settings = pycam.get_camera_autosettings() + # print(f"Current exposure {saved_settings=}") + pycam.set_camera_exposure(saved_settings["exposure"]) + pycam.set_camera_gain(saved_settings["gain"]) + pycam.set_camera_wb(saved_settings["wb"]) + else: # is running, turn off + print("Stopping timelapse") + + timelapse_remaining = None + pycam.camera.exposure_ctrl = True + pycam.set_camera_gain(None) # go back to autogain + pycam.set_camera_wb(None) # go back to autobalance + pycam.set_camera_exposure(None) # go back to auto shutter diff --git a/examples/filter/code.py b/examples/filter/code.py new file mode 100644 index 0000000..aacf487 --- /dev/null +++ b/examples/filter/code.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: 2024 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Effects Demonstration + +This will apply a nubmer of effects to a single image. + +Press any of the directional buttons to immediately apply a new effect. + +Otherwise, effects cycle every DISPLAY_INTERVAL milliseconds (default 2000 = 2 seconds) +""" + +import bitmapfilter +import displayio +from adafruit_display_text.label import Label +from adafruit_ticks import ticks_add, ticks_diff, ticks_less, ticks_ms +from font_free_mono_bold_24 import FONT +from jpegio import JpegDecoder + +from adafruit_pycamera import PyCameraBase, imageprocessing + +blend_50_50 = bitmapfilter.blend_precompute(imageprocessing.alphablend_maker(0.5)) +screen = bitmapfilter.blend_precompute(imageprocessing.screen_func) +overlay = bitmapfilter.blend_precompute(imageprocessing.overlay_func) +hard_light = bitmapfilter.blend_precompute(imageprocessing.hard_light_func) +soft_light = bitmapfilter.blend_precompute(imageprocessing.soft_light_func) +color_dodge = bitmapfilter.blend_precompute(imageprocessing.color_dodge_func) +# linear_dodge = bitmapfilter.blend_precompute(imageprocessing.linear_dodge_func) +# divide = bitmapfilter.blend_precompute(imageprocessing.divide_func) +multiply = bitmapfilter.blend_precompute(imageprocessing.multiply_func) +# subtract = bitmapfilter.blend_precompute(imageprocessing.subtract_func) +# color_burn = bitmapfilter.blend_precompute(imageprocessing.color_burn_func) +# linear_burn = bitmapfilter.blend_precompute(imageprocessing.linear_burn_func) +# darken_only = bitmapfilter.blend_precompute(min) +# lighten_only = bitmapfilter.blend_precompute(max) + + +def blender(f): + def inner(b): + return bitmapfilter.blend(b, b, testpattern, f) + + return inner + + +def reverse_blender(f): + def inner(b): + return bitmapfilter.blend(b, testpattern, b, f) + + return inner + + +inverse_greyscale_weights = bitmapfilter.ChannelMixer( + 1 - 0.299, + 1 - 0.587, + 1 - 0.114, + 1 - 0.299, + 1 - 0.587, + 1 - 0.114, + 1 - 0.299, + 1 - 0.587, + 1 - 0.114, +) + +blur_more = [ + 4, + 15, + 24, + 15, + 4, + 15, + 61, + 97, + 61, + 15, + 24, + 97, + 154, + 97, + 24, + 15, + 61, + 97, + 61, + 15, + 4, + 15, + 24, + 15, + 4, +] + + +# "Sketch" filter based on +# https://www.freecodecamp.org/news/sketchify-turn-any-image-into-a-pencil-sketch-with-10-lines-of-code-cf67fa4f68ce/ +def sketch(b): + bitmapfilter.mix(b, inverse_greyscale_weights) + memoryview(auxbuffer)[:] = memoryview(b) + bitmapfilter.morph(auxbuffer, blur_more) + bitmapfilter.blend(b, auxbuffer, b, color_dodge) + bitmapfilter.mix(b, inverse_greyscale_weights) # get rid of magenta halos + return b + + +effects = [ + ("sketch", sketch), + ("50/50", blender(blend_50_50)), + ("multiply", blender(multiply)), + ("soft light", blender(soft_light)), + ("hard_light", blender(hard_light)), + ("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 cycle(seq): + while True: + yield from seq + + +effects_cycle = iter(cycle(effects)) + + +DISPLAY_INTERVAL = 2000 # milliseconds + +decoder = JpegDecoder() + +pycam = PyCameraBase() +pycam.init_display() + +testpattern = displayio.Bitmap(208, 208, 65535) +auxbuffer = displayio.Bitmap(208, 208, 65535) + + +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) + + decoder.open("/testpattern_208x208.jpg") + decoder.decode(testpattern) + + label = Label(font=FONT, x=0, y=8) + pycam.display.root_group = label + pycam.display.refresh() + + deadline = ticks_ms() + while True: + now = ticks_ms() + 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): + 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 0000000..20ab2c5 Binary files /dev/null and b/examples/filter/cornell_box_208x208.jpg differ 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 diff --git a/examples/filter/testpattern_208x208.jpg b/examples/filter/testpattern_208x208.jpg new file mode 100644 index 0000000..94b01de Binary files /dev/null and b/examples/filter/testpattern_208x208.jpg differ diff --git a/examples/filter/testpattern_208x208.jpg.license b/examples/filter/testpattern_208x208.jpg.license new file mode 100644 index 0000000..746796b --- /dev/null +++ b/examples/filter/testpattern_208x208.jpg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries + +SPDX-License-Identifier: Unlicense diff --git a/examples/overlay/blinka_emoji_rgb888.bmp b/examples/overlay/blinka_emoji_rgb888.bmp new file mode 100644 index 0000000..6a26a1f Binary files /dev/null and b/examples/overlay/blinka_emoji_rgb888.bmp differ diff --git a/examples/overlay/blinka_emoji_rgb888.bmp.license b/examples/overlay/blinka_emoji_rgb888.bmp.license new file mode 100644 index 0000000..831eb5c --- /dev/null +++ b/examples/overlay/blinka_emoji_rgb888.bmp.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT diff --git a/examples/overlay/code_select.py b/examples/overlay/code_select.py new file mode 100644 index 0000000..6735c53 --- /dev/null +++ b/examples/overlay/code_select.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 john park for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""simple point-and-shoot camera example, with overly selecting using select button. + +Place all overlay files inside /sd/overlays/ directory. +""" + +import os +import time +import traceback + +import adafruit_pycamera + +MODE_POSITION = 0 +MODE_SCALE = 1 +CURRENT_MODE = 0 + +int_scale = 100 + +pycam = adafruit_pycamera.PyCamera() +pycam.mode = 0 # only mode 0 (JPEG) will work in this example + +# User settings - try changing these: +pycam.resolution = 1 # 0-12 preset resolutions: +# 0: 240x240, 1: 320x240, 2: 640x480 + +pycam.led_level = 1 # 0-4 preset brightness levels +pycam.led_color = 0 # 0-7 preset colors: 0: white, 1: green, 2: yellow, 3: red, +# 4: pink, 5: blue, 6: teal, 7: rainbow +pycam.effect = 0 # 0-7 preset FX: 0: normal, 1: invert, 2: b&w, 3: red, +# 4: green, 5: blue, 6: sepia, 7: solarize + +print("Overlay example camera ready.") +pycam.tone(800, 0.1) +pycam.tone(1200, 0.05) + +overlay_files = os.listdir("/sd/overlays/") +cur_overlay_idx = 0 + +pycam.overlay = f"/sd/overlays/{overlay_files[cur_overlay_idx]}" +pycam.overlay_transparency_color = 0xE007 +pycam.overlay_position = [0, 0] + +overlay_files = os.listdir("/sd/overlays/") +cur_overlay_idx = 0 + +while True: + pycam.blit(pycam.continuous_capture()) + pycam.keys_debounce() + # print(dir(pycam.select)) + if pycam.select.fell: + cur_overlay_idx += 1 + if cur_overlay_idx >= len(overlay_files): + cur_overlay_idx = 0 + print(f"changing overlay to {overlay_files[cur_overlay_idx]}") + pycam.overlay = f"/sd/overlays/{overlay_files[cur_overlay_idx]}" + + if CURRENT_MODE == MODE_POSITION: + if not pycam.down.value: + pycam.overlay_position[1] += 1 * (int(pycam.down.current_duration / 0.3) + 1) + if not pycam.up.value: + pycam.overlay_position[1] -= 1 * (int(pycam.up.current_duration / 0.3) + 1) + if not pycam.left.value: + pycam.overlay_position[0] -= 1 * (int(pycam.left.current_duration / 0.3) + 1) + if not pycam.right.value: + pycam.overlay_position[0] += 1 * (int(pycam.right.current_duration / 0.3) + 1) + if CURRENT_MODE == MODE_SCALE: + if pycam.down.fell: + int_scale -= 10 + pycam.overlay_scale = int_scale / 100 + print(pycam.overlay_scale) + if pycam.up.fell: + int_scale += 10 + pycam.overlay_scale = int_scale / 100 + print(pycam.overlay_scale) + + if pycam.ok.fell: + CURRENT_MODE = MODE_POSITION if CURRENT_MODE == MODE_SCALE else MODE_SCALE + print(f"Changing mode to: {CURRENT_MODE}") + if pycam.shutter.short_count: + print("Shutter released") + pycam.tone(1200, 0.05) + pycam.tone(1600, 0.05) + try: + pycam.display_message("snap", color=0x00DD00) + pycam.capture_jpeg() + pycam.display_message("overlay", color=0x00DD00) + pycam.blit_overlay_into_last_capture() + pycam.live_preview_mode() + except TypeError as exception: + traceback.print_exception(exception) + pycam.display_message("Failed", color=0xFF0000) + time.sleep(0.5) + pycam.live_preview_mode() + except RuntimeError: + pycam.display_message("Error\nNo SD Card", color=0xFF0000) + time.sleep(0.5) + + if pycam.card_detect.fell: + print("SD card removed") + pycam.unmount_sd_card() + pycam.display.refresh() + + if pycam.card_detect.rose: + print("SD card inserted") + 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 exception: + print("Retrying!", exception) + time.sleep(0.5) + else: + pycam.display_message("SD Card\nFailed!", color=0xFF0000) + time.sleep(0.5) + pycam.display.refresh() diff --git a/examples/overlay/code_simple.py b/examples/overlay/code_simple.py new file mode 100644 index 0000000..4bd821f --- /dev/null +++ b/examples/overlay/code_simple.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 john park for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""simple point-and-shoot camera example, with an overlay frame image.""" + +import time +import traceback + +import adafruit_pycamera + +pycam = adafruit_pycamera.PyCamera() +pycam.mode = 0 # only mode 0 (JPEG) will work in this example + +# User settings - try changing these: +pycam.resolution = 1 # 0-12 preset resolutions: +# 0: 240x240, 1: 320x240, 2: 640x480 + +pycam.led_level = 1 # 0-4 preset brightness levels +pycam.led_color = 0 # 0-7 preset colors: 0: white, 1: green, 2: yellow, 3: red, +# 4: pink, 5: blue, 6: teal, 7: rainbow +pycam.effect = 0 # 0-7 preset FX: 0: normal, 1: invert, 2: b&w, 3: red, +# 4: green, 5: blue, 6: sepia, 7: solarize + +print("Overlay example camera ready.") +pycam.tone(800, 0.1) +pycam.tone(1200, 0.05) + +pycam.overlay = "/heart_frame_rgb888.bmp" +pycam.overlay_transparency_color = 0xE007 + +while True: + pycam.blit(pycam.continuous_capture()) + pycam.keys_debounce() + + if pycam.shutter.short_count: + print("Shutter released") + pycam.tone(1200, 0.05) + pycam.tone(1600, 0.05) + try: + pycam.display_message("snap", color=0x00DD00) + pycam.capture_jpeg() + pycam.display_message("overlay", color=0x00DD00) + pycam.blit_overlay_into_last_capture() + pycam.live_preview_mode() + except TypeError as exception: + traceback.print_exception(exception) + pycam.display_message("Failed", color=0xFF0000) + time.sleep(0.5) + pycam.live_preview_mode() + except RuntimeError: + pycam.display_message("Error\nNo SD Card", color=0xFF0000) + time.sleep(0.5) + + if pycam.card_detect.fell: + print("SD card removed") + pycam.unmount_sd_card() + pycam.display.refresh() + + if pycam.card_detect.rose: + print("SD card inserted") + 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 exception: + print("Retrying!", exception) + time.sleep(0.5) + else: + pycam.display_message("SD Card\nFailed!", color=0xFF0000) + time.sleep(0.5) + pycam.display.refresh() diff --git a/examples/overlay/heart_frame_rgb888.bmp b/examples/overlay/heart_frame_rgb888.bmp new file mode 100644 index 0000000..eab3d0a Binary files /dev/null and b/examples/overlay/heart_frame_rgb888.bmp differ diff --git a/examples/overlay/heart_frame_rgb888.bmp.license b/examples/overlay/heart_frame_rgb888.bmp.license new file mode 100644 index 0000000..831eb5c --- /dev/null +++ b/examples/overlay/heart_frame_rgb888.bmp.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT diff --git a/examples/overlay/pencil_frame_rgb888.bmp b/examples/overlay/pencil_frame_rgb888.bmp new file mode 100644 index 0000000..e92fc9a Binary files /dev/null and b/examples/overlay/pencil_frame_rgb888.bmp differ diff --git a/examples/overlay/pencil_frame_rgb888.bmp.license b/examples/overlay/pencil_frame_rgb888.bmp.license new file mode 100644 index 0000000..831eb5c --- /dev/null +++ b/examples/overlay/pencil_frame_rgb888.bmp.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT diff --git a/examples/qrio/code.py b/examples/qrio/code.py index 0f5720c..026106f 100644 --- a/examples/qrio/code.py +++ b/examples/qrio/code.py @@ -1,5 +1,4 @@ # SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2021 Jeff Epler for Adafruit Industries # # SPDX-License-Identifier: Unlicense @@ -15,8 +14,8 @@ from adafruit_pycamera import PyCamera pycam = PyCamera() -pycam._mode_label.text = "QR SCAN" # pylint: disable=protected-access -pycam._res_label.text = "" # pylint: disable=protected-access +pycam._mode_label.text = "QR SCAN" +pycam._res_label.text = "" pycam.effect = 0 pycam.camera.hmirror = False pycam.display.refresh() diff --git a/examples/timestamp_filename/code.py b/examples/timestamp_filename/code.py new file mode 100644 index 0000000..3dfebcc --- /dev/null +++ b/examples/timestamp_filename/code.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""simple point-and-shoot camera example. With NTP and internal RTC to +add timestamp to photo filenames. Must install adafruit_ntp library! +Example code assumes WIFI credentials are properly setup and web workflow +enabled in settings.toml. If not, you'll need to add code to manually connect +to your network.""" + +import time + +import adafruit_ntp +import rtc +import socketpool +import wifi + +import adafruit_pycamera + +pool = socketpool.SocketPool(wifi.radio) +ntp = adafruit_ntp.NTP(pool, tz_offset=0) +rtc.RTC().datetime = ntp.datetime + +pycam = adafruit_pycamera.PyCamera() +pycam.mode = 0 # only mode 0 (JPEG) will work in this example + +# User settings - try changing these: +pycam.resolution = 2 # 0-12 preset resolutions: +# 0: 240x240, 1: 320x240, 2: 640x480, 3: 800x600, 4: 1024x768, +# 5: 1280x720, 6: 1280x1024, 7: 1600x1200, 8: 1920x1080, 9: 2048x1536, +# 10: 2560x1440, 11: 2560x1600, 12: 2560x1920 +pycam.led_level = 1 # 0-4 preset brightness levels +pycam.led_color = 0 # 0-7 preset colors: 0: white, 1: green, 2: yellow, 3: red, +# 4: pink, 5: blue, 6: teal, 7: rainbow +pycam.effect = 0 # 0-7 preset FX: 0: normal, 1: invert, 2: b&w, 3: red, +# 4: green, 5: blue, 6: sepia, 7: solarize + +print("Simple camera ready.") +pycam.tone(800, 0.1) +pycam.tone(1200, 0.05) + +while True: + pycam.blit(pycam.continuous_capture()) + pycam.keys_debounce() + + if pycam.shutter.short_count: + print("Shutter released") + pycam.tone(1200, 0.05) + pycam.tone(1600, 0.05) + try: + pycam.display_message("snap", color=0x00DD00) + timestamp = f"img_{time.localtime().tm_year}-{time.localtime().tm_mon}-{time.localtime().tm_mday}_{time.localtime().tm_hour:02}-{time.localtime().tm_min:02}-{time.localtime().tm_sec:02}_" # noqa: E501 + pycam.capture_jpeg(filename_prefix=timestamp) + pycam.live_preview_mode() + except TypeError: + pycam.display_message("Failed", color=0xFF0000) + time.sleep(0.5) + pycam.live_preview_mode() + except RuntimeError: + pycam.display_message("Error\nNo SD Card", color=0xFF0000) + time.sleep(0.5) + + if pycam.card_detect.fell: + print("SD card removed") + pycam.unmount_sd_card() + pycam.display.refresh() + + if pycam.card_detect.rose: + print("SD card inserted") + 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 exception: + print("Retrying!", exception) + time.sleep(0.5) + else: + pycam.display_message("SD Card\nFailed!", color=0xFF0000) + time.sleep(0.5) + pycam.display.refresh() diff --git a/examples/viewer/code.py b/examples/viewer/code.py new file mode 100644 index 0000000..db7d387 --- /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 os +import time + +import displayio +from adafruit_ticks import ticks_add, ticks_diff, ticks_less, ticks_ms +from jpegio import JpegDecoder + +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: + 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() diff --git a/examples/ipcam/code.py b/examples/web_camera/code.py similarity index 78% rename from examples/ipcam/code.py rename to examples/web_camera/code.py index 49865b8..4b0d777 100644 --- a/examples/ipcam/code.py +++ b/examples/web_camera/code.py @@ -22,7 +22,14 @@ pycam.camera.quality = 6 server = Server(socketpool.SocketPool(wifi.radio)) -PORT = 81 +if wifi.radio.ipv4_address: + # use alt port if web workflow enabled + port = 8080 +else: + # connect to wifi and use standard http port otherwise + wifi.radio.connect(os.getenv("WIFI_SSID"), os.getenv("WIFI_PASSWORD")) + port = 80 + BOUNDARY = b"FRAME" + binascii.hexlify(os.urandom(8)) @@ -44,14 +51,13 @@ def body(): request, body, headers={ - "Content-Type": "multipart/x-mixed-replace; boundary=%s" - % BOUNDARY.decode("ascii") + "Content-Type": "multipart/x-mixed-replace; boundary=%s" % BOUNDARY.decode("ascii") }, ) async def poll(interval): - server.start(str(wifi.radio.ipv4_address), port=PORT) + server.start(str(wifi.radio.ipv4_address), port=port) while True: try: server.poll() @@ -65,6 +71,6 @@ async def main(): await asyncio.gather(poll_task) -pycam.display_message(f"{wifi.radio.ipv4_address}:{PORT}/", scale=2) +pycam.display_message(f"{wifi.radio.ipv4_address}:{port}/", scale=2) asyncio.run(main()) diff --git a/examples/ipcam2/code.py b/examples/web_settings_explorer/code.py similarity index 87% rename from examples/ipcam2/code.py rename to examples/web_settings_explorer/code.py index ce3c2ce..e6f3cd4 100644 --- a/examples/ipcam2/code.py +++ b/examples/web_settings_explorer/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 @@ -70,17 +69,13 @@ def lcd(request: Request) -> Response: def take_jpeg(request: Request) -> Response: pycam.camera.reconfigure( pixel_format=espcamera.PixelFormat.JPEG, - frame_size=pycam.resolution_to_frame_size[ - pycam._resolution # pylint: disable=protected-access - ], + frame_size=pycam.resolution_to_frame_size[pycam._resolution], ) try: jpeg = pycam.camera.take(1) if jpeg is not None: return Response(request, bytes(jpeg), content_type="image/jpeg") - return Response( - request, "", content_type="text/plain", status=INTERNAL_SERVER_ERROR_500 - ) + return Response(request, "", content_type="text/plain", status=INTERNAL_SERVER_ERROR_500) finally: pycam.live_preview_mode() @@ -110,14 +105,15 @@ def property_common(obj, request): try: current_value = getattr(obj, propname, None) return JSONResponse(request, current_value) - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: return Response(request, {"error": str(exc)}, status=BAD_REQUEST_400) else: new_value = json.loads(value) setattr(obj, propname, new_value) return JSONResponse(request, {"status": "OK"}) - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: return JSONResponse(request, {"error": str(exc)}, status=BAD_REQUEST_400) +pycam.display_message(f"{wifi.radio.ipv4_address}:{port}/", scale=2) server.serve_forever(str(wifi.radio.ipv4_address), port) diff --git a/examples/ipcam2/htdocs/index.html b/examples/web_settings_explorer/htdocs/index.html similarity index 100% rename from examples/ipcam2/htdocs/index.html rename to examples/web_settings_explorer/htdocs/index.html diff --git a/examples/ipcam2/htdocs/index.js b/examples/web_settings_explorer/htdocs/index.js similarity index 100% rename from examples/ipcam2/htdocs/index.js rename to examples/web_settings_explorer/htdocs/index.js diff --git a/examples/ipcam2/htdocs/metadata.js b/examples/web_settings_explorer/htdocs/metadata.js similarity index 100% rename from examples/ipcam2/htdocs/metadata.js rename to examples/web_settings_explorer/htdocs/metadata.js diff --git a/examples/ipcam2/make_web_metadata.py b/examples/web_settings_explorer/make_web_metadata.py similarity index 100% rename from examples/ipcam2/make_web_metadata.py rename to examples/web_settings_explorer/make_web_metadata.py diff --git a/optional_requirements.txt b/optional_requirements.txt index d4e27c4..42b579d 100644 --- a/optional_requirements.txt +++ b/optional_requirements.txt @@ -1,3 +1,6 @@ # SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries # # SPDX-License-Identifier: Unlicense + +adafruit-circuitpython-bitmapsaver +adafruit-circuitpython-imageload diff --git a/pyproject.toml b/pyproject.toml index 5fbf4d7..8e00f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2022 Alec Delaney, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +# SPDX-FileCopyrightText: Jeff Epler for Adafruit Industries # # SPDX-License-Identifier: MIT diff --git a/requirements.txt b/requirements.txt index 67f979e..f562ea4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries for Adafruit Industries +# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries # # SPDX-License-Identifier: MIT diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..73e9efc --- /dev/null +++ b/ruff.toml @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +target-version = "py38" +line-length = 100 + +[lint] +preview = true +select = ["I", "PL", "UP"] + +extend-select = [ + "D419", # empty-docstring + "E501", # line-too-long + "W291", # trailing-whitespace + "PLC0414", # useless-import-alias + "PLC2401", # non-ascii-name + "PLC2801", # unnecessary-dunder-call + "PLC3002", # unnecessary-direct-lambda-call + "E999", # syntax-error + "PLE0101", # return-in-init + "F706", # return-outside-function + "F704", # yield-outside-function + "PLE0116", # continue-in-finally + "PLE0117", # nonlocal-without-binding + "PLE0241", # duplicate-bases + "PLE0302", # unexpected-special-method-signature + "PLE0604", # invalid-all-object + "PLE0605", # invalid-all-format + "PLE0643", # potential-index-error + "PLE0704", # misplaced-bare-raise + "PLE1141", # dict-iter-missing-items + "PLE1142", # await-outside-async + "PLE1205", # logging-too-many-args + "PLE1206", # logging-too-few-args + "PLE1307", # bad-string-format-type + "PLE1310", # bad-str-strip-call + "PLE1507", # invalid-envvar-value + "PLE2502", # bidirectional-unicode + "PLE2510", # invalid-character-backspace + "PLE2512", # invalid-character-sub + "PLE2513", # invalid-character-esc + "PLE2514", # invalid-character-nul + "PLE2515", # invalid-character-zero-width-space + "PLR0124", # comparison-with-itself + "PLR0202", # no-classmethod-decorator + "PLR0203", # no-staticmethod-decorator + "UP004", # useless-object-inheritance + "PLR0206", # property-with-parameters + "PLR0904", # too-many-public-methods + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0914", # too-many-locals + "PLR0915", # too-many-statements + "PLR0916", # too-many-boolean-expressions + "PLR1702", # too-many-nested-blocks + "PLR1704", # redefined-argument-from-local + "PLR1711", # useless-return + "C416", # unnecessary-comprehension + "PLR1733", # unnecessary-dict-index-lookup + "PLR1736", # unnecessary-list-index-lookup + + # ruff reports this rule is unstable + #"PLR6301", # no-self-use + + "PLW0108", # unnecessary-lambda + "PLW0120", # useless-else-on-loop + "PLW0127", # self-assigning-variable + "PLW0129", # assert-on-string-literal + "B033", # duplicate-value + "PLW0131", # named-expr-without-context + "PLW0245", # super-without-brackets + "PLW0406", # import-self + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "PLW0604", # global-at-module-level + + # fails on the try: import typing used by libraries + #"F401", # unused-import + + "F841", # unused-variable + "E722", # bare-except + "PLW0711", # binary-op-exception + "PLW1501", # bad-open-mode + "PLW1508", # invalid-envvar-default + "PLW1509", # subprocess-popen-preexec-fn + "PLW2101", # useless-with-lock + "PLW3301", # nested-min-max +] + +ignore = [ + "PLR2004", # magic-value-comparison + "UP030", # format literals + "PLW1514", # unspecified-encoding + "PLR0913", # too-many-arguments + "PLR0915", # too-many-statements + "PLR0917", # too-many-positional-arguments + "PLR0904", # too-many-public-methods + "PLR0912", # too-many-branches + "PLR0916", # too-many-boolean-expressions + "PLR6301", # could-be-static no-self-use + "PLC0415", # import outside toplevel + "PLC2701", # private import +] + +[format] +line-ending = "lf"