Repository: signag/raspi-cam-srv Branch: main Commit: 41367ee33931 Files: 143 Total size: 2.8 MB Directory structure: gitextract_uzgjfrtj/ ├── .dockerignore ├── .gitignore ├── .vscode/ │ └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── config/ │ ├── raspiCamSrv.service │ └── raspiCamSrv_gunicorn.service ├── docker-compose.yml ├── docs/ │ ├── API.md │ ├── AiCameraSupport.md │ ├── Authentication.md │ ├── Background Processes.md │ ├── Cam.md │ ├── CamCalibration.md │ ├── CamMulticam.md │ ├── CamStereo.md │ ├── CamWebcam.md │ ├── CameraControls.md │ ├── CameraControls_AutoExposure.md │ ├── CameraControls_Ctrl.md │ ├── CameraControls_Exposure.md │ ├── CameraControls_Image.md │ ├── CameraControls_UsbCams.md │ ├── Configuration.md │ ├── Configuration_AI.md │ ├── Console.md │ ├── ConsoleActionButtons.md │ ├── ConsoleVButtons.md │ ├── FocusHandling.md │ ├── Information.md │ ├── Information_Cam.md │ ├── Information_CamPrp.md │ ├── Information_Sensor.md │ ├── Information_Sys.md │ ├── LiveDirectControl.md │ ├── LiveScreen.md │ ├── PhotoSeries.md │ ├── PhotoSeriesExp.md │ ├── PhotoSeriesFocus.md │ ├── PhotoSeriesTimelapse.md │ ├── PhotoViewer.md │ ├── Phototaking.md │ ├── ReleaseNotes.md │ ├── ScalerCrop.md │ ├── Settings.md │ ├── SettingsAButtons.md │ ├── SettingsAPI.md │ ├── SettingsConfiguration.md │ ├── SettingsConfiguration_NoCam.md │ ├── SettingsDevices.md │ ├── SettingsLButtons.md │ ├── SettingsUpdate.md │ ├── SettingsUsers.md │ ├── SettingsVButtons.md │ ├── Settings_NoCam.md │ ├── SetupDocker.md │ ├── Trigger.md │ ├── TriggerActions.md │ ├── TriggerActive.md │ ├── TriggerCalendar.md │ ├── TriggerCameraActions.md │ ├── TriggerControl.md │ ├── TriggerEventViewer.md │ ├── TriggerMotion.md │ ├── TriggerNotification.md │ ├── TriggerOverview.md │ ├── TriggerTriggerActions.md │ ├── TriggerTriggers.md │ ├── Troubelshooting.md │ ├── Tuning.md │ ├── UserGuide.md │ ├── UserGuide_NoCam.md │ ├── Z_Legacy_Information.md │ ├── ZoomPan.md │ ├── api/ │ │ └── postman/ │ │ └── raspiCamSrv.postman_collection.json │ ├── bp_Hotspot_Bookworm.md │ ├── bp_Hotspot_Bullseye.md │ ├── bp_Hotspot_Trixie.md │ ├── bp_PiZero_Standalone.md │ ├── features.md │ ├── getting_started_overview.md │ ├── gpioDevices/ │ │ ├── ServoPWM.md │ │ └── StepperMotor.md │ ├── img/ │ │ └── TLSeriesStateChart.vsdx │ ├── index.md │ ├── installation.md │ ├── installation_man.md │ ├── picamera2_manual.md │ ├── requirements.md │ ├── service_configuration.md │ ├── system_setup.md │ ├── tutorials/ │ │ ├── AWB_with_neural_networks.md │ │ └── Tutorials_Overview.md │ └── updating_raspiCamSrv.md ├── mkdocs.yml ├── raspiCamSrv/ │ ├── __init__.py │ ├── api.py │ ├── auth.py │ ├── auth_su.py │ ├── camCfg.py │ ├── camera_pi.py │ ├── config.py │ ├── console.py │ ├── db.py │ ├── dbx.py │ ├── gpioDeviceTypes.py │ ├── gpioDevices.py │ ├── home.py │ ├── images.py │ ├── info.py │ ├── motionAlgoIB.py │ ├── motionDetector.py │ ├── photoseries.py │ ├── photoseriesCfg.py │ ├── schema.sql │ ├── settings.py │ ├── static/ │ │ └── w3.css │ ├── stereoCam.py │ ├── sun.py │ ├── templates/ │ │ ├── auth/ │ │ │ ├── login.html │ │ │ ├── password.html │ │ │ └── register.html │ │ ├── base.html │ │ ├── config/ │ │ │ └── main.html │ │ ├── console/ │ │ │ └── console.html │ │ ├── home/ │ │ │ ├── index.html │ │ │ └── liveDirectPanel.html │ │ ├── images/ │ │ │ └── main.html │ │ ├── info/ │ │ │ └── info.html │ │ ├── media_viewer.html │ │ ├── photoseries/ │ │ │ └── main.html │ │ ├── settings/ │ │ │ └── main.html │ │ ├── trigger/ │ │ │ └── trigger.html │ │ └── webcam/ │ │ └── webcam.html │ ├── trigger.py │ ├── triggerHandler.py │ ├── version.py │ ├── versionDoc.py │ └── webcam.py ├── requirements.txt └── scripts/ ├── install_raspiCamSrv.sh └── uninstall_raspiCamSrv.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ **/__pycache__ **/.env **/.git **/.venv **/.vscode config **/docs **/instance **/logs **/backups raspiCamSrv/static/config raspiCamSrv/static/events raspiCamSrv/static/photos raspiCamSrv/static/photoseries raspiCamSrv/static/tuning **/tests **/.dockerignore **/.gitignore **/docker-compose* **/Dockerfile* LICENSE README.md ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class tests/ user_code/ logs/ output/ backups raspiCamSrv/static/calib_data/ raspiCamSrv/static/calib_photos/ raspiCamSrv/static/photos/ raspiCamSrv/static/timelapse/ raspiCamSrv/static/photoseries/ raspiCamSrv/static/config/ raspiCamSrv/static/events/ raspiCamSrv/static/tuning/ *.mp4 *.h264 # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ================================================ FILE: .vscode/launch.json ================================================ { // Verwendet IntelliSense zum Ermitteln möglicher Attribute. // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Python-Debugger: Aktuelle Datei", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal" } ] } ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM dtcooper/raspberrypi-os:bookworm LABEL maintainer="signag" RUN apt update && apt -y upgrade RUN apt update && apt install -y \ gcc-aarch64-linux-gnu \ systemd \ systemd-timesyncd \ python3 \ python3-dev \ python3-pip \ python3-venv \ python3-opencv \ python3-gpiozero \ python3-lgpio \ ffmpeg \ python3-picamera2 --no-install-recommends \ imx500-all \ dpkg-dev \ v4l-utils RUN ln -s /usr/bin/python3 /usr/bin/python # Prevents Python from writing pyc files. ENV PYTHONDONTWRITEBYTECODE=1 # Keeps Python from buffering stdout and stderr to avoid situations where # the application crashes without emitting any logs due to buffering. ENV PYTHONUNBUFFERED=1 # Set environment variables ENV DEBIAN_FRONTEND=noninteractive WORKDIR /app # Copy the source code into the container. COPY . . # Install Python dependencies in virtual environment RUN python -m venv --system-site-packages .venv ENV PATH=".venv/bin:$PATH" RUN pip install --no-cache-dir -r requirements.txt # Expose the port that the application listens on. EXPOSE 5000 # Initialize database for Flask RUN flask --app raspiCamSrv init-db # Run the application. CMD gunicorn -b 0.0.0.0:5000 -w 1 -k gthread --threads 6 --timeout 0 --log-level info 'raspiCamSrv:create_app()' ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 signag Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # raspiCamSrv V4.10.0 **raspiCamSrv** is a Web server for Raspberry Pi systems providing an App for control and streaming of CSI and USB cameras as well as for controlling a large variety of connected GPIO devices. While all currently connected cameras are accessible by the system, up to two cameras can be operated simultaneously at a time, supporting multi-camera features like Stereo Vision. Interoperability between Cameras and GPIO devices is achieved through the freely configurable event handling infrastructure. **raspiCamSrv** supports all Raspberry Pi platforms from Pi Zero to Pi 5, running Bullseye, Bookworm or Trixie OS. Besides the currently available Raspberry Pi cameras, also compatible CSI cameras from other providers can be used. USB web cams are seamlessly integrated. **raspiCamSrv** is built with Flask 3.x and uses the Picamera2 library. Due to responsive layout from W3.CSS, all modern browsers on PC, Mac or mobile devices can be used as clients. For more details, refer to the [raspiCamSrv Documentation](https://signag.github.io/raspi-cam-srv/latest/), especially the [Features List](https://signag.github.io/raspi-cam-srv/latest/features/) or check the [Release Notes](https://signag.github.io/raspi-cam-srv/latest/ReleaseNotes/) for current version and latest updates. ![Live Overview](docs/img/Live.jpg) To [get started with raspiCamSrv](https://signag.github.io/raspi-cam-srv/latest/getting_started_overview/), 1. [Check necessary requirements](https://signag.github.io/raspi-cam-srv/latest/requirements/) 2. [Set up your Raspberry Pi](https://signag.github.io/raspi-cam-srv/latest/system_setup/) 3. [Install raspiCamSrv](https://signag.github.io/raspi-cam-srv/latest/installation/) 4. Refer to the raspiCamSrv [User Guide](https://signag.github.io/raspi-cam-srv/latest/UserGuide/) ================================================ FILE: config/raspiCamSrv.service ================================================ [Unit] Description=raspiCamSrv After=network.target [Service] ExecStart=/home//prg/raspi-cam-srv/.venv/bin/python -m flask --app raspiCamSrv run --port 5000 --host=0.0.0.0 Environment="PATH=/home//prg/raspi-cam-srv/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" WorkingDirectory=/home//prg/raspi-cam-srv StandardOutput=inherit StandardError=inherit Restart=always User= [Install] WantedBy=multi-user.target ================================================ FILE: config/raspiCamSrv_gunicorn.service ================================================ [Unit] Description=raspiCamSrv After=network.target [Service] ExecStart=/home//prg/raspi-cam-srv/.venv/bin/gunicorn -b 0.0.0.0:5000 -w 1 -k gthread --threads 6 --timeout 0 --log-level info 'raspiCamSrv:create_app()' Environment="PATH=/home//prg/raspi-cam-srv/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment="GUNICORN_THREADS=6" WorkingDirectory=/home//prg/raspi-cam-srv StandardOutput=inherit StandardError=inherit Restart=always User= [Install] WantedBy=multi-user.target ================================================ FILE: docker-compose.yml ================================================ name: raspi-cam-srv services: raspi-cam-srv: container_name: raspi-cam-srv build: . image: signag/raspi-cam-srv network_mode: "host" ports: - "5000:5000" devices: - /dev/video0:/dev/video0 - /dev/gpiochip0:/dev/gpiochip0 volumes: - /dev:/dev - /sys:/sys - /run/udev/:/run/udev:ro - /run/systemd:/run/systemd:ro - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - GPIOZERO_PIN_FACTORY=lgpio - SYSTEMD_BUS_ADDRESS=unix:path=/run/systemd/private restart: unless-stopped privileged: true ================================================ FILE: docs/API.md ================================================ # raspiCamSrv API [![Up](img/goup.gif)](./UserGuide.md) The **raspiCamSrv** API allows access to several RaspberryPi camera functions through WebService endpoints. Configuration of the API is done on the [Settings/API](./SettingsAPI.md) screen. ## Postman Test Collection For testing the API, a [Postman](https://www.postman.com/) collection is available at [docs/api/postman](https://github.com/signag/raspi-cam-srv/tree/main/docs/api/postman) which can be downloaded and imported into a Postman instance. ![PostmanColl](./img/API_Postman_collection.jpg) ## API Documentation The [docs/api/postman](https://github.com/signag/raspi-cam-srv/tree/main/docs/api/postman) folder contains also the [API Documentation](./api/postman/raspiCamSrv.postman_collection.pdf) generated from Postman. ## Variables The collection uses a set of variables: ![PostmanVars](./img/API_Postman_variables.jpg) ```base_url```, ```user``` and ```pwd``` need to be adjusted to the current environment for a user which has been previously created in raspiCamSrv. ```access_token``` and ```refresh_token``` will be automatically filled from responses of the /api/login and /api/refresh endpoints. ## Usage ### 1. Login Use the ```api login``` request to log in to **raspiCamSrv** and receive an Access Token and a Refresh Token ### 2. Interact with **raspiCamSrv** Use any of the GET requests to interact with **raspiCamSrv**. These requests use the Access Token for authentication. ### 3. Refresh the Access Token If a request returns a token expiration error, refresh the Access Token using the ```api refresh``` request. This will use the Refresh Token for authentication and return a fresh Access Token. ================================================ FILE: docs/AiCameraSupport.md ================================================ # raspiCamSrv AI Camera Support [![Up](img/goup.gif)](./UserGuide.md) The [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) has an integrated AI accelerator which can process Input Tensor data delivered by the camera ISP directly within the camera system. Output Tensor data with inference information are delivered to the Raspberry Pi host system in the context of image meta data. Therefore, the main computational effort for the neural network is on the camera system while the host system is left with analysis and visualization of inference from the meta data. This makes it possible to see Neural Networks at work even on low cost system such as Raspberry Pi Zero 2 (See [Raspberry Models lower 5 and Raspberry Pi Zero](#raspberry-models-4-and-lower-and-raspberry-pi-zero)). **raspiCamSrv** from V4.6 supports AI features for the imx500 on the basis of published [neural network models](https://github.com/raspberrypi/imx500-models) which are deployed together with the imx500-all package and for which Picamera2 provides [demo implemetations](https://github.com/raspberrypi/picamera2/tree/main/examples/imx500). These implementations have been integrated in **raspiCamSrv** with minor modifications for visualization on different camera streams. **NOTE**: Currently, Visualization of inference data always scales to the [Stream Size](./Configuration.md#stream-size-width-height) and does not consider cropping. Therefore you should avoid [zoom and pan](./ZoomPan.md). ## Installation All required packages are installed with the [automatic installer](./installation.md#installer) if you confirm to use the AI Camera. ## Update from previous raspiCamSrv Versions If you update from a previous **raspiCamSrv** version (V4.5 and earlier), you can just run the [automatic installer](./installation.md) again. It will recognize components which are already installed and only install new components or update existing ones to the latest version. ## Activation If an imx500 camera is connected to one of the CSI ports, this will be recognized by the raspiCamSrv Flask server at startup and is shown in the [Info/Cameras](./Information_Cam.md#ai-features) dialog. By default, the imx500 camera is handled like a normal CSI camera. To activate Camera AI support, you need to check *Use Camera AI* in the [Settings](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features) dialog. ## Neural Network Configuration If *Use Camera AI* is activated, the [Config](./Configuration.md) menu will show an additional submenu [AI Configuration](./Configuration_AI.md) Here you can choose a Task to be executed by the model and, subsequently, one of the models which implements the chosen task. In addition, you can choose a set of parameters which allow restricting detections by specific threshold values or by number. By default, inference results are visualized on the *lores* camera stream which is usually used for the Live View. If you need this visualization also on photos and/or videos, you need to activate visualization on the *main* stream. **NOTE** For Fotos and Videos, the [Stream Size](./Configuration.md#stream-size-width-height) for the *main* stream should be set to values similar to those of the *lores* stream. The reson is that currently text sizes and line width of the visualization do not scale with the stream size. ## Enabling a Neural Network Once the configuration is done, you can enable the selected network model in the [AI Configuration](./Configuration_AI.md#enable-ai) dialog. Now, when the camera is started, the network model will be loaded onto the camera system. This may take a while, depending on the platform used. In order to avoid irritations about long camera startup times, **raspiCamSrv** shows an [animation](./UserGuide.md#live-stream-at-camera-start) when the camera is started until the first frame is delevered by the camera. ## Recommendations The current implementation of imx500 AI support is initial and not all combinations of network models and other configuration settings have been tested. The following recommendations may serve as a starting point for gaining experience with this subject. ### Configuration Settings It is recommended setting the [Stream Size](./Configuration.md#stream-size-width-height) for *Photo* and *Video* to the same low value as for the *Live View* (e.g. **640 x 480**). This assures better representation of inference results on photos and videos, if these are activated. [Buffer Count](./Configuration.md#buffer-count) should be set to **12**, which is also used by the Picamera2 [demo implemetations](https://github.com/raspberrypi/picamera2/tree/main/examples/imx500) ### Neural Network Models The following models have been successfully tested within **raspiCamSrv** | Task | Model | |------------------|-------------------------------------------------------| | Classification | imx500_network_mobilenet_v2.rpk | | Object Detection | imx500_network_ssd_mobilenetv2_fpnlite_320x320_pp.rpk | | Pose Estimation | imx500_network_higherhrnet_coco.rpk | | Segmentation | imx500_network_deeplabv3plus.rpk ### Parameter Settings For performance reasons, you should enable *Draw Results on Stream main* only if needed for photos and videos. If you do not see any results, you may need to: - Decrease *Detection Threshold* If performance is low, for example on a Raspberry Pi Zero, you may need to: - Increase *Detection Threshold* in order to reduce the number of detections to be handled. ### Raspberry Models 4 and lower and Raspberry Pi Zero For these models, the *lores* stream needs to be in YUV format according to te [Picamera2 Manual](https://pip-assets.raspberrypi.com/categories/652-raspberry-pi-camera-module-2/documents/RP-008156-DS-2-picamera2-manual.pdf) ch. 4.2. The YUV format is not supported by all AI postprocessing pipelines. It is, therefore, recommended using the following [Configuration](./Configuration.md) settingss for **all** use cases except *Raw Photo*, : - Buffer Count: 12 - Sensor Mode: Custom - Stream: main - Stream Size: 640 x 480 - Stream Format: XBGR8888 For [AI Configuration](./Configuration_AI.md): - Disable: *Draw Results on Stream lores* - Enable: *Draw Results on Stream main* ### Case for Raspberry Pi Zero 2 The official case for the Raspberry Pi Zero models is not suitable for the AI Camera. As alternatives, there are 3D-printed cases, e.g. [Case for Raspberry Pi Zero Models with Camera](https://makerworld.com/en/models/1804527-case-for-raspberry-pi-zero-models-with-camera): ![Pi Zero Cover](./img/PI_zero_cover_3dp_ai.jpg) ## System Journal at Camera Startup Before the camera is actually started, Picamera2 issues a message to stdout with a hint about long starting times: ![imx500 Start](./img/Config_AI_log.jpg) Depending on whether a model is initially loaded or whether a previously loaded model is replaced, there will be kernel messages accompanying the loading process: ![imx500 Log](./img/Config_AI_log2.jpg) ## Live Stream Depending on the task of the neural network model, different types of visualizations will be used. Resulting information is drawn on the individual frames in a pre_callback which is executed by Picamera2 before the frames are supplied to applications. ### Classification ![Classification](./img/imx500_object_classification.jpg) With the *Classification* task, the neural network will identify individual classes out of a set of about 1000 classes (see [below](#classes)) When photos are taken, the Metadata include on the input and output tensors used by the neural network. ### Object Detection ![Object Detection](./img/imx500_object_detection.jpg) With the *Object Detection* task, the neural network will track and frame individual objects out of a set of about 1000 classes (see [below](#classes)) ### Segmentation ![Segmentation](./img/imx500_segmentation.jpg) With the *Segmentation* task, the neural network will track and mask individual objects. The implementation in **raspiCamSrv** differs from the [demo implementation](https://github.com/raspberrypi/picamera2/blob/main/examples/imx500/imx500_segmentation_demo.py), in which masks are burned on the GPU preview only. These overlays are, therefore, not available in the *lores* and *main* streams used by **raspiCamSrv** for Live view and Photo/Video. Since the inference rate of 19 of the neural network is significantly lower than the framerate (30), a larger part of frames is not processed by the neural network. To avoid flickering, overlays are cached and the last overlay is used for frames which have bypassed AI processing. As a consequence, when photos are taken, there will be photos for which the *Metadata* do not show tensor information, although a mask is visible on the photo. ## AI Camera as Second Camera You can also use an AI camera as Second Camera or, on a Pi 5, you can work with two AI cameras. [AI configuration](./Configuration_AI.md) is treated like any other [Camera Configuration](./Configuration.md). This means that, in order to preserve any configuration changes for a camera switch, you need to apply the [Save Active Camera Settings for Camera Switch](./CamMulticam.md#save-active-camera-settings-for-camera-switch) button before you replace the active camera with another camera or before you switch cameras. ## Classes The following classes are used in tasks *Classification* and *Object Detection*: ``` "classes": { "labels": [ "0:background", "1:tench, Tinca tinca", "2:goldfish, Carassius auratus", "3:great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias", "4:tiger shark, Galeocerdo cuvieri", "5:hammerhead, hammerhead shark", "6:electric ray, crampfish, numbfish, torpedo", "7:stingray", "8:cock", "9:hen", "10:ostrich, Struthio camelus", "11:brambling, Fringilla montifringilla", "12:goldfinch, Carduelis carduelis", "13:house finch, linnet, Carpodacus mexicanus", "14:junco, snowbird", "15:indigo bunting, indigo finch, indigo bird, Passerina cyanea", "16:robin, American robin, Turdus migratorius", "17:bulbul", "18:jay", "19:magpie", "20:chickadee", "21:water ouzel, dipper", "22:kite", "23:bald eagle, American eagle, Haliaeetus leucocephalus", "24:vulture", "25:great grey owl, great gray owl, Strix nebulosa", "26:European fire salamander, Salamandra salamandra", "27:common newt, Triturus vulgaris", "28:eft", "29:spotted salamander, Ambystoma maculatum", "30:axolotl, mud puppy, Ambystoma mexicanum", "31:bullfrog, Rana catesbeiana", "32:tree frog, tree-frog", "33:tailed frog, bell toad, ribbed toad, tailed toad, Ascaphus trui", "34:loggerhead, loggerhead turtle, Caretta caretta", "35:leatherback turtle, leatherback, leathery turtle, Dermochelys coriacea", "36:mud turtle", "37:terrapin", "38:box turtle, box tortoise", "39:banded gecko", "40:common iguana, iguana, Iguana iguana", "41:American chameleon, anole, Anolis carolinensis", "42:whiptail, whiptail lizard", "43:agama", "44:frilled lizard, Chlamydosaurus kingi", "45:alligator lizard", "46:Gila monster, Heloderma suspectum", "47:green lizard, Lacerta viridis", "48:African chameleon, Chamaeleo chamaeleon", "49:Komodo dragon, Komodo lizard, dragon lizard, giant lizard, Varanus komodoensis", "50:African crocodile, Nile crocodile, Crocodylus niloticus", "51:American alligator, Alligator mississipiensis", "52:triceratops", "53:thunder snake, worm snake, Carphophis amoenus", "54:ringneck snake, ring-necked snake, ring snake", "55:hognose snake, puff adder, sand viper", "56:green snake, grass snake", "57:king snake, kingsnake", "58:garter snake, grass snake", "59:water snake", "60:vine snake", "61:night snake, Hypsiglena torquata", "62:boa constrictor, Constrictor constrictor", "63:rock python, rock snake, Python sebae", "64:Indian cobra, Naja naja", "65:green mamba", "66:sea snake", "67:horned viper, cerastes, sand viper, horned asp, Cerastes cornutus", "68:diamondback, diamondback rattlesnake, Crotalus adamanteus", "69:sidewinder, horned rattlesnake, Crotalus cerastes", "70:trilobite", "71:harvestman, daddy longlegs, Phalangium opilio", "72:scorpion", "73:black and gold garden spider, Argiope aurantia", "74:barn spider, Araneus cavaticus", "75:garden spider, Aranea diademata", "76:black widow, Latrodectus mactans", "77:tarantula", "78:wolf spider, hunting spider", "79:tick", "80:centipede", "81:black grouse", "82:ptarmigan", "83:ruffed grouse, partridge, Bonasa umbellus", "84:prairie chicken, prairie grouse, prairie fowl", "85:peacock", "86:quail", "87:partridge", "88:African grey, African gray, Psittacus erithacus", "89:macaw", "90:sulphur-crested cockatoo, Kakatoe galerita, Cacatua galerita", "91:lorikeet", "92:coucal", "93:bee eater", "94:hornbill", "95:hummingbird", "96:jacamar", "97:toucan", "98:drake", "99:red-breasted merganser, Mergus serrator", "100:goose", "101:black swan, Cygnus atratus", "102:tusker", "103:echidna, spiny anteater, anteater", "104:platypus, duckbill, duckbilled platypus, duck-billed platypus, Ornithorhynchus anatinus", "105:wallaby, brush kangaroo", "106:koala, koala bear, kangaroo bear, native bear, Phascolarctos cinereus", "107:wombat", "108:jellyfish", "109:sea anemone, anemone", "110:brain coral", "111:flatworm, platyhelminth", "112:nematode, nematode worm, roundworm", "113:conch", "114:snail", "115:slug", "116:sea slug, nudibranch", "117:chiton, coat-of-mail shell, sea cradle, polyplacophore", "118:chambered nautilus, pearly nautilus, nautilus", "119:Dungeness crab, Cancer magister", "120:rock crab, Cancer irroratus", "121:fiddler crab", "122:king crab, Alaska crab, Alaskan king crab, Alaska king crab, Paralithodes camtschatica", "123:American lobster, Northern lobster, Maine lobster, Homarus americanus", "124:spiny lobster, langouste, rock lobster, crawfish, crayfish, sea crawfish", "125:crayfish, crawfish, crawdad, crawdaddy", "126:hermit crab", "127:isopod", "128:white stork, Ciconia ciconia", "129:black stork, Ciconia nigra", "130:spoonbill", "131:flamingo", "132:little blue heron, Egretta caerulea", "133:American egret, great white heron, Egretta albus", "134:bittern", "135:crane", "136:limpkin, Aramus pictus", "137:European gallinule, Porphyrio porphyrio", "138:American coot, marsh hen, mud hen, water hen, Fulica americana", "139:bustard", "140:ruddy turnstone, Arenaria interpres", "141:red-backed sandpiper, dunlin, Erolia alpina", "142:redshank, Tringa totanus", "143:dowitcher", "144:oystercatcher, oyster catcher", "145:pelican", "146:king penguin, Aptenodytes patagonica", "147:albatross, mollymawk", "148:grey whale, gray whale, devilfish, Eschrichtius gibbosus, Eschrichtius robustus", "149:killer whale, killer, orca, grampus, sea wolf, Orcinus orca", "150:dugong, Dugong dugon", "151:sea lion", "152:Chihuahua", "153:Japanese spaniel", "154:Maltese dog, Maltese terrier, Maltese", "155:Pekinese, Pekingese, Peke", "156:Shih-Tzu", "157:Blenheim spaniel", "158:papillon", "159:toy terrier", "160:Rhodesian ridgeback", "161:Afghan hound, Afghan", "162:basset, basset hound", "163:beagle", "164:bloodhound, sleuthhound", "165:bluetick", "166:black-and-tan coonhound", "167:Walker hound, Walker foxhound", "168:English foxhound", "169:redbone", "170:borzoi, Russian wolfhound", "171:Irish wolfhound", "172:Italian greyhound", "173:whippet", "174:Ibizan hound, Ibizan Podenco", "175:Norwegian elkhound, elkhound", "176:otterhound, otter hound", "177:Saluki, gazelle hound", "178:Scottish deerhound, deerhound", "179:Weimaraner", "180:Staffordshire bullterrier, Staffordshire bull terrier", "181:American Staffordshire terrier, Staffordshire terrier, American pit bull terrier, pit bull terrier", "182:Bedlington terrier", "183:Border terrier", "184:Kerry blue terrier", "185:Irish terrier", "186:Norfolk terrier", "187:Norwich terrier", "188:Yorkshire terrier", "189:wire-haired fox terrier", "190:Lakeland terrier", "191:Sealyham terrier, Sealyham", "192:Airedale, Airedale terrier", "193:cairn, cairn terrier", "194:Australian terrier", "195:Dandie Dinmont, Dandie Dinmont terrier", "196:Boston bull, Boston terrier", "197:miniature schnauzer", "198:giant schnauzer", "199:standard schnauzer", "200:Scotch terrier, Scottish terrier, Scottie", "201:Tibetan terrier, chrysanthemum dog", "202:silky terrier, Sydney silky", "203:soft-coated wheaten terrier", "204:West Highland white terrier", "205:Lhasa, Lhasa apso", "206:flat-coated retriever", "207:curly-coated retriever", "208:golden retriever", "209:Labrador retriever", "210:Chesapeake Bay retriever", "211:German short-haired pointer", "212:vizsla, Hungarian pointer", "213:English setter", "214:Irish setter, red setter", "215:Gordon setter", "216:Brittany spaniel", "217:clumber, clumber spaniel", "218:English springer, English springer spaniel", "219:Welsh springer spaniel", "220:cocker spaniel, English cocker spaniel, cocker", "221:Sussex spaniel", "222:Irish water spaniel", "223:kuvasz", "224:schipperke", "225:groenendael", "226:malinois", "227:briard", "228:kelpie", "229:komondor", "230:Old English sheepdog, bobtail", "231:Shetland sheepdog, Shetland sheep dog, Shetland", "232:collie", "233:Border collie", "234:Bouvier des Flandres, Bouviers des Flandres", "235:Rottweiler", "236:German shepherd, German shepherd dog, German police dog, alsatian", "237:Doberman, Doberman pinscher", "238:miniature pinscher", "239:Greater Swiss Mountain dog", "240:Bernese mountain dog", "241:Appenzeller", "242:EntleBucher", "243:boxer", "244:bull mastiff", "245:Tibetan mastiff", "246:French bulldog", "247:Great Dane", "248:Saint Bernard, St Bernard", "249:Eskimo dog, husky", "250:malamute, malemute, Alaskan malamute", "251:Siberian husky", "252:dalmatian, coach dog, carriage dog", "253:affenpinscher, monkey pinscher, monkey dog", "254:basenji", "255:pug, pug-dog", "256:Leonberg", "257:Newfoundland, Newfoundland dog", "258:Great Pyrenees", "259:Samoyed, Samoyede", "260:Pomeranian", "261:chow, chow chow", "262:keeshond", "263:Brabancon griffon", "264:Pembroke, Pembroke Welsh corgi", "265:Cardigan, Cardigan Welsh corgi", "266:toy poodle", "267:miniature poodle", "268:standard poodle", "269:Mexican hairless", "270:timber wolf, grey wolf, gray wolf, Canis lupus", "271:white wolf, Arctic wolf, Canis lupus tundrarum", "272:red wolf, maned wolf, Canis rufus, Canis niger", "273:coyote, prairie wolf, brush wolf, Canis latrans", "274:dingo, warrigal, warragal, Canis dingo", "275:dhole, Cuon alpinus", "276:African hunting dog, hyena dog, Cape hunting dog, Lycaon pictus", "277:hyena, hyaena", "278:red fox, Vulpes vulpes", "279:kit fox, Vulpes macrotis", "280:Arctic fox, white fox, Alopex lagopus", "281:grey fox, gray fox, Urocyon cinereoargenteus", "282:tabby, tabby cat", "283:tiger cat", "284:Persian cat", "285:Siamese cat, Siamese", "286:Egyptian cat", "287:cougar, puma, catamount, mountain lion, painter, panther, Felis concolor", "288:lynx, catamount", "289:leopard, Panthera pardus", "290:snow leopard, ounce, Panthera uncia", "291:jaguar, panther, Panthera onca, Felis onca", "292:lion, king of beasts, Panthera leo", "293:tiger, Panthera tigris", "294:cheetah, chetah, Acinonyx jubatus", "295:brown bear, bruin, Ursus arctos", "296:American black bear, black bear, Ursus americanus, Euarctos americanus", "297:ice bear, polar bear, Ursus Maritimus, Thalarctos maritimus", "298:sloth bear, Melursus ursinus, Ursus ursinus", "299:mongoose", "300:meerkat, mierkat", "301:tiger beetle", "302:ladybug, ladybeetle, lady beetle, ladybird, ladybird beetle", "303:ground beetle, carabid beetle", "304:long-horned beetle, longicorn, longicorn beetle", "305:leaf beetle, chrysomelid", "306:dung beetle", "307:rhinoceros beetle", "308:weevil", "309:fly", "310:bee", "311:ant, emmet, pismire", "312:grasshopper, hopper", "313:cricket", "314:walking stick, walkingstick, stick insect", "315:cockroach, roach", "316:mantis, mantid", "317:cicada, cicala", "318:leafhopper", "319:lacewing, lacewing fly", "320:dragonfly, darning needle, devil's darning needle, sewing needle, snake feeder, snake doctor, mosquito hawk, skeeter hawk", "321:damselfly", "322:admiral", "323:ringlet, ringlet butterfly", "324:monarch, monarch butterfly, milkweed butterfly, Danaus plexippus", "325:cabbage butterfly", "326:sulphur butterfly, sulfur butterfly", "327:lycaenid, lycaenid butterfly", "328:starfish, sea star", "329:sea urchin", "330:sea cucumber, holothurian", "331:wood rabbit, cottontail, cottontail rabbit", "332:hare", "333:Angora, Angora rabbit", "334:hamster", "335:porcupine, hedgehog", "336:fox squirrel, eastern fox squirrel, Sciurus niger", "337:marmot", "338:beaver", "339:guinea pig, Cavia cobaya", "340:sorrel", "341:zebra", "342:hog, pig, grunter, squealer, Sus scrofa", "343:wild boar, boar, Sus scrofa", "344:warthog", "345:hippopotamus, hippo, river horse, Hippopotamus amphibius", "346:ox", "347:water buffalo, water ox, Asiatic buffalo, Bubalus bubalis", "348:bison", "349:ram, tup", "350:bighorn, bighorn sheep, cimarron, Rocky Mountain bighorn, Rocky Mountain sheep, Ovis canadensis", "351:ibex, Capra ibex", "352:hartebeest", "353:impala, Aepyceros melampus", "354:gazelle", "355:Arabian camel, dromedary, Camelus dromedarius", "356:llama", "357:weasel", "358:mink", "359:polecat, fitch, foulmart, foumart, Mustela putorius", "360:black-footed ferret, ferret, Mustela nigripes", "361:otter", "362:skunk, polecat, wood pussy", "363:badger", "364:armadillo", "365:three-toed sloth, ai, Bradypus tridactylus", "366:orangutan, orang, orangutang, Pongo pygmaeus", "367:gorilla, Gorilla gorilla", "368:chimpanzee, chimp, Pan troglodytes", "369:gibbon, Hylobates lar", "370:siamang, Hylobates syndactylus, Symphalangus syndactylus", "371:guenon, guenon monkey", "372:patas, hussar monkey, Erythrocebus patas", "373:baboon", "374:macaque", "375:langur", "376:colobus, colobus monkey", "377:proboscis monkey, Nasalis larvatus", "378:marmoset", "379:capuchin, ringtail, Cebus capucinus", "380:howler monkey, howler", "381:titi, titi monkey", "382:spider monkey, Ateles geoffroyi", "383:squirrel monkey, Saimiri sciureus", "384:Madagascar cat, ring-tailed lemur, Lemur catta", "385:indri, indris, Indri indri, Indri brevicaudatus", "386:Indian elephant, Elephas maximus", "387:African elephant, Loxodonta africana", "388:lesser panda, red panda, panda, bear cat, cat bear, Ailurus fulgens", "389:giant panda, panda, panda bear, coon bear, Ailuropoda melanoleuca", "390:barracouta, snoek", "391:eel", "392:coho, cohoe, coho salmon, blue jack, silver salmon, Oncorhynchus kisutch", "393:rock beauty, Holocanthus tricolor", "394:anemone fish", "395:sturgeon", "396:gar, garfish, garpike, billfish, Lepisosteus osseus", "397:lionfish", "398:puffer, pufferfish, blowfish, globefish", "399:abacus", "400:abaya", "401:academic gown, academic robe, judge's robe", "402:accordion, piano accordion, squeeze box", "403:acoustic guitar", "404:aircraft carrier, carrier, flattop, attack aircraft carrier", "405:airliner", "406:airship, dirigible", "407:altar", "408:ambulance", "409:amphibian, amphibious vehicle", "410:analog clock", "411:apiary, bee house", "412:apron", "413:ashcan, trash can, garbage can, wastebin, ash bin, ash-bin, ashbin, dustbin, trash barrel, trash bin", "414:assault rifle, assault gun", "415:backpack, back pack, knapsack, packsack, rucksack, haversack", "416:bakery, bakeshop, bakehouse", "417:balance beam, beam", "418:balloon", "419:ballpoint, ballpoint pen, ballpen, Biro", "420:Band Aid", "421:banjo", "422:bannister, banister, balustrade, balusters, handrail", "423:barbell", "424:barber chair", "425:barbershop", "426:barn", "427:barometer", "428:barrel, cask", "429:barrow, garden cart, lawn cart, wheelbarrow", "430:baseball", "431:basketball", "432:bassinet", "433:bassoon", "434:bathing cap, swimming cap", "435:bath towel", "436:bathtub, bathing tub, bath, tub", "437:beach wagon, station wagon, wagon, estate car, beach waggon, station waggon, waggon", "438:beacon, lighthouse, beacon light, pharos", "439:beaker", "440:bearskin, busby, shako", "441:beer bottle", "442:beer glass", "443:bell cote, bell cot", "444:bib", "445:bicycle-built-for-two, tandem bicycle, tandem", "446:bikini, two-piece", "447:binder, ring-binder", "448:binoculars, field glasses, opera glasses", "449:birdhouse", "450:boathouse", "451:bobsled, bobsleigh, bob", "452:bolo tie, bolo, bola tie, bola", "453:bonnet, poke bonnet", "454:bookcase", "455:bookshop, bookstore, bookstall", "456:bottlecap", "457:bow", "458:bow tie, bow-tie, bowtie", "459:brass, memorial tablet, plaque", "460:brassiere, bra, bandeau", "461:breakwater, groin, groyne, mole, bulwark, seawall, jetty", "462:breastplate, aegis, egis", "463:broom", "464:bucket, pail", "465:buckle", "466:bulletproof vest", "467:bullet train, bullet", "468:butcher shop, meat market", "469:cab, hack, taxi, taxicab", "470:caldron, cauldron", "471:candle, taper, wax light", "472:cannon", "473:canoe", "474:can opener, tin opener", "475:cardigan", "476:car mirror", "477:carousel, carrousel, merry-go-round, roundabout, whirligig", "478:carpenter's kit, tool kit", "479:carton", "480:car wheel", "481:cash machine, cash dispenser, automated teller machine, automatic teller machine, automated teller, automatic teller, ATM", "482:cassette", "483:cassette player", "484:castle", "485:catamaran", "486:CD player", "487:cello, violoncello", "488:cellular telephone, cellular phone, cellphone, cell, mobile phone", "489:chain", "490:chainlink fence", "491:chain mail, ring mail, mail, chain armor, chain armour, ring armor, ring armour", "492:chain saw, chainsaw", "493:chest", "494:chiffonier, commode", "495:chime, bell, gong", "496:china cabinet, china closet", "497:Christmas stocking", "498:church, church building", "499:cinema, movie theater, movie theatre, movie house, picture palace", "500:cleaver, meat cleaver, chopper", "501:cliff dwelling", "502:cloak", "503:clog, geta, patten, sabot", "504:cocktail shaker", "505:coffee mug", "506:coffeepot", "507:coil, spiral, volute, whorl, helix", "508:combination lock", "509:computer keyboard, keypad", "510:confectionery, confectionary, candy store", "511:container ship, containership, container vessel", "512:convertible", "513:corkscrew, bottle screw", "514:cornet, horn, trumpet, trump", "515:cowboy boot", "516:cowboy hat, ten-gallon hat", "517:cradle", "518:crane", "519:crash helmet", "520:crate", "521:crib, cot", "522:Crock Pot", "523:croquet ball", "524:crutch", "525:cuirass", "526:dam, dike, dyke", "527:desk", "528:desktop computer", "529:dial telephone, dial phone", "530:diaper, nappy, napkin", "531:digital clock", "532:digital watch", "533:dining table, board", "534:dishrag, dishcloth", "535:dishwasher, dish washer, dishwashing machine", "536:disk brake, disc brake", "537:dock, dockage, docking facility", "538:dogsled, dog sled, dog sleigh", "539:dome", "540:doormat, welcome mat", "541:drilling platform, offshore rig", "542:drum, membranophone, tympan", "543:drumstick", "544:dumbbell", "545:Dutch oven", "546:electric fan, blower", "547:electric guitar", "548:electric locomotive", "549:entertainment center", "550:envelope", "551:espresso maker", "552:face powder", "553:feather boa, boa", "554:file, file cabinet, filing cabinet", "555:fireboat", "556:fire engine, fire truck", "557:fire screen, fireguard", "558:flagpole, flagstaff", "559:flute, transverse flute", "560:folding chair", "561:football helmet", "562:forklift", "563:fountain", "564:fountain pen", "565:four-poster", "566:freight car", "567:French horn, horn", "568:frying pan, frypan, skillet", "569:fur coat", "570:garbage truck, dustcart", "571:gasmask, respirator, gas helmet", "572:gas pump, gasoline pump, petrol pump, island dispenser", "573:goblet", "574:go-kart", "575:golf ball", "576:golfcart, golf cart", "577:gondola", "578:gong, tam-tam", "579:gown", "580:grand piano, grand", "581:greenhouse, nursery, glasshouse", "582:grille, radiator grille", "583:grocery store, grocery, food market, market", "584:guillotine", "585:hair slide", "586:hair spray", "587:half track", "588:hammer", "589:hamper", "590:hand blower, blow dryer, blow drier, hair dryer, hair drier", "591:hand-held computer, hand-held microcomputer", "592:handkerchief, hankie, hanky, hankey", "593:hard disc, hard disk, fixed disk", "594:harmonica, mouth organ, harp, mouth harp", "595:harp", "596:harvester, reaper", "597:hatchet", "598:holster", "599:home theater, home theatre", "600:honeycomb", "601:hook, claw", "602:hoopskirt, crinoline", "603:horizontal bar, high bar", "604:horse cart, horse-cart", "605:hourglass", "606:iPod", "607:iron, smoothing iron", "608:jack-o'-lantern", "609:jean, blue jean, denim", "610:jeep, landrover", "611:jersey, T-shirt, tee shirt", "612:jigsaw puzzle", "613:jinrikisha, ricksha, rickshaw", "614:joystick", "615:kimono", "616:knee pad", "617:knot", "618:lab coat, laboratory coat", "619:ladle", "620:lampshade, lamp shade", "621:laptop, laptop computer", "622:lawn mower, mower", "623:lens cap, lens cover", "624:letter opener, paper knife, paperknife", "625:library", "626:lifeboat", "627:lighter, light, igniter, ignitor", "628:limousine, limo", "629:liner, ocean liner", "630:lipstick, lip rouge", "631:Loafer", "632:lotion", "633:loudspeaker, speaker, speaker unit, loudspeaker system, speaker system", "634:loupe, jeweler's loupe", "635:lumbermill, sawmill", "636:magnetic compass", "637:mailbag, postbag", "638:mailbox, letter box", "639:maillot", "640:maillot, tank suit", "641:manhole cover", "642:maraca", "643:marimba, xylophone", "644:mask", "645:matchstick", "646:maypole", "647:maze, labyrinth", "648:measuring cup", "649:medicine chest, medicine cabinet", "650:megalith, megalithic structure", "651:microphone, mike", "652:microwave, microwave oven", "653:military uniform", "654:milk can", "655:minibus", "656:miniskirt, mini", "657:minivan", "658:missile", "659:mitten", "660:mixing bowl", "661:mobile home, manufactured home", "662:Model T", "663:modem", "664:monastery", "665:monitor", "666:moped", "667:mortar", "668:mortarboard", "669:mosque", "670:mosquito net", "671:motor scooter, scooter", "672:mountain bike, all-terrain bike, off-roader", "673:mountain tent", "674:mouse, computer mouse", "675:mousetrap", "676:moving van", "677:muzzle", "678:nail", "679:neck brace", "680:necklace", "681:nipple", "682:notebook, notebook computer", "683:obelisk", "684:oboe, hautboy, hautbois", "685:ocarina, sweet potato", "686:odometer, hodometer, mileometer, milometer", "687:oil filter", "688:organ, pipe organ", "689:oscilloscope, scope, cathode-ray oscilloscope, CRO", "690:overskirt", "691:oxcart", "692:oxygen mask", "693:packet", "694:paddle, boat paddle", "695:paddlewheel, paddle wheel", "696:padlock", "697:paintbrush", "698:pajama, pyjama, pj's, jammies", "699:palace", "700:panpipe, pandean pipe, syrinx", "701:paper towel", "702:parachute, chute", "703:parallel bars, bars", "704:park bench", "705:parking meter", "706:passenger car, coach, carriage", "707:patio, terrace", "708:pay-phone, pay-station", "709:pedestal, plinth, footstall", "710:pencil box, pencil case", "711:pencil sharpener", "712:perfume, essence", "713:Petri dish", "714:photocopier", "715:pick, plectrum, plectron", "716:pickelhaube", "717:picket fence, paling", "718:pickup, pickup truck", "719:pier", "720:piggy bank, penny bank", "721:pill bottle", "722:pillow", "723:ping-pong ball", "724:pinwheel", "725:pirate, pirate ship", "726:pitcher, ewer", "727:plane, carpenter's plane, woodworking plane", "728:planetarium", "729:plastic bag", "730:plate rack", "731:plow, plough", "732:plunger, plumber's helper", "733:Polaroid camera, Polaroid Land camera", "734:pole", "735:police van, police wagon, paddy wagon, patrol wagon, wagon, black Maria", "736:poncho", "737:pool table, billiard table, snooker table", "738:pop bottle, soda bottle", "739:pot, flowerpot", "740:potter's wheel", "741:power drill", "742:prayer rug, prayer mat", "743:printer", "744:prison, prison house", "745:projectile, missile", "746:projector", "747:puck, hockey puck", "748:punching bag, punch bag, punching ball, punchball", "749:purse", "750:quill, quill pen", "751:quilt, comforter, comfort, puff", "752:racer, race car, racing car", "753:racket, racquet", "754:radiator", "755:radio, wireless", "756:radio telescope, radio reflector", "757:rain barrel", "758:recreational vehicle, RV, R.V.", "759:reel", "760:reflex camera", "761:refrigerator, icebox", "762:remote control, remote", "763:restaurant, eating house, eating place, eatery", "764:revolver, six-gun, six-shooter", "765:rifle", "766:rocking chair, rocker", "767:rotisserie", "768:rubber eraser, rubber, pencil eraser", "769:rugby ball", "770:rule, ruler", "771:running shoe", "772:safe", "773:safety pin", "774:saltshaker, salt shaker", "775:sandal", "776:sarong", "777:sax, saxophone", "778:scabbard", "779:scale, weighing machine", "780:school bus", "781:schooner", "782:scoreboard", "783:screen, CRT screen", "784:screw", "785:screwdriver", "786:seat belt, seatbelt", "787:sewing machine", "788:shield, buckler", "789:shoe shop, shoe-shop, shoe store", "790:shoji", "791:shopping basket", "792:shopping cart", "793:shovel", "794:shower cap", "795:shower curtain", "796:ski", "797:ski mask", "798:sleeping bag", "799:slide rule, slipstick", "800:sliding door", "801:slot, one-armed bandit", "802:snorkel", "803:snowmobile", "804:snowplow, snowplough", "805:soap dispenser", "806:soccer ball", "807:sock", "808:solar dish, solar collector, solar furnace", "809:sombrero", "810:soup bowl", "811:space bar", "812:space heater", "813:space shuttle", "814:spatula", "815:speedboat", "816:spider web, spider's web", "817:spindle", "818:sports car, sport car", "819:spotlight, spot", "820:stage", "821:steam locomotive", "822:steel arch bridge", "823:steel drum", "824:stethoscope", "825:stole", "826:stone wall", "827:stopwatch, stop watch", "828:stove", "829:strainer", "830:streetcar, tram, tramcar, trolley, trolley car", "831:stretcher", "832:studio couch, day bed", "833:stupa, tope", "834:submarine, pigboat, sub, U-boat", "835:suit, suit of clothes", "836:sundial", "837:sunglass", "838:sunglasses, dark glasses, shades", "839:sunscreen, sunblock, sun blocker", "840:suspension bridge", "841:swab, swob, mop", "842:sweatshirt", "843:swimming trunks, bathing trunks", "844:swing", "845:switch, electric switch, electrical switch", "846:syringe", "847:table lamp", "848:tank, army tank, armored combat vehicle, armoured combat vehicle", "849:tape player", "850:teapot", "851:teddy, teddy bear", "852:television, television system", "853:tennis ball", "854:thatch, thatched roof", "855:theater curtain, theatre curtain", "856:thimble", "857:thresher, thrasher, threshing machine", "858:throne", "859:tile roof", "860:toaster", "861:tobacco shop, tobacconist shop, tobacconist", "862:toilet seat", "863:torch", "864:totem pole", "865:tow truck, tow car, wrecker", "866:toyshop", "867:tractor", "868:trailer truck, tractor trailer, trucking rig, rig, articulated lorry, semi", "869:tray", "870:trench coat", "871:tricycle, trike, velocipede", "872:trimaran", "873:tripod", "874:triumphal arch", "875:trolleybus, trolley coach, trackless trolley", "876:trombone", "877:tub, vat", "878:turnstile", "879:typewriter keyboard", "880:umbrella", "881:unicycle, monocycle", "882:upright, upright piano", "883:vacuum, vacuum cleaner", "884:vase", "885:vault", "886:velvet", "887:vending machine", "888:vestment", "889:viaduct", "890:violin, fiddle", "891:volleyball", "892:waffle iron", "893:wall clock", "894:wallet, billfold, notecase, pocketbook", "895:wardrobe, closet, press", "896:warplane, military plane", "897:washbasin, handbasin, washbowl, lavabo, wash-hand basin", "898:washer, automatic washer, washing machine", "899:water bottle", "900:water jug", "901:water tower", "902:whiskey jug", "903:whistle", "904:wig", "905:window screen", "906:window shade", "907:Windsor tie", "908:wine bottle", "909:wing", "910:wok", "911:wooden spoon", "912:wool, woolen, woollen", "913:worm fence, snake fence, snake-rail fence, Virginia fence", "914:wreck", "915:yawl", "916:yurt", "917:web site, website, internet site, site", "918:comic book", "919:crossword puzzle, crossword", "920:street sign", "921:traffic light, traffic signal, stoplight", "922:book jacket, dust cover, dust jacket, dust wrapper", "923:menu", "924:plate", "925:guacamole", "926:consomme", "927:hot pot, hotpot", "928:trifle", "929:ice cream, icecream", "930:ice lolly, lolly, lollipop, popsicle", "931:French loaf", "932:bagel, beigel", "933:pretzel", "934:cheeseburger", "935:hotdog, hot dog, red hot", "936:mashed potato", "937:head cabbage", "938:broccoli", "939:cauliflower", "940:zucchini, courgette", "941:spaghetti squash", "942:acorn squash", "943:butternut squash", "944:cucumber, cuke", "945:artichoke, globe artichoke", "946:bell pepper", "947:cardoon", "948:mushroom", "949:Granny Smith", "950:strawberry", "951:orange", "952:lemon", "953:fig", "954:pineapple, ananas", "955:banana", "956:jackfruit, jak, jack", "957:custard apple", "958:pomegranate", "959:hay", "960:carbonara", "961:chocolate sauce, chocolate syrup", "962:dough", "963:meat loaf, meatloaf", "964:pizza, pizza pie", "965:potpie", "966:burrito", "967:red wine", "968:espresso", "969:cup", "970:eggnog", "971:alp", "972:bubble", "973:cliff, drop, drop-off", "974:coral reef", "975:geyser", "976:lakeside, lakeshore", "977:promontory, headland, head, foreland", "978:sandbar, sand bar", "979:seashore, coast, seacoast, sea-coast", "980:valley, vale", "981:volcano", "982:ballplayer, baseball player", "983:groom, bridegroom", "984:scuba diver", "985:rapeseed", "986:daisy", "987:yellow lady's slipper, yellow lady-slipper, Cypripedium calceolus, Cypripedium parviflorum", "988:corn", "989:acorn", "990:hip, rose hip, rosehip", "991:buckeye, horse chestnut, conker", "992:coral fungus", "993:agaric", "994:gyromitra", "995:stinkhorn, carrion fungus", "996:earthstar", "997:hen-of-the-woods, hen of the woods, Polyporus frondosus, Grifola frondosa", "998:bolete", "999:ear, spike, capitulum", "1000:toilet tissue, toilet paper, bathroom tissue" ] } ``` ================================================ FILE: docs/Authentication.md ================================================ # raspiCamSrv Authorization [![Up](img/goup.gif)](./UserGuide.md) Access to the raspiCamSrv server requires login with Username and Password. A user session will live as long as the browser remains open, even if the tab with a **raspiCamSrv** dialog has been closed. The basic principle is that the first user in the system will be automatically registered as SuperUser. Only the SuperUser will be able to register new users or remove users from the system. After the database has been initialized with ```flask --app raspiCamSrv init-db``` (see [RaspiCamSrv Installation](./installation.md)) Step 11), there is no user in the database. In this situation, any connect to the server will open the *Register* screen: ![Register Initial](./img/Auth_RegisterInitial.jpg) Now, the SuperUser can complete his registration and will then be redirected to the *Log In* screen: ![Log In](./img/Auth_Login.jpg) From now on, the *Register* screen will no longer be available through the menu. Also, direct access through the *Register* screen URL will only be allowed for the SuperUser. Other users will be redirected to the *Live* screen. ## User Management For management of users, the *Settings* screen has an additional section *Users* which is visible only for the SuperUser: ![User Management](./img/Auth_UserManagement.jpg) The list shows all registered users with - unique user *ID* - user *Name* - *Initial*, indicating whether the user has been initially created by the SuperUser and needs to change password on first log-in. - *SuperUser*, indicating the user registered as SuperUser The SuperUser can - register new users using the *Register New User* button - remove users which have been selected in the list ## Password Users with flag "Initial" will automatically be requested to change their password when they log in for the first time. All users can change their password before they are logged in. ![Password](./img/Auth_Password.jpg) After the password has been successfully changed, the *Log In* screen will be opened. ## Old User Schema The functionality described above is available for systems installed after Feb. 15, 2024. Systems installed before but updated (git pull) later, still work with the old user schema. In this case, all users are considered SuperUsers. The [User Management](#user-management) functionality in the *Settings* screen will be available for all users. However, a hint is shown to update the database schema: ![User Management Old](./img/Auth_UserManagement_old.jpg) ================================================ FILE: docs/Background Processes.md ================================================ # raspiCamSrv Tasks and Background Processes [![Up](img/goup.gif)](./UserGuide.md) The figure below gives an overview of the different tasks available in **raspiCamSrv** and their relation to **raspiCamSrv** [Configurations](./Configuration.md) and camera strams. For more information on the components, see the [Picamera2 manual](./picamera2-manual.pdf), chapter 4.2. ![stream usage](./img/CameraStreamUsage.jpg) The tasks marked in green are executed in background processes (Threads) and may run simultaneously. The status of each of these processes is indicated with [status indicators](./UserGuide.md#process-status-indicators): ![Status Indicator](./img/ProcessIndicator4.jpg) ## Default Configuration The association between **raspiCamSrv** [Configurations](./Configuration.md) and camera streams shown in the figure above, is the default configuration. In addition, default values for other configuration parameters are harmonized in such a way that all background processes can run simultaneously. This is especially important for the live stream which will remain active while a video is recorded, while photos are taken or while a Photo Series is executed. **raspiCamSrv** merges the different configurations to a single one which is applied when the camera is started. This requires that the following configuration parameters must have the same values for the different configurations: - *Transform* - *Colour Space* - *Queue* The values for *Buffer Count* can be different. In the merge process, the largest number of buffers will be selected. ## Configuration Changes All configuration scan be changed, including the association between configuration and camera stream (except raw). If a configuration change, for example Transform, is made for a single configuration, for example *Video*, it is no longer possible to use a common configuration for all tasks. If then, for example, a video is recorded, the video thread needs to run in exclusive mode because it cannot share configuration with the Live Stream. For this purpose: 1. The Live Stream must be stopped and paused during video recording 2. The Encoder for the Live Stream must be stopped 3. The camera must be stopped 4. The camera must be configured with the Video configuration 5. The camera must be started 6. The encoder for video must be started while the video is being recorded 7. The encoder must be stopped when video recording is finished 8. The camera must be stopped 9. The camera must be configured for the LiveStream, including eventally compatible configurations 10. The camera must be started 11. The MJPEG encoder for Live Stream must be started 12. The Live Stream Thread must be started In case of harmonized configurations, only steps 7 and 8 would have been required. ================================================ FILE: docs/Cam.md ================================================ # Cam - Camera Usage [![Up](img/goup.gif)](./UserGuide.md) ![Cam Menu](img/CamMenu.jpg) This menu gives access to the available high-level camera usage scenarios, in particular in the case of multiple cameras connected to a Raspberry Pi (currently only Pi 5 can connect two CSI cameras). When connecting USB cameras, in addition to CSI cameras, you can in principle, get access to as many cameras as you can connect. However, raspiCamSrv can only operate up to 2 cameras simultaneusly. So, you will need to select which of the connected cameras (see [Info/Cameras](./Information_Cam.md)) shall be used as *Active Camera* and as *Second Camera*. - The [Web Cam](./CamWebcam.md) dialog demonstrates how to stream video or image from your cameras. - The [Multi-Cam](./CamMulticam.md) dialog allows controlling both cameras individually or synchronously. (This dialog is only available for multi-camera systems) - The [Camera Calibration](./CamCalibration.md) dialog can be used for calibration and rectification of stereo cameras. (This dialog is only available if *Stereo Vision* is activated in the [Settings](./Settings.md#activating-and-deactivating-stereo-vision) dialog) - The [Stereo-Cam](./CamStereo.md) dialog can be used for visualization of depth maps as well as for viewing and recording of 3D videos. (This dialog is only available if *Stereo Vision* is activated in the [Settings](./Settings.md#activating-and-deactivating-stereo-vision)) ================================================ FILE: docs/CamCalibration.md ================================================ # Camera Calibration [![Up](img/goup.gif)](./Cam.md) This dialog allows calibration of the [stereo camera system](./CamStereo.md#stereo-camera) in order to improve the quality of the stereo result. **NOTE**:The dialog is only accessible if [Stereo Vision](./Settings.md#activating-and-deactivating-stereo-vision) has been activated. **REMINDER**: When finished with calibration you will need to [Store Configuration](./Settings.md#configuration) in order to preserve the results over a server restart. ![Camera Calibration](img/CamCalibration1.jpg) When opened without existing calibration data, the dialog allows configuring the calibration process: - *Calibration Pattern* allows selecting the pattern to be used for calibration. Currently, only the [Chessboard pattern from OpenCV](https://github.com/opencv/opencv/blob/4.x/doc/pattern.png) is supported. This pattern needs to be printed or displayed on a tablet for being used during the calibration process. - *Pattern Size* specifies the number of columns and rows of identifiable vertices of the pattern. 9x6 is appropriate for the standard chessboard pattern. (Only the inner vertices count) - In *Number of Photos required* you can specify how many photos you want to use for calibration. The dialog allows selecting as few as 5 photos but this is only to see how quality decreases with the number of photos. 20-30 will be a better choice. - *Number of Photos taken* will show the current number of photos. - *Rectify Scale* is a parameter used during rectification. "Valid Pixels Only" will include only valid pixels in the final result and will remove black areas. "All Pixels" will include all pixels in the final result of transformed images. ## Picture Taking The process of photo taking is automatic: When the pattern is presented to the cameras so that it is fully visible in both cameras, the system will try to find the specified number of corners. Only if this is successful, the image will be stored. After 2 seconds, the next pair of images will be analyzed until sufficient photos have been taken. The process is started with button **Start taking Pattern Photos** which will request a confirmation: ![Confirmation](img/CamCalibration_conf.jpg) and signal readiness to take a photo: ![Start](img/CamCalibration_start.jpg) Now you need to bring the pattern in the visible area and slowly change its orientation and position within the image areas. ![Photo](img/CamCalibration_photo.jpg) If not all required corners could be found at the current position, this will be indicated: ![No Photo](img/CamCalibration_no_photo.jpg) ## Picture Review After all required pictures have been taken, camera streaming stops and the stored pictures are presented for being reviewed: ![Review](img/CamCalibration_rev.jpg) A navigation bar is shown which allows scrolling through the taken images. Images with bad quality can be removed. *Show Corners* will present images where the found corners are shown: ![Review](img/CamCalibration_rev_corn.jpg) If photos have been removed, the missing ones need to be filled up using button "Continue taking Pattern Photos" ![Review](img/CamCalibration_continue.jpg) Button **Reset Calibration Photos** will remove all photos and reset the calibration process after a confirmation. ## Calibration When the required number of photos have been taken, you can continue with calibration. ![Result](img/CamCalibration_result.jpg) Display of the time of calibration as well as the *RMS Re-Projection Error* indicate that valid calibration data are available. The color with which the error is shown, (green, yellow, red) indicates the quality of the calibration: - < 0.5 px : excellent - 0.5 - 1 px : acceptable - > 1 px : poor ## Calibration Data Storage Calibration photos (with and without corners) are stored underneath the ```raspiCamSrv/static``` folder: ![Storage](img/CamCalibration_storage.jpg) ================================================ FILE: docs/CamMulticam.md ================================================ # Multi-Camera Control [![Up](img/goup.gif)](./Cam.md) **NOTE**: This dialog is only available for systems with two or more CSI or USB cameras connected (see [Info](./Information.md)). **raspiCamSrv** can simultaneously operate two cameras: the *Active Camera* and the *Second Camera*. If more cameras are connected and available (usually USB cameras), *Active* and *Second* Camera must be selected out of these. While live streams for both cameras are shown, this dialog allows photo taking and video recording either individually with each of the two cameras or simultaneously with both. ![Webcam](./img/CamMulticamUsb.jpg) The left side of the page always shows the active camera. The screenshot, above, shows a configuration with four available cameras. If more than two cameras are available, the stream titles are dropdown lists from which you can select *Active* or *Second Camera*. In every case, the other camera is not selectable because one camera cannot have two roles. If there are just two cameras available, the drop-down lists are replaced by normal text. When switching the cameras, either with the **Switch Cameras** button on this side or by changing the camera in the [Settings](./Settings.md#switching-the-active-camera), the streams will be exchanged. ### Buttons #### Photo / Raw / Video Every camera has an own set of action buttons which apply only to this camera. Their function is identical to that of the corresponding [buttons on the Live screen](./Phototaking.md) The resulting photos or place holders will not be shown on this page. They are stored in camera-specific subfolders and are accessible through the [Photos](./PhotoViewer.md) dialog For the active camera, the last photo or placeholder is shown in the [Photo Display of the Live screen](./Phototaking.md#photo-display) and can be added to the display buffer for inspection of meta data or histogram.. #### Photo - Both / Raw - Both / Video - Both When these buttons are pressed, the respective function is applied for both cameras. The media files will have the same name for both cameras, which is generated from the timestamp of command execution, but are stored in their camera-specific subfolder. This allows identifying photos which have been synchronously taken and videos which have been synchronously started. **NOTE**: Currently, the actions on both cameras are executed sequentially, so that there may be a small subsecond delay. #### Save Active Camera Settings for Camera Switch This button stores the current [Camera Configuration](./Configuration.md) for all configurations as well as the current [Controls](./CameraControls.md) settings for the active camera in a specific structure (streamingCfg) so that it can be reused for streaming in a case that the other camera has been activated. In addition, also the camera-related settings of [Trigger](./Trigger.md) ([Trigger/Motion](./TriggerMotion.md) and [Trigger/Camera](./TriggerCameraActions.md)) will be stored in the streamingCfg. (See also [Configuring MJPEG Stream and jpeg Photo](#configuring-mjpeg-stream-and-jpeg-photo)) #### Synchronize Configurations This button is disabled if the two cameras are of different model. If they are the same model, the button can be used to transfer the configuration, controls and camera-related Trigger settings from the active camera to the second camera. This is especially useful in the case of [Stereo Vision](./CamStereo.md) where both cameras should be operated with identical configuration. **NOTE**: the button does **not** save the settings for the active camera in the *streamingCfg* structure. This must be done explicitly and is recommended to be done before synchronizing. #### Switch Cameras With this button, you can switch the cameras so that the one sown on the right side will become the active camera. In case you have made changes of [Configuration](./Configuration.md), [Controls](./CameraControls.md) or camera-related [Trigger](./Trigger.md) settings ([Trigger/Motion](./TriggerMotion.md) and [Trigger/Camera](./TriggerCameraActions.md)) which have not yet been saved for Camera Switch, there will be a warning: ![CamSwitchWarning](./img/CamMulticamSwitchConfirm.jpg) To make sure your configuration changes survive the camera switxh, push [Save Active Camera Settings for Camera Switch](#save-active-camera-settings-for-camera-switch) ## Process Status Indicators [Process Status Indicators](./UserGuide.md#process-status-indicators) show whether a camera is currently recording video or not. This is done independently for the active camera ![StatusActiveCam](./img/ProcessIndicatorRecordingActive.jpg) and for the other camera ![StatusActiveCam](./img/ProcessIndicatorRecording2Active.jpg). **NOTE** that the recording status indicators are also activated when recording is started through the [API](./API.md) ## Configuring MJPEG Stream and jpeg Photo With **raspiCamSrv**, [Camera Configuration](./Configuration.md) and [Controls](./CameraControls.md) apply always to the active camera (which camera is the active one, can be selected in the [Settings](./Settings.md)). When the Flask server starts up without preloading stored configurations, the active camera and, if available, the second camera are preconfigured with parameter defaults. The entire [Camera Configuration](./Configuration.md) as well as the [Controls](./CameraControls.md) for both cameras are stored in a specific streaming datastructure. When [Camera Configuration](./Configuration.md) and/or [Controls](./CameraControls.md) for the active camera are modified, these settings will **not** be automatically stored in the streaming configuration. This must be actively done with the **Save Active Camera Settings for Camera Switch**. When cameras are switched, configuration and controls for the active camera will be replaced by those from the second camera stored in the streaming datastructure. In order to configure your camera setup, you can proceed as follows: 1. Select one of the cameras as active camera 2. Adjust the [Camera Configuration](./Configuration.md), for example *Transform* and/or *Sensor Mode* with *Stream Size* 3. Adjust the [Controls](./CameraControls.md), for example *focus*/*lensposition*, *zoom*, *AutoExposure* or others 4. When the setup is satisfactory, go to the *Multi-Cam* dialog and press the **Save Active Camera Settings for Camera Switch** button. 5. Then switch cameras with the **<<< Switch Cameras >>>** button. 6. Repeat steps 2. to 4. for the other camera 7. If you now switch cameras, each stream, photo, raw photo and video should show in the way specifically configured for the camera. 8. Now you can go to the [Settings](./Settings.md) screen and push the **Store Configuration** button in order to persist the streaming data along with the other configuration settings. 9. If you want the entire configuration, including the streaming configuration, to be loaded when the server starts up, check the related checkbox in the [Settings](./Settings.md) screen. ================================================ FILE: docs/CamStereo.md ================================================ # Stereo-Cam [![Up](img/goup.gif)](./Cam.md) This dialog features [stereo camera](#stereo-camera) capabilities and can be used for visualizing [Depth Maps](#depth-maps) and [3D Video](#3d-video). Both can also be [streamed independently](#streaming). **NOTE**:The dialog is only accessible if [Stereo Vision](./Settings.md#activating-and-deactivating-stereo-vision) has been activated. ![Stereo-Cam](img/CamStereoCam1.jpg) ## Stereo Camera A precondition for Stereo Vision with raspiCamSrv is a system which allows connecting a pair of non-USB cameras of the same model. These cameras need to be arranged as a stereo system with a typical human eye distance: ![Stereo-System](img/Pi_Camera_3_Case_Stereo_front.JPG) For a 3D-printable model, see [Raspberry Pi Camera 3 Stereo Case](https://makerworld.com/en/models/1742837-raspberry-pi-camera-3-stereo-case) **NOTE**: *Stereo Vision* needs to be activated in the [Settings](./Settings.md#activating-and-deactivating-stereo-vision) dialog. ## Implementation Stereo Vision in raspiCamSrv is based on [OpenCV](https://opencv.org/) and is inspired by a variety of examples on [LearnOpenCV](https://learnopencv.com/) such as [Making A Low-Cost Stereo Camera Using OpenCV](https://learnopencv.com/making-a-low-cost-stereo-camera-using-opencv/), from which also part of the code has been adapted and integrated with raspiCamSrv. ## Depth Maps (See [Wikipedia article on Depth Maps](https://en.wikipedia.org/wiki/Depth_map)) The Stereo-Cam dialog usually opens with the following layout: ![Stereo-Cam](img/CamStereoCam2.jpg) When opening, the system starts the Live view for both cameras as indicated by the [Process Status indicators](./UserGuide.md#process-status-indicators) and shows the streams of left and right camera in the upper part of the dialog. The lower left part allows configuration of the intended Stereo Vision: - *Intent* distinguishes the basic intent and allows selection between **Depth Map** and **3D Video**. - *Rectified Images*, when activated uses the rectified images (see [Camera Calibration](./CamCalibration.md)) instead of the original ones to construct the stereo image. - *Algorithm* allows selecting the Open CV algorithm to be used when constructing the depth map. - *Algorithm Reference* links to the respective references in Open CV ([Stereo Block Matching](https://docs.opencv.org/4.6.0/d9/dba/classcv_1_1StereoBM.html) or [Semi-Global Matching](https://docs.opencv.org/4.6.0/d2/d85/classcv_1_1StereoSGBM.html)) where the parameters for the different algorithms are explained. Pushing **Start** with *Depth Map* selected as Intent, will start a background process which applies the algorithm to the images of left and right video stream to produce a stream of depth map images: ![Stereo-Cam](img/CamStereoCam3.jpg) The activity of the Stereo thread, in addition to the two camera live view threads, is indicated by the different color of the [Process Status indicators](./UserGuide.md#process-status-indicators) for the two cameras. ## Streaming The Stereo stream can also be accessed through the URL which is shown underneath the stream in the dialog. This URL, when called independently from the raspiCamSrv UI, will automatically start all necessary streaming processes. The necessity of authentication can be configured in the [Settings](./Settings.md#configuring-authentication-for-streaming). ## 3D Video (See [Wikipedia article on 3D Video](https://en.wikipedia.org/wiki/3D_film)) Starting Stereo processing with *Intent* "3D Video" will show the scene as 3D video which needs to be viewed with Red-Cyan color 3D glasses to see the 3D effect: ![Stereo-Cam](img/CamStereoCam1.jpg) Here, you also have the possibility to record the stream as video. Videos, recorded in this way, are stored under ```\static\photos\camera_S``` and can be accessed with the [Photos](./PhotoViewer.md) dialog by selecting the "Stereo" camera: ![Stereo-Cam](img/Photos_Stereo.jpg) ================================================ FILE: docs/CamWebcam.md ================================================ # Web Cam Access [![Up](img/goup.gif)](./Cam.md) **raspiCamSrv** enables webcam functionalities with Raspberry Pi cameras as well as with USB cameras. For Pi 5 with two camera ports, both cameras can be streamed simultaneously. Alternatively, you can choose one of the connected USB cameras as *Active* or *Second* camera. This page shows the URLS for MJPEG streaming as well as for photo snapshots: ![Webcam](./img/CamWebcam2.jpg) The left side of the page always shows the active camera. If an additional camera is available, video stream and photo are shown on the right side. When switching the cameras, either with the **Switch Cameras** button in [Multi-Cam](./CamMulticam.md) or by changing the camera in the [Settings](./Settings.md#switching-the-active-camera), the streams will be exchanged. The *video_feed* endpoint will always refer to the active camera, which is also shown in the title bar. The *video_feed2* endpoint will always refer to the other camera, if available. The configuration and camera stream used for video and photo capture are indicated. The links shown on the page open a new browser window. ## Video Stream The video stream will always use the LIVE configuration. By default, this configuration uses the *lores* camera stream. The camera stream as well as its *stream size* can be configured in the [Configuration](./Configuration.md) screen. ## Photo Snapshot For the photo snapshot, you can choose between using the [LIVE configuration](./Configuration.md), usually configured with the *lores* stream with low resolution and a snapshot using the [FOTO configuration](./Configuration.md), usually configured with the *main* stream with high resolution. **NOTE**: Photo snapshots with high resolution assume an active live stream where the *lores* and the *main* streams of the camera are **simultaneously** configured (see [raspiCamSrv Tasks](./Background%20Processes.md)). If your [configurations](./Configuration.md) for LIVE and FOTO are incompatible (for example using a different *Colour Space* or when both use the *main* stream with different resolutions), the live stream will request exclusive camera access with only the LIVE configuration activated. In this case, the URL for the high resolution photo snapshot will return an image with the resolution configured for LIVE. If the live stream is active at the time when the photo snapshot is triggered, the snapshot will be taken immediately. Otherwise, the live stream will be activated before the snapshot is taken. This requres starting the camera and giving it time to gather sufficient information for the auto-algorithms, which results in larger latency (1 to 1.5 sec). ================================================ FILE: docs/CameraControls.md ================================================ # raspiCamSrv Camera Controls [![Up](img/goup.gif)](./LiveScreen.md) **NOTE**: The subsequent description is essentially related to CSI cameras. For USB cameras, see [Camera Controls for USB Cameras](./CameraControls_UsbCams.md). Picamera2 allows for a set of 36 camera control parameters which can be adjusted while the camera is active. From these, 8 parameters are just part of the image metadata and cannot be applied to the camera. In principle, the remaining 28 parameters can be applied to the camera at different times 1. As part of the [Camera Configuration](./Configuration.md). Here **raspiCamSrv** supports adding any of these parameters to the configuration. Control parameters included in the configuration have precedence over parameters not in the configuration. 2. After camera configuration before camera start. In **raspiCamSrv**, this applies for all photos and videos taken in a raspiCamSrv session. 3. After the camera has been started. In **raspiCamSrv**, this is only used for the live stream shown in the upper left quarter. If controls have been modified and submitted, they will be directly applied to the live stream. Modification of camera controls does not affect raw photos. **NOTE**: For USB Cameras, [Handling of Controls](./CameraControls_UsbCams.md) is slightly different. In **raspiCamSrv** all controls are explained through tooltips on the parameter name: ![Tooltip](img/Tooltip.jpg) The texts for the tooltips have been mainly taken from the [Picamera2 Manual](./picamera2-manual.pdf) or the underlying [libcamera documentation](https://libcamera.org/api-html/index.html). The controls are grouped into - [Focus Handling](./FocusHandling.md) - [Zoom & Pan](./ZoomPan.md) - [Auto-Exposure](./CameraControls_AutoExposure.md) - [Exposure](./CameraControls_Exposure.md) - [Image](./CameraControls_Image.md) - [Ctrl](./CameraControls_Ctrl.md) ## Basics All Control Parameter tabs (except Zoom and Ctrl) are structured similarly: - Every tab is a form. This means that all parameters shown can be modified without any effect. Only when the form is submitted through the **Submit** button, the settings are saved in the server configuration and directly applied to the live stream. - Every parameter has a preceeding checkbox, which allows activation/deactivation of the control parameter within the configuration. Only if the checkbox is checked, the parameter can be modified. If the checkbox is unchecked, the control is not effective independently from its value. - Individual parameters may have restictions either as distinct values or ranges of allowed values. It should normally not be possible to enter a value which will not be accepted by the camera. - Some camera systems support only a subset of the available control parameters. For example, Raspberry Pi camera models 1 and 2 have no focus management. This is recognized by **rapiCamSrv** and these parameters will not be presented to the user. - All forms for the different parameter groups on different tabs are part of the same web page. If values are modified without submitting, the modification will be visible even if another tab has been selected in the meantime. **If modifications are not submitted on their own tab, they will be lost in the next request/response cycle which can be triggered by a submit on another tab**. ================================================ FILE: docs/CameraControls_AutoExposure.md ================================================ # Camera Controls / Auto-Exposure [![Up](img/goup.gif)](./CameraControls.md) ![Auto-Exposure](img/AutoExposure.jpg) This tab includes parameters which control the Auto Exposure (AE) algorithm of the camera. ================================================ FILE: docs/CameraControls_Ctrl.md ================================================ # Camera Controls / Ctrl [![Up](img/goup.gif)](./CameraControls.md) ![Image](img/Live_Ctrl.jpg) This tab shows functional buttons which have been configured in [Settings/Live Buttons](./SettingsLButtons.md). Typically, these buttons will be used for control of devices which affect the camera position, such as servos controlling a Pan/Tilt device or a stepper motor for camera rotation. You could also switch LEDs for illumination or control a slider motor. When controlled through buttons on this page, the effect on the camera image can imediately be seen. ================================================ FILE: docs/CameraControls_Exposure.md ================================================ # Camera Controls / Exposure [![Up](img/goup.gif)](./CameraControls.md) ![Exposure](img/Exposure.jpg) This tab includes parameters related to exposure control. ================================================ FILE: docs/CameraControls_Image.md ================================================ # Camera Controls / Image [![Up](img/goup.gif)](./CameraControls.md) ![Image](img/Image.jpg) This tab includes parameters controlling the image appearance ================================================ FILE: docs/CameraControls_UsbCams.md ================================================ # Camera Controls for USB Cameras [![Up](img/goup.gif)](./CameraControls.md) **raspiCamSrv** supports a limited set of controls for USB Cameras: - Switch between auto focus and manual focus - Adjustment of focal distance - Zoom, pan, tilt - Enabling/disabling automatic white balance - Adjusting the color temperature in case of manual white balance - Adjusting the sharpness - Adjusting the contrast - Adjusting color saturation - adjusting brightness Whereas zoom/pan/tilt, as well as horizontal and vertical flipping, is controlled through OpenCV by modifying each individual frame delivered by the camera, the other controls are affected through the V4l2 (Video for Linux) interface to the camera. Every camera advertises the supported controls along with the related range of valid values. This information is [queried from the USB camera](./Information_Cam.md#determining-supported-controls) while **raspiCamSrv** initializes the camera information. The list of supported controls as well as their minimum, maximum, step and default values are used tho customize the individual controls screens to the currently active camera. ### Focus Handling USB Cameras ![Focus USB](./img/Focus_USB.jpg) ### Image Control USB Cameras ![Image](./img/Image_USB.jpg) In these dialogs, the input fields have value ranges and defaults appropriate for the active camera type. Value ranges and default values are also visible in the tooltips. ### Applying Controls to USB Cameras The supported controls ara applied to USB cameras through v4l2 commands, such as: ```v4l2-ctl --set-ctrl=contrast=50``` ================================================ FILE: docs/Configuration.md ================================================ # raspiCamSrv Camera Configuration Related Topics: - [AI Camera Configuration](./Configuration_AI.md) - [Camera Tuning](./Tuning.md) [![Up](img/goup.gif)](./UserGuide.md) Configuration parameters are so basic that they need to be applied before the camera is started. Picamera2 provides three configuration bases which can be taken as is for a specific use case or they can be adjusted in one or several aspects. These are: - Preview configuration for previews on a screen connected to the Raspberry Pi - Still configuration for photos - Video configuration for videos **raspiCamSrv** does not make direct use of these configurations. Instead, the following configurations can be fully configured: - Live View configuration which will be applied to the live stream - Photo configuration which will be applied when normal photos are taken - Raw Photo configuration which will be applied when raw photos are taken - Video configuration which will be applied when videos are recorded Configuration changes may have an impact on the way how tasks and background processes are executed. If specific parameters, such as [Transform](#transform) are changed for a specific use case only, for example for *Video*, video recording will require that the Live Stream is stopped and paused while the video is being recorded. For more details, see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md). #### USB Cameras The schema of configuration use cases, conceptually originating from Picamera2, is also retained for USB cameras. However, there are specific differences related to *Colour Space*, *Buffer Count*, *Queue* parameters. Also Controls cannot be included in the configuration as with Picamera2. A major difference is that USB cameras accessed through OpenCV, do not allow using different streams. As a consequence, live stream in parallel to photo taking or video recording would only be possible with identical configurations. Since this does not seam to make sense because of performance issues, **raspiCamSrv** always requests exclusive use of the camera when taking photos or recording videos. [Motion Capturing](./TriggerMotion.md) works in the same way for USB and CSI cameras. ## Configuration Tab The *Config* submenu includes a tab *Tuning* which is described in [raspiCamSrv Camera Tuning](./Tuning.md) and not here. An individual configuration tab is available for each use case. All tabs have essentially the same structure: As a general aspect, the green [Submenu](./UserGuide.md#submenu) bar includes an option to synchronize the aspect ratio of [stream sizes](#stream-size-width-height) across all configurations if this has been changed for the current configuration. If this option is activated after it was previously deactivated, all aspect ratios will be set to the one of the current configuration. **NOTE**: For USB cameras this option is unchecked and the checkbox is disabled becaus USB cameras scale and crop with the correct aspect ratio intrinsically. ![Configuration](img/Config.jpg) **As always: any modifications need to be submitted before they can be effective** ### Transform With *Transform*, you can specify whether the image needs to be flipped horizontally, vertically or both. The latter case is identical to rotation of 180°. **NOTE:** When this is modified for one configuration, the settings are automatically transferred to all other configurations.
This is necessary because **raspiCamSrv** simultaneously configures all streams (lores, main and raw) in order to allow using these in parallel for different purposes, such as live stream and video recording. The transform settings cannot be different for different streams at the same time. ### Colour Space This allows selecting one of the supported colour spaces ### Buffer Count Specifies the number of buffers used by the camera for the specific use case. Values are preset in accordance with corresponding settings of the Picamera2 standard use cases ### Queue Specifies whether the camera is allowed to queue up a frame ready for a capture request. ### Sensor Mode When **raspiCamSrv** starts up, one of the first things is to query the camera system for the available Sensor Modes. These can be inspected on the [Info](./Information.md) screen. These modes are offered for selection here. When a Sensor Mode is selected, its main characteristics are shown to the right. In addition to the available Sensor Modes, a "Custom" mode can be selected which allows especially to set the intended stream size (width and height if the image in pixels) For the *Raw Photo* use case, "Custom" cannot be selected. Raw photos will allways use the stream size of the selected Sensor Mode. ### Stream Specifies the stream to be used for the respective use case. The camera system supports three streams (see [Picamera2 Manual](./picamera2-manual.pdf)): - the **main** stream - the **lowres** stream - and the **raw** stream The latter is for raw data output which bypasses the image signal processor. For the *Raw Photo* use case, this is the only stream which can be selected. ### Stream Size (width, height) If a standard Sensor Mode has been selected, the size related to the mode is shown. If "Custom" has been selected as sensor mode, you may enter any size here (except for *Raw Photos*). Produced photos or videos will then be in the specified format. If the option to synchronize aspect ratios (right side of green Submenu bar) is selected, the *Stream Size*s for all other configurations will be adjusted to reproduce the aspect ratio of the current configuration. **NOTE:** If, after submitting a *Live View* configuration, you get an error message ``` lores Stream Size must not exceed main Stream Size (Photo)```, you need to go to the *Photo* configuration and adjust its *Stream Size* to the desired value. ### Stream size aligned with Sensor Modes If this option is activated, and if a stream size has been specified which is different from stream sizes of the available Sensor Modes, the Picamera2 Configuration will be asked to align the stream size. This may result in slightly different sizes which are, however, in better accordance with available Sensor Modes. ### Stream Format It can be selected from a number of pixel and image formats supported by Picamera2. For details, see [Picamera2 Manual](./picamera2-manual.pdf), Appendix A. Whereas for *Live Stream*, *Photo* and *Video* a format can be selected from the same list, the formats available for *Raw Photo* are different. These are specified in the Sensor Modes (see [Info](./Information.md)). **raspiCamSrv** queries these from Picamera2 at server startup and offers the found formats in the configuration screen. ### Display This parameter is just shown for completeness. It specifies the stream which shall be used for display on a monitor connected to the system. This is not relevant in the scenario addressed by **raspiCamSrv**, which is usually headless. ### Encode This specifies the stream which needs to be sent to the encoder. Settings are preconfigured and cannot be modified. Encoding is only necessary for the *Live View* (MJPEG encoding) and the *Video* use cases. For *Video*, the encoder depends on the video format chosen. ## Controls included in Configuration A configuration for a specific use case may also include Camera Controls. Actually, Picamera2 requires that at least one control is included in the configuration. **raspiCamSrv** preconfigurs a control with specific settings in accordance with the Picamera2 standard use case configurations. In addition, the button **Add Active Ctrls** will include all controls which are currently active in the [Camera Controls](./CameraControls.md) configuration. Values are taken as configured and cannot be modified here. In case a control parameter has a wrong value, it can be selected and then removed from the configuration with the **Remove Selected Ctrls** button. ================================================ FILE: docs/Configuration_AI.md ================================================ # raspiCamSrv Camera AI Configuration [![Up](img/goup.gif)](./Configuration.md) **NOTE**: This dialog is only available if the Active Camera is a [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) and if AI features have been activated in the [Settings Diaolog](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features) The IMX500 imaging sensor of the AI Camera can load a neural network model from a location on the Raspberry Pi and apply it to the individual frames delivered by the sensor. Inference data, generated by the model, are supplied to application2 through image meta data. This allows applications to evaluate these data and visualize them in the application context or through overlays on the individual frames. ![Configuration](img/Config_AI.jpg) The dialog has three distinct sections: ## Configuration for AI Here you specify the neural network file which will be loaded by the AI Camera. **NOTE**: The model can only be changed if the imx500 camera is currently not open. So, you need to wait until the Live View background process has automatically stopped (within 10 seconds). You can check the status Live stream of the Active Camera by clicking on the *Config* menu until you see the [Process Status Indicator](./UserGuide.md#process-status-indicators) for the Active Camera change to grey. Then, the fields for selection of the *AI Model File* will be enabled: - *Full Path to Folder with Model Files*
This is the folder where model files are stored.
With installation of the ```imx500-all``` package, a set of model files will already be available at
```/usr/share/imx500-models```.
Leaving the field empty will automatically set this as the default folder. - *Task*
Every model file has a specific *Task*.
You can currently choose between
- Classification
- Object Detection
- Pose Estimation
- Segmentation
When choosing one of these tasks, the entry for the *AI Model File* will be cleared to enforce selection. - *AI Model File*
After a *Task* has been chosen, raspiCamSrv will iterate the available model files in the specified folder and determine the *Task* for which these have been prepared.
Only files having the specified task will be offered for selection. - *Intrinsics*
Every model fileexposes a set of intrinsics characterizing its capabilities and operational details.
These will be shown here. Sources and details for various Reference Neural Network Models can be found on [https://github.com/raspberrypi/imx500-models](https://github.com/raspberrypi/imx500-models) Implementations within **raspiCamSrv** are based on the [Demo Code Examples](https://github.com/raspberrypi/picamera2/tree/main/examples/imx500) coming with [Picamera2](https://github.com/raspberrypi/picamera2). ## Settings This section includes several parameters by which the visualization of inference data can be adjusted. Only a subset of these parameters is applicable for a specific model. Parameters which do not apply, are disabled when a model has been selected. - *Top K Indices* (Classification)
Only the given number of indices with the highest rating will be visualized. - *Detection Threshold*
Only detections having a score larger than the given threshold will be visualized. - *IOU Threshold*
Specifies the IoU (Intersection over Union) threshold for object detection. - *Max Detections*
specifies the maximum number of detections to be visualized - *Draw Resulte on Stream lores*
If activated, inference results will be visualized on the *lores* stream which is usually used for the Live Stream [Configuration](./Configuration.md) - *Draw Resulte on Stream main*
If activated, inference results will be visualized on the *main* stream which is usually used for the Photo and Video [Configuration](./Configuration.md) **NOTE**: Text sizes and line thickness used for visualization, as used in the Picamera2 demos are optimized for lower resolution previews (e.g. 640x480). If you intend to visualize on photos and/or videos, it is recommended to set the [Stream Size](./Configuration.md#stream-size-width-height) to a 'Custom' size of about the same size as the *lores* stream for *Live View*. ## Enable AI Here you can enable the selected model together with the visualization parameters. Or you can disable a currently active model. Either way will require a confirmation. ================================================ FILE: docs/Console.md ================================================ # Console [![Up](img/goup.gif)](./UserGuide.md) The Console group of dialogs provides functions for user interactions with the Raspberry Pi. ![Console](./img/Console.jpg) - [Versatile Buttons](./ConsoleVButtons.md) allow interaction with the Operating System by running OS commands or scripts. - [Action Buttons](./ConsoleActionButtons.md) allow execution of various types [Actions] for interaction with GPIO-connected output devices or the camera system. ================================================ FILE: docs/ConsoleActionButtons.md ================================================ # Console - Action Buttons [![Up](img/goup.gif)](./Console.md) This page shows buttons which have been configured in the [Settings / Action Buttons](./SettingsAButtons.md): ![aButtons](./img/Console_AButtons.jpg) The example layout of this screenshot is based on the example configuration shown for the [Settings / Action Buttons](./SettingsAButtons.md) screen. ## Button Execution When the [Action](./TriggerActions.md), configured for a button is executed, the behavior for the action is slightly different compared to invocation of an action on behalf of a [Trigger](./TriggerTriggers.md). - If a device is busy at the time when a button is pressed, the action is not executed. Instead a "Device busy" information is shown in the status line: ![busy](./img/Console_AButtonsDeviceBusy.jpg) - Also, the action is not executed in an own thread but synchronously, so that the user needs to wait for action completion which is confirmed in the message line. ================================================ FILE: docs/ConsoleVButtons.md ================================================ # Console - Versatile Buttons [![Up](img/goup.gif)](./Console.md) This page shows buttons which have been configured in the [Settings / Versatile Buttons](./SettingsVButtons.md): ![vButtons](./img/Console_VButtons.jpg) The example layout of this screenshot is based on the example configuration shown for the [Settings / Versatile Buttons](./SettingsVButtons.md) screen. ## Button Execution When a button is clicked which is configured to require confirmation, a confirmation dialog is shown where execution can be refused: ![vButtonConfirm](./img/Console_VButtons_conf.jpg) ## Execution Result In the bottom part of the dialog the result of the command execution is shown: - *Command*
The command configured for the button. - *Run Arguments*
Command execution is done using the Python [subprocess.run](https://docs.python.org/3/library/subprocess.html) method which receives arguments as a list. This list is obtained by parsing the command string with spaces as separators. - *Return Code*
The return code returned from command execution. - *Stdout*
Output which command execution has sent to Stdout.
Multiline output can be scrolled. - *Stderr*
Error information which command execution has sent to Stderr.
Multiline output can be scrolled. - Status Line
The status line at the bottom shows whether the command could be executed or not, irrespective of errors which might have occurred during command execution. The last *Execution Result* remains visible within the live time of the Flask server. Of course, no result will be visible if the Flask server has been restarted or of the Raspberry Pi has been rebooted. ## Interactive Commandline If the [Settings / Versatile Buttons](./SettingsVButtons.md) have declared the commandline to be interactive, commands can be directly entered on the commandline: ![Commandline](./img/Console_VButtons_commandline.jpg) ================================================ FILE: docs/FocusHandling.md ================================================ # raspiCamSrv Focus Handling [![Up](img/goup.gif)](./CameraControls.md) Focus handling is not supported by camera versions 1 and 2. ![Focus handling](img/Focus.jpg) This tab includes various controls which affect the Auto Focus (AF) algorithm of the camera. The **Autofocus Mode** can be set to "Manual", "Auto" or "Continuous" ## Manual Focus When *Autofocus Mode* "Manual" is chosen, also the *Focal Distance* field must be activated and the distance must be set manually. Pressing **Submit** will apply the setting to the live stream and the changed focus will be immediately visible. ## Continuous Focus When *Autofocus Mode* is set to "Continuous", the camera will, after submitting, continuously try to focus under consideration of settings for other focus handling parameters. ## Automatic Focus When *Autofocus Mode* is set to "Auto", the camera will automatically focus after an autofocus cycle has been triggered. Before the cycle can be triggered through the **Trigger Autofocus** button, the settings must be applied with the **Submit** button. Submitting the "Auto" *Autofocus Mode* will have no effect on the live stream. ## Trigger Autofocus The effect of the autofocus cycle will essentially depend on the settings for the other AF control parameters. Whether or not the autofocus cycle was successful, will be shown in the message area at the bottom of the application window: ![AFMessage](img/AFMessage.jpg) If the autofocus cycle was successful, **raspiCamSrv** will request metadata from the camera and determine the focal distance from the LensPosition. The Value will be entered in the *Focal Distance* field, which will also be activated automatically. Also, the *Autofocus Mode* will be automatically set to "Manual" so that the measured *Focal Distance* can be used for future photos. ![AFTrigger](img/AFTrigger2.jpg) ## Autofocus Windows These are rectangle areas within the image which will be used by the AF algorithm to focus. Multiple rectangle areas can be specified. **raspiCamSrv** supports graphical specification of these areas in the following way. 1. Activate the checkbox for *Autofocus Windows*. As result, a canvas will be drawn over the live stream area which is visible as thin red border: ![AfWindows1](img/AFWindows1.jpg) 2. Now you can use the mouse to draw rectangles on this canvas: Position the cursor at one corner of the intended rectangle, press the left mouse button, and drag with mouse button down to the opposite corner. 3. When the mouse button is released, the rectangle coordinates will be scaled to the current scaler crop settings and entered in the *Autofocus Windows* field. ![AfWindows2](img/AFWindows2.jpg) 4. If required, you can draw additional rectangles in the same way. While drawing rectangles, previously drawn rectangles will vanish without getting lost. 5. Finally, when the mouse pointer leaves the canvas area, all rectangles will be shown over the live stream area. ![AfWindows3](img/AFWindows3.jpg) **Don't forget to push Submit because otherwise, these settings will get lost!** 6. In order to remove all areas, just deactivate the *Autofocus Windows* checkbox and activate it again. The canvas and the rectangles representing the AF Windows will remain visible as long as the *Autofocus Windows* checkbox is activated and whenever the *Focus* tab is visible. ================================================ FILE: docs/Information.md ================================================ # raspiCamSrv Information [![Up](img/goup.gif)](./UserGuide.md) ![Info Menu](img/Info-Menu.jpg) This menu gives access to detailed information on the raspiCamSrv system: - [System](./Information_Sys.md) shows detailed information about the Raspberry Pi system as well as on the software stack used by raspiCamSrv. The following sub-menus are only visible if at least one camera is connected to the system: - [Cameras](./Information_Cam.md) shows information on the installed cameras. - [Camera Properties](./Information_CamPrp.md) shows detailed information for the **active** camera. - [Sensor Mode n](./Information_Sensor.md) This set of tabs shows characteristics for the different sensor modes advertised by the **active** camera. ================================================ FILE: docs/Information_Cam.md ================================================ # raspiCamSrv Info/Camera Information [![Up](img/goup.gif)](./Information.md) This screen shows information on the installed cameras. ![Cameras](img/Info-Cameras.jpg) *Software Stack* shows information on installed packages with Version (*Ver*) and the path from which the packages were loaded (*Loc*). ## Camera x The tab lists all cameras currently connected to the system. Each camera has an identifying number (0, 1, ...) shown in the title above each parameter list. The assignment of camera number to physical camera may change when CSI cameras are plugged into a different CSI port (Pi 5) or when USB cameras are plugged into a different USB port. When the server starts up, the first camera in the [list of cameras](#detection-of-cameras) is selected as active camera, unless a specific camera is activated when a [stored configuration](./SettingsConfiguration.md) is loaded at startup.. You may later switch to another camera on the [Settings](./Settings.md) screen or the [Multi Cam](./CamMulticam.md) screen The active camera is indicated in the list. The active camera will also be shown in the title bar of the application after log-in. For USB cameras, the device through which the camera is accessible is also shown (See [Detection of Cameras](#detection-of-cameras)). The information "(Not in use)" for a USB camera indicates that the camera has been detected by **raspiCamSrv** but it is not in use because USB cameras have been deactivated in the [Settings](./Settings_NoCam.md). In this case, the USB camera cannot be selected as active or second camera in the [Settings](./Settings.md) screen or the [Multi Cam](./CamMulticam.md) screen. ### Status *Current Status* shows the status of the camera: - open / closed - started / stopped - current [Sensor Mode](./Information_Sensor.md) This is only shown for the currently active camera if it is started. If the Sensor Mode cannot currently be determined, 'unknown' is shown. The Sensor Mode is usually automatically selected by the camera and normally corresponds to the largest [Stream Size](./Configuration.md#stream-size-width-height), requested by one of the [Camera Configurations](./Configuration.md). - inactive
is shown for USB cameras which are currently not in use as active or second camera. - excluded
is shown for a USB camera which is, in principle, available for being used with **raspiCamSrv**, but currently excluded in the [Settings](./Settings.md) - not supported (OpenCV missing)
Is shown if a detected USB camera cannot be used within **raspiCamSrv** because OpenCV is not installed. See [Camera Status and Number of Threads](#camera-status-and-number-of-threads) Under *Tuning File*, you can see whether the Default or a custom tuning file are currently in use. See [raspiCamSrv Camera Tuning](./Tuning.md). ### AI Features This shows whether AI Features of a camera are available and active. Currently, this applies only to the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) with Sony IMX500 sensor. - Not Available
Indicates that the camera has no AI capabilities - Available
Indicates that the camera has AI capabilities and that AI features are enabled in the [Settings](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features).
Whether or not the camera is currently running a neural network model can be controlled in the [Camera AI Configuration](./Configuration_AI.md) - Disabled in Settings
Indicates that the camera has AI capabilities. However AI features are disabled in the [Settings](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features) ### Camera connected but not in the list? If you have a USB camera connected which does not show up in the list, you may have plugged in the camera while **raspiCamSrv** was running. In this case, you can use function [Reload Cameras](./SettingsConfiguration.md) to identify hot-plugged cameras. ## Camera Status and Number of Threads The number of threads used by the server process depends on the status of the camera(s). - When all cameras are closed, there is just the server process and, in case of Bookworm systems, 2 threads which are started with the import of Picamera2. Thus, there is a minimum of three threads (1 for Bullseye). - Opening a camera starts additional threads which remain active while the camera is open. The number of threads may depend on the camera infrastructure specific for the operating system. - Starting a camera and/or starting an encoder starts additional threads depending on the chosen camera function and encoder. - **raspiCamSrv** also uses threads for background processes, such as live stream, video recording, photo series and motion detection. These ramain active while these processes are running. - Stopping and closing a camera will also stop the dependent threads and thus reduce the number of active threads. - If .mp4 video is currently recorded ([started manually](./Phototaking.md) or as an action within [motion capturing](./Trigger.md)), there will be an additional ffmpeg process with additional threads. - In case of .mp4 video recording with H264Encoder and FfmpegOutput there seems to be an issue with threads: In this case, there may be threads surviving when the encoder is stopped (see [picamera2 Issue #1023](https://github.com/raspberrypi/picamera2/issues/1023)). So, when .mp4 videos have been recorded, the number of threads may not go down to 3 (1 for Bullseye) after all camaras have been closed. Experience shows that such threads may survive for a longer time but typically, they show only minor or no CPU utilization. Often, they vanish after the camera has been closed after live stream has stopped. **raspiCamSrv** closes the camera in case it is not used: - When the [live stream](./LiveScreen.md) stops after 10 seconds of inactivity, the camera used for the live stream will be stopped and closed. - After [photos have been taken or videos have been recorded](Phototaking.md), the camera will be stopped and closed. - For [Photo Series](./PhotoSeries.md), the camera will be stopped and closed after a shot if the interval to the next shot is >60 sec. This does not apply to [Exposure Series](./PhotoSeriesExp.md) and [Focus Stacks](./PhotoSeriesFocus.md). - If [motion detection](./Trigger.md) is active, the live stream is kept activated which keeps the camera open and started. - In case of [Stereo Vision](./CamStereo.md), the live streams for both cameras are kept active, ## Detection of Cameras **raspiCamSrv** uses the ```Picamera2.global_camera_info()``` list (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), ch. 8.7) to identify the currently connected cameras. For each camera, the information provided by Picamera2 includes - ```Num```: The camera number by which a camera is identified within Picamera2 as well as in raspiCamSrv. - ```Model```: The model name of the camera, as advertised by the camera driver - ```Location```: A number reporting how the camera is mounted, as reported by libcamera. - ```Rotation```: How the camera is rotated for normal operation, as reported by libcamera - ```ID```: An identifier string for the camera, indicating how the camera is connected.
You can tell from this value whether the camera is accessed using I2C or USB. ## Identification of USB Cameras A camera is identified as USB camera, if ```usb``` is found in the ```ID```. In **raspiCamSrv**, USB cameras are accessed through [OpenCV](https://opencv.org/) rather than through Picamera2, which provides only very limited support for USB cameras. However, with OpenCV, a camera cannot be accessed through the Picamera2 camera number (```Num```). Instead, the ```/dev/videoX``` of the Linux kernel must be used. For mapping of the Picamera2 camera number (```Num```) to the device number, **raspiCamSrv** uses the following algorithm: Assuming that the ```ID``` is structured in the following way: e.g.: ```/base/axi/pcie@1000120000/rp1/usb@200000-2:1.0-046d:085c``` | Component | Meaning |---------------------------------|------------ | ```/base/axi/pcie@1000120000``` | Root of the system-on-chip’s PCIe controller | ```/rp1/usb@200000``` | The RP1 I/O controller’s USB host controller (i.e. USB root hub) | ```-2:1.0``` | USB device address and interface: port 2, interface 1.0 | ```-046d:085c``` | Vendor ID : Product ID (046d = Logitech, 085c = C922 Pro Stream Webcam) Now, with Video for Linux (V4L2), we can list all video devices: ```v4l2-ctl --list-devices``` reveals, for example: ``` ... rpi-hevc-dec (platform:rpi-hevc-dec): /dev/video19 /dev/media1 Logi 4K Stream Edition (usb-xhci-hcd.0-1): /dev/video2 /dev/video3 /dev/video4 /dev/video5 /dev/media4 C922 Pro Stream Webcam (usb-xhci-hcd.0-2): /dev/video0 /dev/video1 /dev/media3 ``` Each header within the list shows the camera's model and port (```(usb-xhci-hcd.0-2)``` indicates port 2) Now, by mapping model and port from the Picamera2 ```ID``` with corresponding information from ```v4l2-ctl```, we can identify the group for each USB camera. The first entry in the list of devices for this group is assigned to the camera number, assuming that this represents the main camera stream, whereas subsequent entries are for alternative functions. ## Determining Camera Properties for USB Cameras Whereas camera properties of CSI cameras are directly provided by Picamera2 (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), Appendix D), this is not the case for USB cameras. **raspiCamSrv** maps properties of USB cameras as far as possible to the Picamera2 datastructure for seamless integration of USB cameras. The USB camera properties are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --all``` giving: ``` Driver Info: Driver name : uvcvideo Card type : C922 Pro Stream Webcam Bus info : usb-xhci-hcd.0-2 Driver version : 6.12.47 Capabilities : 0x84a00001 Video Capture Metadata Capture Streaming Extended Pix Format Device Capabilities Device Caps : 0x04200001 Video Capture Streaming Extended Pix Format Media Driver Info: Driver name : uvcvideo Model : C922 Pro Stream Webcam Serial : A9382BFF Bus info : usb-xhci-hcd.0-2 Media version : 6.12.47 Hardware revision: 0x00000016 (22) Driver version : 6.12.47 Interface Info: ID : 0x03000002 Type : V4L Video Entity Info: ID : 0x00000001 (1) Name : C922 Pro Stream Webcam Function : V4L2 I/O Flags : default Pad 0x01000007 : 0: Sink Link 0x0200001f: from remote pad 0x100000a of entity 'Processing 3' (Video Pixel Formatter): Data, Enabled, Immutable Priority: 2 Video input : 0 (Camera 1: ok) Format Video Capture: Width/Height : 640/480 Pixel Format : 'YUYV' (YUYV 4:2:2) Field : None Bytes per Line : 1280 Size Image : 614400 Colorspace : sRGB Transfer Function : Rec. 709 YCbCr/HSV Encoding: ITU-R 601 Quantization : Default (maps to Limited Range) Flags : Crop Capability Video Capture: Bounds : Left 0, Top 0, Width 640, Height 480 Default : Left 0, Top 0, Width 640, Height 480 Pixel Aspect: 1/1 Selection Video Capture: crop_default, Left 0, Top 0, Width 640, Height 480, Flags: Selection Video Capture: crop_bounds, Left 0, Top 0, Width 640, Height 480, Flags: Streaming Parameters Video Capture: Capabilities : timeperframe Frames per second: 30.000 (30/1) Read buffers : 0 User Controls brightness 0x00980900 (int) : min=0 max=255 step=1 default=128 value=128 contrast 0x00980901 (int) : min=0 max=255 step=1 default=128 value=128 saturation 0x00980902 (int) : min=0 max=255 step=1 default=128 value=128 white_balance_automatic 0x0098090c (bool) : default=1 value=1 gain 0x00980913 (int) : min=0 max=255 step=1 default=0 value=0 power_line_frequency 0x00980918 (menu) : min=0 max=2 default=2 value=2 (60 Hz) 0: Disabled 1: 50 Hz 2: 60 Hz white_balance_temperature 0x0098091a (int) : min=2000 max=6500 step=1 default=4000 value=4000 flags=inactive sharpness 0x0098091b (int) : min=0 max=255 step=1 default=128 value=128 backlight_compensation 0x0098091c (int) : min=0 max=1 step=1 default=0 value=0 Camera Controls auto_exposure 0x009a0901 (menu) : min=0 max=3 default=3 value=3 (Aperture Priority Mode) 1: Manual Mode 3: Aperture Priority Mode exposure_time_absolute 0x009a0902 (int) : min=3 max=2047 step=1 default=250 value=250 flags=inactive exposure_dynamic_framerate 0x009a0903 (bool) : default=0 value=1 pan_absolute 0x009a0908 (int) : min=-36000 max=36000 step=3600 default=0 value=0 tilt_absolute 0x009a0909 (int) : min=-36000 max=36000 step=3600 default=0 value=0 focus_absolute 0x009a090a (int) : min=0 max=250 step=5 default=0 value=0 flags=inactive focus_automatic_continuous 0x009a090c (bool) : default=1 value=1 zoom_absolute 0x009a090d (int) : min=100 max=500 step=1 default=100 value=100 ``` By parsing this information, relevant data for camera properties can be retrieved and mapped to camera property elements. The ```PixelArraySize``` is determined as the maximum size of the Sensor Modes found (see [below](#determining-sensor-modes-for-usb-cameras)) ## Determining Sensor Modes for USB Cameras Whereas sensor modes of CSI cameras are directly provided by Picamera2 (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), ch. 4.2.2.3), this is not the case for USB cameras. **raspiCamSrv** maps video formats of USB cameras as far as possible to the Picamera2 sensor mode datastructure for seamless integration of USB cameras. The USB camera video formats are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --list-formats-ext``` giving: ``` ioctl: VIDIOC_ENUM_FMT Type: Video Capture [0]: 'YUYV' (YUYV 4:2:2) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) Size: Discrete 160x90 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ... Size: Discrete 2304x1536 Interval: Discrete 0.500s (2.000 fps) [1]: 'MJPG' (Motion-JPEG, compressed) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ... Size: Discrete 1920x1080 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ``` From this output the list of sensor modes is generated with information on Size and Format. The FPS, stored for each sensor mode is the maximum value of fps found for each format. ## Determining supported Controls Every USB Camera advertises a list of controls which can be used to adjust focus or image appearance. Supported controls are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --list-ctrls``` giving: ``` User Controls brightness 0x00980900 (int) : min=0 max=255 step=1 default=128 value=128 contrast 0x00980901 (int) : min=0 max=255 step=1 default=128 value=128 saturation 0x00980902 (int) : min=0 max=255 step=1 default=128 value=128 white_balance_automatic 0x0098090c (bool) : default=1 value=0 gain 0x00980913 (int) : min=0 max=255 step=1 default=0 value=0 power_line_frequency 0x00980918 (menu) : min=0 max=2 default=2 value=2 (60 Hz) white_balance_temperature 0x0098091a (int) : min=2000 max=7500 step=10 default=4000 value=3000 sharpness 0x0098091b (int) : min=0 max=255 step=1 default=128 value=128 backlight_compensation 0x0098091c (int) : min=0 max=1 step=1 default=1 value=1 Camera Controls auto_exposure 0x009a0901 (menu) : min=0 max=3 default=3 value=3 (Aperture Priority Mode) exposure_time_absolute 0x009a0902 (int) : min=3 max=2047 step=1 default=250 value=312 flags=inactive exposure_dynamic_framerate 0x009a0903 (bool) : default=0 value=0 pan_absolute 0x009a0908 (int) : min=-36000 max=36000 step=3600 default=0 value=0 tilt_absolute 0x009a0909 (int) : min=-36000 max=36000 step=3600 default=0 value=0 focus_absolute 0x009a090a (int) : min=0 max=255 step=5 default=0 value=10 flags=inactive focus_automatic_continuous 0x009a090c (bool) : default=1 value=1 zoom_absolute 0x009a090d (int) : min=100 max=500 step=1 default=100 value=100 ``` **raspiCamSrv** analyzes this list with respect to a limited set of controls and registers - control name - value data type - minimum value - maximum value - default value For the active camera, this information is appended to the Camera Controls data structure (see [Server Configuration Storage](./SettingsConfiguration.md#server-configuration-storage)), which will also be transferred to the Streaming Configuration in case of multiple cameras. In order to establish this data structure, USB cameras need to be activated once as *Active Camera*. The controls information is cused to customize the [Camera Controls](./CameraControls_UsbCams.md) screens when a USB camera is the *Active Camera*. ================================================ FILE: docs/Information_CamPrp.md ================================================ # raspiCamSrv Info/Camera Properties [![Up](img/goup.gif)](./Information.md) This screen shows properties of the active camera. ![Camera Properties](img/Info-CamProps.jpg) See also [Determining Camera Properties for USB Cameras](./Information_Cam.md#determining-camera-properties-for-usb-cameras) ================================================ FILE: docs/Information_Sensor.md ================================================ # raspiCamSrv Info/Sensor Mode [![Up](img/goup.gif)](./Information.md) The camera system advertises the supported Sensor Modes with their characteristics. These are referred to within the [Camera Configuration](./Configuration.md). The characteristics for every Sensor Mode are shown on an individual tab: ![Sensor Mode](img/Info_SensorMode.jpg) USB cameras may advertise a large number of sensor modes. In this case, the remaining buttons for sensor mode selection can be found in a drop-down list: ![Sensor Mode](img/Info_SensorModeUsb.jpg) ================================================ FILE: docs/Information_Sys.md ================================================ # raspiCamSrv Info/System Information [![Up](img/goup.gif)](./Information.md) This screen shows information on the Raspberry Pi system as well as on the software stack required by raspiCamSrv. ![System](img/Info-System.jpg) ### Hardware and OS This section shows information on the server hardware and operating system - *Model* Raspberry Pi model frpm ```/proc/device-tree/model``` - *Board Revision* Board revision from ```/proc/cpuinfo``` - *Kernel Version* Kernel Version as reported by ```uname -r``` - *Debian Version* Information on the operating system: .. *Description* from ```lsb_release -a``` .. *Version* from ```/etc/debian_version``` .. 32-bit/64-bit from ```dpkg-architecture --query DEB_HOST_ARCH``` ### Processes This section informs about active processes related to raspiCamSrv. #### Environment shows whether the raspiCamSrv server process is running - directly on the "Host System" - or in a "Docker Container" #### Server Process shows how the server process has been started: - "Server started via systemd system service" in this case, audio cannot be recorded along with video. - "Server started via systemd user service" in this case, audio can be recorded along with video. - "Server started via command line" #### WSGI Server raspiCamSrv is based on [Flask](https://flask.palletsprojects.com/en/stable/), which is a WSGI (Web Server Gateway Interface) application. A WSGI server is required to run the application. The server which is currently active is shown here. Standard [raspiCamSrv installations](./installation.md) support the following alternatives: - *gunicorn* [Gunicorn](https://gunicorn.org/) is a mature, stable and widely used WSGI server for production use. For Gunicorn, also the number of threads, configured for the worker process, are shown. The number of threads limit the number of simultaneous MJPEG streams (See [Gunicorn Settings](./installation_man.md#gunicorn-settings)). - *werkzeug* [Werkzeug](https://werkzeug.palletsprojects.com/en/stable/) is the WSGI server integrated in Flask for development and testing purposes. On start, a warning is shown: ```Do not use it in a production deployment. Use a production WSGI server instead.``` #### Process Info shows current process information for the raspiCamSrv server process (result of Linux ```ps -eLf``` command) - *PID* Process ID of WSGI server running Flask. In case of *werkzeug*, there is just one process. For *gunicorn*, there are two processes, where the first is the Master process with typically 2 threads which control a single worker process (restricted through ```-w 1``` option at startup) which is running the Flask application. - *Start* Process start time (STIME): either start time (HH:MM) at current day or day (MonDD) when process was started. - *#Threads* Number of threads (NLWP) - *CPU Process* CPU time of process (TIME for LWP == PID) in HH:MM:SS - *CPU Threads* Sum of CPU time for threads ((TIME for LWP != PID)) in %H:MM:SS #### FFmpeg Info shows information on an ffmpeg process if encoding of .mp4 videos is currently active. Recording of .mp4 videos may have been [started manually](./Phototaking.md) or as an action within [motion capturing](./Trigger.md) #### raspiCamSrv Start shows the time when the raspiCamSrv server has been started. At server start, raspiCamSrv checks whether or not the Raspberry Pi system time is synchronized with the time server. When the device is booted and raspiCamSrv is automatically started, the time synchronization will occasionally be done after the Flask server has already been started. In this case, in order to avoid timing issues, raspiCamSrv will wait at startup until time synchronization is completed. The time shown here is the system time at the moment when the check for time synchronization was successful. raspiCamSrv analyzes the output of command ```timedatectl``` to check the system clock synchronization status. If this check fails or times out (60 sec), raspiCamSrv will start nevertheless. In this case, the information "System time not synced at raspiCamSrv start" will be shown here. If the server is running in a Docker container (see [Running raspiCamSrv as Docker Container](./SetupDocker.md)), the time is assumed to be synchronized and the check will be skipped. This is indicated through ![Container](img/Info-Container.jpg) ### Software Stack In this section, information on installed packages is shown. - *Ver* is the package version - *Loc* is the path from which the packages were loaded. ### Streaming Clients ![Streaming Clients](./img/Info-StreamingClients.jpg) The tab lists the clients which are currently using one of the camera streams. Along with the IP address of the client, a list of streams is shown which the client is using: - *live_view*
[The Live View](./LiveScreen.md) stream
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLiveActive.jpg) - *video_feed*
The [video Stream](./CamWebcam.md#video-stream) for the active camera
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLiveActive.jpg) - *video_feed2*
The [video Stream](./CamWebcam.md#video-stream) for the second camera, if available
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLive2Active.jpg) ================================================ FILE: docs/LiveDirectControl.md ================================================ # raspiCamSrv Live Direct Control [![Up](img/goup.gif)](./LiveScreen.md) This screen is opened by clicking on the [Live Stream area](./LiveScreen.md#accessing-the-direct-control-panel). It allows direct control of selected control parameters. Control parameters with numeric values are accessible if they have been activated on the [Camera Controls](./CameraControls.md) screens. When finished with parameter tuning, return to the [Live](./LiveScreen.md) screen or select any other menu option. ![DirctControl](./img/LiveDirectControl.jpg) ## Focal Distance The slider for *Focal Distance* is mapped to a range from 0 to 1. If you drag the slider to a value below the minimum focal distance, it will snap back to the minimum. Scaling uses an x**3 behavior so that lower values can be selected with higher precision. ## Left and Right Sliders The sliders for all parameters are mapped to a range from -1 to 1 with the default at 0, following an x**3 function: ![Scaling](./img/LiveDirectControlSlider.jpg) Therefore, values closer to the default value can be selected with higher precision. Because of the mapping, you will always see the same sliders, independently from the real value ranges which are significantly different for CSI and USB cameras and which can also vary between different USB camera models. For some parameters, e.g. *Analogue Gain*, the default value is at the minimum of the parameter range. In this case, negative slider positions can not be set; the slider will snap back to 0. ## Zoom Factor The slider for the *Zoom Factor* directly shows the *Zoom Factor* with linear scaling. If you previously have selected a specific image region (Scaler Crop) with the [Zoom and Pan](./ZoomPan.md) dialog, the resulting *Zoom Factor* will be the maximum which ca be set with the slider. Selecting a larger value will always snap back to this maximum. However, you can zoom into this area until the lowest zoom factor is reached. ================================================ FILE: docs/LiveScreen.md ================================================ # raspiCamSrv Live Screen [![Up](img/goup.gif)](./UserGuide.md) The **Live** screen is the central part of the application. After photos or videos have been taken in the current session, its layout is as shown below: ![Live Screen](img/Live0.jpg) ## Layout ### Top Left Quarter This is the area where a Live stream is shown, except for phases when videos are recorded and when [Configuration](./Configuration.md) for Live View and Video are not compatible.. ### Top Right Quarter This area allows selecting and configuration of all [Camera Controls](./CameraControls.md) supported by Picamera2. These are parameters which affect the characteristics of images and outputs of the camera and which can be modified while the camera is running. The menu row of this section groups the controls into several categories. ### Bottom Left Quarter The bottom part of the screen is only shown if a Photo or video has been taken within the server's life time and if the user did not decide to hide this area. The bottom left quarter presents function buttons for [Photo/Video taking](./Phototaking.md) In addition, there are also buttons controlling the photo buffer to which users can add or remove individual photos and navigate between them. Raw photos or videos are not shown directly. Instead a placeholder in the configured photo format is shown. ### Bottom Right Quarter Here, the metadata of the currently visible photo/video are shown. The metadata are captured within the same **Capturing Request** together with the photo itself. In the case of videos, the metadata are captured immediately before recording starts. Alternatively to metadata, the histogram of the photo can be shown. ## Accessing the Direct Control Panel For fine tuning all numeric control parameters (e.g. *Focal Distance*, *Zoom Factor*, *Contrast*, etc.), you can use the [Direct Control Panel](./LiveDirectControl.md). When hovering with the mouse over the Live Stream area, you will get a hint: ![Direct Control hint](./img/LiveDirectControlOpen.jpg) Before clicking on the Live Stream, you will need to activate those control parameters which you want to adjust: - [Focal Distance](./FocusHandling.md) - [Zoom Factor](./ZoomPan.md) (does not require activation) - [Exposure Time](./CameraControls_Exposure.md) - [Exposure Value](./CameraControls_Exposure.md) - [Analogue Gain](./CameraControls_Exposure.md) - [Colour Gain](./CameraControls_Exposure.md) - [Sharpness](./CameraControls_Image.md) - [Contrast](./CameraControls_Image.md) - [Saturation](./CameraControls_Image.md) - [Brightness](./CameraControls_Image.md) Some of these parameters might not be available if the Active Camera is a USB camera. Furthermore, if you want to restrict to a specific image section, you need specify this on the [Zoom](./ZoomPan.md) window first. The [Direct Control Panel](./LiveDirectControl.md) allows only zooming into that window but not changing the window itself. ================================================ FILE: docs/PhotoSeries.md ================================================ # raspiCamSrv Photo Series [![Up](img/goup.gif)](./UserGuide.md) The *Photo Series* screen allows the management of different kinds of Photo Series and includes means for series configuration, lifecycle management, photo shooting and supervision. A series is a sequence of photos taken with specific time intervals. Special kinds of series are [Timelapse Series](./PhotoSeriesTimelapse.md), [Exposure Series](./PhotoSeriesExp.md) and [Focus Stacks](./PhotoSeriesFocus.md). ![Photoseries Screen](img/Photoseries2.jpg) ## Creation of a new Series When the *Photo Series* screen is opened for the first time, it offers the option to create a new series: ![New Series](img/Photoseries0.jpg) You need to enter a unique name for the series. Since the name will be used as folder name and as part of the filename for photos, you need to consider any restrictions for systems where you want to store and process these files. Linux is quite tolerant in this aspect but it is recommended using only letters, numbers and underscore characters. ## Series Configuration When a series is initially created, some parameters are predefined which later need to be configured: ![Series Config](img/Photoseries1.jpg) - The lifecycle of a Photo Series is represented as its **Status**. Transitions between different states can be initiated by one or two buttons at the right of the series selection combo box. For details see the [Series State Chart](#photo-series-state-chart). - If multiple series have been created, the **active series** can be selected with a combo box showing the series names. - The *Series Type* distinguishes "Normal" series without special characteristics from specialized series, such as "Exposure Series", "Focus Stacks" or "Timelapse Series". - The *Path* is the path where all resources for the series are located. For details see [Photo Series in the File System](#photo-series-in-the-file-system) - The system has also initialized a [Series Configuration File](#series-configuration-file) a [Series Log File](#series-log-file) and a [Camera Settings File](#series-camera-file). - In addition, a "hist" subdirectory has been created where histogram images will be stored (currently only for *Exposure Series*). - Under *Photo Type*, it can be selected whether only ```jpg``` or ```raw+jpg``` photos shall be taken. - The *Start* time is initiated with the current time. This needs to be set to the time when the series shall start. - The *End* time can be set explicitly if a specific end time is required. If this is done, the number of shots will be calculated based on the specified *Interval* - As *Interval* , the time difference (in seconds) between successive shots can be specified. From experience, the system will observe the given value within a tolerance of about 30 ms. - *On Dial Marks* specifies whether the shots shall be taken on whole hours, quarters, minutes, ..., depending on the intarval.
For example, if the interval is 900 sec, photos will be taken exactly (within tolerances) at :00, :15, :30, :45, or if the interval is 3600 sec, photos will be taken every full hour. - The *Number of Shosts* specifies the numper of photos intended for the series. If the *End* time has not been explicitly specified, it will be calculated from *Interval* and *Number of Shots* considering the specified *Start* time. - The checkbox *Cont. on Server Start* allows to automatically continue an active series in case of a server restart. Such a situation may happen if the server is stopped (explicitly or implicitly with a device shutdown) while a series is active. For example, if you have a long running timelapse series, there might be power outages forcing a system reboot. If you have set the series to automatic continuation it will be continued as soon as the server is restarted. Otherwise, it will be in status PAUSED. Automatic continuation is not used for [Exposure Series](./PhotoSeriesExp.md) or [Focus Stack](./PhotoSeriesFocus.md) series because these series are typically not running for a longer time. After the values have been entered, pressing the *Submit* button will calculate dependent parameters an change the [status](#photo-series-state-chart) of the series to "READY". **For Photo Series of type [Exposure Series](./PhotoSeriesExp.md), [Focus Stack](./PhotoSeriesFocus.md) and [Timelapse Series](./PhotoSeriesTimelapse.md), additional configurations are required.** ## Series Start A series in state "READY" can be started with the *Start* button. This will execute the following steps: 1. Set the status to "ACTIVE" 2. Configure the camera with the active [Configuratien](./Configuration.md) for "Photo" or "Raw Photo", depending on the selected *Photo Type* 3. Apply the active [Camera Controls](./CameraControls.md)
For [Exposure Series](./PhotoSeriesExp.md) and [Focus Stack](./PhotoSeriesFocus.md), specific controls will be adjusted or varied for each photo. 4. Start the camera 5. Wait until the start time 6. Execute the necessary capture request (jpg, dng, metadata) 7. Store the metadata in the [Series Log File](#series-log-file) 8. Store the camera configuration as well as the controls parameters, which have been applied before request execution, in the [Camera Settings File](#series-camera-file) 9. Wait until the next interval and repeat steps from 6. until either the configured *Number of Shots* or the configured *End* time has been reached. 10. Finally, the series status will be "FINISHED". While the series is "ACTIVE", this is shown by the Series status indicator and the screen will show the progress: ![Series Progress](img/Photoseries2.jpg) In the *Preview* area, the time for the next photo to be taken is shown and the progress bar shows the time remaining. When the photo time is reached, the page will be reloaded and the latest photo will be shown on top. The last 20 photos are available in the scroll area. ## Downloading a Series A series can be downloaded at any time after it has been created. Whether or not a series has been downloaded is shown under *Downloaded* which is either "Never" or the time of the last download. Pushing the *Download* button will require a confirmation before download will be executed. The download will be named ```raspiCamSrvSeries__``` with the timestamp of the download. The download is a zip archive including the entire folder structure of the series (see [Photo Series in the File System](#photo-series-in-the-file-system)): - All photos taken until the time of download - The [Series Cofiguration file](#series-configuration-file) - The [Series Camera File](#series-camera-file) (which will be empty if no camera configuration has been attached to the series) - The [Series Log File](#series-log-file) (which will be empty if the series has not yet been started) - A subfolder ```hist``` containing histograms, in case the series has been an [Exposure Series](./PhotoSeriesExp.md) ## Finished Series When a series has ended or after it has been actively finished with the *Finish* button, its status is shown as "FINISHED": ![Series End](img/Photoseries2b.jpg) If the series has been downloaded after it had ended, can be seen by comparing the respective timestamps. ## Live Stream ### Active Live Stream If the [Configuration](./Configuration.md) for the *Photo* and *Raw Photo* use cases are compliant with the configuration for *Live View*, the Live Stream will not be interrupted while the series is ACTIVE. For more details, see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md) Simultaneous activity of Live Stream and Photo Series is indicated by the process status indicators: ![Process Status Indicators](./img/ProcessIndicator4.jpg) If the series is an [Exposure Series](./PhotoSeriesExp.md) or a [Focus Stack](./PhotoSeriesFocus.md) Series, [Camera Controls](./CameraControls.md) will be modified while the series is active. *Auto Exposure* and *Auto White Balance* will be deactivated and other parameters, such as *Exposure Time*, *Analogue Gain* or *Focal Distance*/*Lens Position* will vary from photo to photo. These variations will be visible in the Live Stream. After the series is FINISHED, the original control parameters will be restored. ### Paused Live Stream If the Live Stream is paused because photo taking requires exclusive camera access because of specific [Configuration](./Configuration.md) (see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md)), this is indicated by the process status indicators: ![Process Status Indicators](./img/ProcessIndicator6.jpg) and a placeholder image will be schown instead of the Live Stream: ![Photoseries Active](img/Photoseries3.jpg) ## Interrupting an ACTIVE Series While a Photo Series is ACTIVE, photo- and video taking is disabled: ![Photoseries Active](img/Photoseries3b.jpg) The thread in which the Photo Series is executed, checks every 2 seconds whether a request to pause or stop has been issued. If the series shall be paused and (possibly) continued later, the *Pause* button in the *Series* screen can be used. If the series shall be interrupted and terminated, the *Finish* button can be used. ## Attaching Camara Configuration to a Photo Series Before a Photo Series is started, normally the camera [configuration](./Configuration.md) and [controls](./CameraControls.md) will manually be adjusted for optimal photo quality. These settings (configuration **and** controls) can be attached to a Photo Series with the *Attach Camera Config* button. These will be persisted in the [series configuration file](#series-configuration-file) If this has been done, the system offers to activate these settings at a later time: ![Activate Config](img/PhotoSeries4.jpg) So, if another series shall be run with the same or a similar setup, the camera configuration and control settings can be reused after they have been activated. This feature can also be used to persist specific settings under an indicative name (the series name), also if it is not intended to really run such a series. ## Photo Series State Chart ![StateChart](img/PhotoSeriesStateChart.jpg) ## Photo Series in the File System All resources related to a Photo Series are stored in the file system under ```/home//prg/raspi-cam-srv/RaspiCamSrv/static/photoseries/``` where `````` is the user ID specified during [system setup](./system_setup.md) and `````` is the name of the series. After [Creation of a new Series](#creation-of-a-new-series), the folder has been created and a [Series Configuration File](#series-configuration-file), a [Series Log File](#series-log-file) and a [Series Camera File](#series-camera-file) have been initiated: ![Series in FS](img/PhotoSeries5.jpg). After the series has been [started](#series-start), also all photos (.jpg) and, if selected, also the raw photos (.dng) can be found in this folder: ![Series in FS](img/PhotoSeries6.jpg). Typically, photo series will be processed on another system, especially if they have been taken with a Raspberry Pi Zero system. **RaspiCamSrv** does not provide any means to download or transfer these data. There are numerous tools to achieve this (e.g. scp, Samba) ### Series Configuration File The file ```_cfg.json``` contains the entire configuration of a series, including, if attached, the camera configuration and camera controls. ![Series Configuration File](img/PhotoSeries7.jpg) When the server starts up, all folders under ```.../photoseries``` are searched for a configuration file and series configurations are created from their contents. These will then be available in the **raspiCamSrv** *Photo Series* dialog. ### Series Log File The file ```_log.csv``` contains log entries for each photo of the series: ![Series Log File](img/PhotoSeries8.jpg) Bisides the name of the photo and the time of creation, the most important metadata are included which have been captured in the same request as the Raw Photo and / or jpg Photo. ### Series Camera File The file ```_cam.json``` contains a JSON structure with the [camara configuration](./Configuration.md) and the [camera controls](./CameraControls.md) applied for each photo of the series. This is available only for [Exposure Series](./PhotoSeriesExp.md) and [Focus Stack Series](./PhotoSeriesFocus.md). ![Camera](img/PhotoSeriesExp3.jpg) ================================================ FILE: docs/PhotoSeriesExp.md ================================================ # Photo Series of type "Exposure Series" [![Up](img/goup.gif)](./PhotoSeries.md) Exposure series iterate through a specified range of an exposure parameter, keeping all other exposure parameters constant. In general, exposure is controlled by three parameters: aperture, exposure time and ISO value. Raspberry Pi cameras have a fixed or manually controlled aperture and ISO values are not standardized. Instead of ISO values, the Analogue gaing can be set. Roughly, the relation is *ISO* = 100 * *AnalogueGain*. The *Exposure Series* subdialog allows specifying necessary parameters for series where either *Exposure Time* or *Analogue Gain* is varied. **NOTE**: This function is not available for USB cameras. ![Exposure Series](img/PhotoSeriesExp1.jpg) The dialog references the active Photo Series which is managed in the [Series](./PhotoSeries.md) subdialog of the *Photo Series* dialog. The *Number of Shots* is shown here, because it will be affected by the chosen *Start*, *Stop* and *Interval* values. To configure the active Photo Series as *Exposure Series*, proceed as follows 1. Activate the *Exposure Series* checkbox 2. Select the exposure parameter which shall be kept at a fixed value by checking one of the check boxes under *Exposure Time* or *Analogue Gain* 3. For the selected parameter, enter the intended value in the *Start* field. 4. Now, specify *Start* and *Stop* values for the variable parameter 5. The interval is specified in terms of photographic [Exposure Values](https://en.wikipedia.org/wiki/Exposure_value) (EV)
With a value of 1/3 EV the series is obtained by multiplying the last value of *Exposure Time* or *Analogue Gain* by 2**(1/3) to get the next value.
With a value of 1 EV, the factor is 2**(1)=2
and with 2 EV, the factor is 2**(2)=4.
1/3 EV is the typical raster value for commercial cameras when modifying either aperture, exposure time or ISO. Finally push the **Submit** button to store the specified value. This will recalculate the *Number of Shots* required for the series. To start photoshooting, go to the [Series](./PhotoSeries.md) subdialog ## Result After the series has finished, the results can be inspected on the *Exposure Series* subscreen: ![Exposure](img/PhotoSeriesExp2.jpg) Together with each photo, the screen shows a histogram and characteristic metadata: - Exp: Exposure Time in seconds - Gain: Analogue Gain - Lux: An estimation of the brightness More information can be gained from - the [Series Camera File](./PhotoSeries.md#series-camera-file) which lists the configuration and control parameters applied before shooting a photo - the [Series Log File](./PhotoSeries.md#series-log-file) which lists the metadata captured together with each photo. ## Parameter Table (1/3 EV) The following table contains systematic values for Exposure Time and Analogue Gain with 1/3 EV, corresponding roughly to commercial camera settings ![1/3EV](img/PhotoSeriesExpTab1_3.jpg) ## Parameter Table (1 EV) The following table contains systematic values for Exposure Time and Analogue Gain with 1 EV, corresponding roughly to commercial camera settings ![1/3EV](img/PhotoSeriesExpTab1.jpg) ================================================ FILE: docs/PhotoSeriesFocus.md ================================================ # Photo Series of Type "Focus Stack" [![Up](img/goup.gif)](./PhotoSeries.md) A Focus Stack series iterates the Lens Position (or Focal Distance). With suitable software, such a stack can be combined to achieve a large Depth of Field (DoF). **NOTE**: This function is not available for USB cameras. ![Focus Stack](img/PhotoSeriesFoc1.jpg) To create a focus stack, you can proceed as follows: 1. In the [Live Screen](./LiveScreen.md) use [Focus Handling](./FocusHandling.md) to determine the Focal Distance for the nearest and the furthest point of the scene. 2. After initializing a Photo Series in the [Series](./PhotoSeries.md) subscreen, open the *Photo Stack* subscreen and check *Focus Stacking Series* 3. Then enter the nearest and furthest Focal Distance as *Start* and *Stop* values and choose a suitable *Interval* 4. Push **Submit** to configure the series 5. In the [Series](./PhotoSeries.md) subscreen, start the Photo Series The result will be shown in the *Focus Stack* subdialog: ![Fochs Stack Final](img/PhotoSeriesFoc2.jpg) Together with each photo, characteristic metadata are shown: - The *Lens Position* with which the photo was taken
(reciprocal of Focal Distance) - The *Focal Distance* which was varied within the series - The *Focus FoM*, a Figure of Merit (FoM) to indicate how in-focus the frame is. A larger FocusFoM value indicates a more in-focus frame. More information can be gained from - the [Series Camera File](./PhotoSeries.md#series-camera-file) which lists the configuration and control parameters applied before shooting a photo - the [Series Log File](./PhotoSeries.md#series-log-file) which lists the metadata captured together with each photo. ================================================ FILE: docs/PhotoSeriesTimelapse.md ================================================ # Photo Series of Type "Timelapse" [![Up](img/goup.gif)](./PhotoSeries.md) This screen allows special configurations for Photo Series in the Timelapse domain. Of course, every normal Photo Series can be used for Timelapse purposes. However, users often require specific features like specific time slots for multi-day series or automatic exposure adjustment during sunset/sunrise phases (Autoramping / "Timelapse Holy Grail"). This page is dedicated to this kind of configuration settings. Currently, raspiCamSrv supports the following *Sun-control Mode*s: - *Sunrise/Sunset*
limiting photo shooting to configurable periods depending on sunrise and sunset. - *Azimuth*
taking photos at specific azimuth values over a period of days so that photos are taken with the same horizontal direction of sun. Usage of this feature requires calculation of sunrise and sunset, depending on date. The algorithm (see [Sunrise Equation](#sunrise-equation)) requires information about the geografic coordinates of the camera position. These need to be specified on the [Settings](./Settings.md) screen before a Series can be classified as "Sun-controlled". It is recommended to [store the configuration](./SettingsConfiguration.md#server-configuration-storage) in order to have these settings available after a server restart. ## Sunrise/Sunset Mode When this screen is activated after a [new Series](./PhotoSeries.md#creation-of-a-new-series) has been created, it will be initialized with *Sunrise/Sunset* mode: ![Timelapse1](./img/PhotoSeriesTL1.jpg) - The fields **Series**Name, **Interval** and **Number of Shots** refer to the same parameters as screen [Series](./PhotoSeries.md). - Activating the checkbox **Sun-controlled Series** will activate selective photo shooting in periods depending on sunrise and/or sunset. - You can specify the **Number of Days** for which the series shall be active - The fields **Sunrise** and **Sunset** will show values for the current day.
If the series will be running for several days, sunrise and sunset will be calculated individually for every day. - The system allows the definition of two periods per day during which photos will be shot with the given **Interval**:
These are named **Period 1** and **Period 2** - At least for **Period 1**, you need to specify **Start** and **End**
If only one is specified, the system reports an error and does not persist the specified data.
**Start** and **End** are specified if the **Reference** is not "Unused". - The **Reference** specifies whether "Sunrise" or "Sunset" will be used to limit the intended period. - For each **Period**s **Start** and **End**, you can specify a time **Shift** in Minutes by which the start or the end of the period will be shifted with respect to the selected **Reference**.
The **Shift** can be positive or negative. - After **Submit**, the system will calculate **Todays Values** for **Start** and **End**. - Also the time for the **Next Shot** will be shown. When submitting entries with a reasonable interval and Number of Days, the system will recalculate the required **Number of Shots** as well as the expected **End** of the Series, shown on the [Series](./PhotoSeries.md) screen. ![Timelapse2](./img/PhotoSeriesTL2.jpg) ### Example: Single Period for Daylight Photos ![Timelapse3](./img/PhotoSeriesTL3.jpg) ### Example: Two Periods around Sunrise and Sunset ![Timelapse4](./img/PhotoSeriesTL4.jpg) ## Azimuth Mode When choosing *Sun-Control Mode* "Azimuth", the screen layout will change to ![Timelapse5](./img/PhotoSeriesTLAzimuth1.jpg) - *Time for Azimuth*
can be used to calculate the azimuth at a specific time.
When its value is not explicitely set, it will be updated with the current date/time. - *Azimuth [°]*
is the azimuth at the given time - *Elevation [°]*
is the elevation of the sun above horizon at the given time - *Azimuth 1*, ... *Azimuth 4*
Here, you can specify up to 4 azimuth values for which photos shall be taken at every day within the specified number of days from series start.
For each value the *Todays Time* at the current day will be calculated when the sun position will have this azimuth.
The system will also verify that azimuth values are valid for the entire period of the series. If this is not the case, an error message will be shown.
When entering more than one azimuth value, these will be sorted with increasing times. ![Timelapse6](./img/PhotoSeriesTLAzimuth.jpg) ### Series Log File For photo series using *Azimuth* Mode, the [Series Log File](./PhotoSeries.md#series-log-file) will include the Azimuth value for each photo: ![Timelapse7](./img/PhotoSeriesTLAzimuthLog.jpg) ## Sunrise Equation The algorithm for the sunrise/sunset equation has been taken from Wikipedia: [https://en.wikipedia.org/wiki/Sunrise_equation](https://en.wikipedia.org/wiki/Sunrise_equation) This article also publishes Python code which has been taken as is (version from August 11, 2024, 14:18) and integrated with minor technical adjustments into the RaspiCamSrv Flask server code. Comparison of the results from this algorithm with those from the "NOAA Solar Calculator" ([https://gml.noaa.gov/grad/solcalc/](https://gml.noaa.gov/grad/solcalc/)) for the time of writing at the author's location showed a deviation of -2 Minutes for sunrise and +3 Minutes for sunset. However, the NOAA Calculator does not seem to take elevation into account. ================================================ FILE: docs/PhotoViewer.md ================================================ # raspiCamSrv Photo Viewer [![Up](img/goup.gif)](./UserGuide.md) All photos, raw photos or videos taken wit **raspiCamSrv** are stored in a camera-specific folder on the server. Currently, the folder is located within the folder where Flask expects static content (```~/home/prgraspi-cam-srv/raspiCamSrv/static/photos/camera_n``` (n=0, 1)). The full path of the folder for the active camera is shown in the [Settings](./Settings.md) screen. The current implementation of **raspiCamSrv** includes a very simple viewer which allows inspecting the available photos and videos as well as downloading and deleting selected files: ![Photos](img/Photos.jpg) On the left side, a selection of photos (.jpg placeholders for raw and video) are shown in a scroll area in reverse order with the newest one on top. The file name in the photo (or placeholder) shows the correct filename of the resource represented by the picture. A large view of the photo or a video player is presented when a specific picture has been clicked on. - You need to select the **Camera** for which photos shall be shown.
In systems with multiple cameras, photos taken with a camera are stored in a camera-specific folder. - **From** and **To** date selectors allow restricting photos to a specific range of dates
When initially starting the dialog, the current day is selected.
Internally, **From** has time 00:00:00 and **To** 23:59:59. - Button **Today** restricts the time range to the current date - Button **All** sets **From** to January 1st, 1970 and **To** to today. - On the left of each thumbnail picture, there is a **checkbox** where you can select photos or videos for download or for deletion. - Buttons **Select all** and **Deselect all** apply to all photos currently shown in the scrolling area. - With button **Delete** you can delete all selected photos
Before deletion is executed, a confirmation is required.
If a specific media (e.g. video or raw photo) incudes the media file itself, a jpg placeholder and an optional histogram file, all are deleted.
Deletion of photos also clears the [Photo Display Buffer](./Phototaking.md#photo-display). - With the **Download** button, you can download the selected files.
Also here, a confirmation is required.
If more than one file has been selected, the selected files will be zipped into a file named *raspiCamSrvMedia_YYYYMMDD_HHMMSS.zip*
If a single file is selected, it will be downloades as is.
Placeholders for raw and videos as well as histogram are not included in the download. ================================================ FILE: docs/Phototaking.md ================================================ # raspiCamSrv Photo Taking and Video Recording [![Up](img/goup.gif)](./LiveScreen.md) The *Live* tab of **raspiCamSrv** provides functionality to take photos, raw photos and videos. In all cases, the predefined [Camara Configuration](./Configuration.md) for the specific use case is applied together with the currently activated [Camera Controls](./CameraControls.md). As long as no photo has been taken, the *Live* screen will show like below: ![Foto0](img/Foto0.jpg) Now, you can use - the **Photo** button to take a photo, where the file type can be selected in the [Settings](./Settings.md) screen. ("jpg" is recommended) - the **Raw** button to take a raw photo. Currently only the *.dng* format is supported. - the **Video** button to record a video, where for the video format you may choose between *.mp4* and *.h264* in the [Settings](./Settings.md). Recommended is *.mp4* ## Photo / Raw Photo If the *Photo*/*Raw Photo* [Configuration](./Configuration.md) is not compliant with the *Live View* configuration, the live stream will be shortly interrupted in order to allow the system to apply the [Camera Configuration](./Configuration.md) for these use cases. For more details, see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md) The photo (in the case of raw photos a placeholder photo) will be shown in the bottom area of the screen together with the Metadata. ## Video ### With active Live Stream When video recording is started, the system will first check whether the required [Camera Configuration](./Configuration.md) is compliant with the configuration of the active Live Stream. If this is the case, first a normal photo of the scene will be taken, which will be used as placeholder. This will be shown in the bottom area. ![Video1](img/Video1.jpg) The **Video** button has changed to **Stop** which must be used to stop video recording. If audio is recorded along with video (see [Settings](./Settings.md#recording-audio-along-with-video)), this will be indicated by the process indicator: ![Processindicator](./img/ProcessIndicator22.jpg) ### With paused Live Stream If video recording requires exclusive camera access because of specific configuration (see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md)), the live stream will be paused and a placeholder is shown instead: ![Video1](img/Video2.jpg) ## Photo Display When a photo, raw photo or video has been taken, the bottom area will show the photo together with its Metadata or its histogram: ![Foto1](img/Foto1.jpg) The file name for the photo, raw photo or video has been generated automatically based on system date/time when the request is issued to the camera. Above the photo, some buttons are shown which allow manipulation of a display buffer: ![PhotoBuffer0](img/FotoBuffer0.jpg) - Pressing the **-** button will remove the photo from display. It will not be removed from the file system. - The active **+** button shows that the photo is not yet a member of the display buffer. Pressing this button will add it to the buffer. This is shown as: ![PhotoBuffer1](img/FotoBuffer1.jpg) The indicator (x/y) above the **+** button shows the position of the Photo within the buffer as the left number and the total number of photos in the buffer as the right number. With the larger number of photos within the buffer, navigation buttons **<** and **>** allow navigation within the buffer: ![PhotoBuffer3](img/FotoBuffer3.jpg) Additional buttons are available: - **Hide** / **Show** can be used to hide or show the bottom area with the display buffer - **Clr(x)** will clear the entire buffer. The number in brackets is the total number of elements in the buffer. - **<** will display the previous photo in the buffer - **>** will display the next photo within the buffer - **-** when applied to a member of the buffer, will remove it from the buffer ## Metadata Along with a photo, also its metadata will be shown. Metadata and photo have been captured within the same capturing request. For the metadata, tooltips on the metadata properties explain the respective parameter. **Previous** and **Next** buttons allow scrolling the list of metadata if it does not fit on the screen. ![Metadata1](img/Metadata1.jpg) ![Metadata2](img/Metadata2.jpg) ## Histogram Alternatively to the metadata, you may also switch to Histogram if usage of histograms has been activated in the [Settings](./Settings.md). ![Histogram](img/MetaHistogram.jpg) The setting whether metadata or histogram are shown, is kept and remains active also when the next photo is taken or when scrolling through the Photo Buffer. Histogram graphics are calculated on the fly when they are requested for the first time. They are stored in a "hist" subfolder under the *Path for Photos/Videos* (see [Settings](./Settings.md)) ================================================ FILE: docs/ReleaseNotes.md ================================================ # Release Notes [![Up](img/goup.gif)](./index.md) ## V4.10.0 ### New Features - [Photo Series of Type "Timelapse"](./PhotoSeriesTimelapse.md) were extended with a new [Azimuth Mode](./PhotoSeriesTimelapse.md#azimuth-mode). This allows taking multi-day photo series where photos are taken at times when the sun position has specific azimuth values. - Extended capabilities of ```GET api probe``` [API](./API.md) WebService endpoint: Now, the ```PhotoSeriesCfg``` object and its properties are accessible which represents the settings for photo series. - [Information / Software Stack](./Information_Sys.md) now also shows version of libcamera. - A tutorial has been added which shows how to enable [Neural Network-based Automatic White Balance](./tutorials/AWB_with_neural_networks.md) ### Bugfixes - Empty *ID* is no longer possible when creating a new [Device](./SettingsDevices.md) - Fixed error ```AttributeError: 'Picamera2' object has no attribute 'allocator'``` which could occur when the Flask server was started or restarted with an active [Photo Series](./PhotoSeries.md) for which *Cont. on Server Start* was active. - Fixed errors which could occur when creating Histograms ([Photo Taking on Live screen](./Phototaking.md#histogram) or [Exposure Series](./PhotoSeriesExp.md))
This resolves [raspi.cam-srv Issue #89](https://github.com/signag/raspi-cam-srv/discussions/89). ## V4.9.0 ### New Features - New [Actions](./TriggerActions.md) can now be immediately [tested](./TriggerActions.md#testing-an-action) before they are used in a [Trigger assignment](./TriggerTriggerActions.md) or in an assignment to an [Action Button](./SettingsAButtons.md) or a [Live Button](./SettingsLButtons.md). - [Device Type Configuration](./SettingsDevices.md#device-type-configuration) for [gpiozero Output Devices](https://gpiozero.readthedocs.io/en/stable/api_output.html#regular-classes) has been extended to allow [Action Configuration](./TriggerActions.md) for more action methods:
LED : toggle, blink, value
PWMLED: toggle, blink, pulse, value
RGBLED: on, off, toggle, blink, pulse
Buzzer: toggle, beep, value
Motor : reverse ## V4.8.0 ### New Features - Added [Ctrl Pane](./CameraControls_Ctrl.md) to [Live Screen](./LiveScreen.md) for which you can [configure buttons](./SettingsLButtons.md) for execution of OS commands or [Actions](./TriggerActions.md) for control of various devices such as servos for Pan/Tilt control. - Added [ServoPWM](./gpioDevices/ServoPWM.md) as new [Device Type](./SettingsDevices.md).
This can be used as alternative to [gpiozero Servo](https://gpiozero.readthedocs.io/en/stable/api_output.html#servo), which uses software PWM (pigpio is currently not available under Trixie) resulting in significant jitter and is, therefore, not suitable to be used as device for camera positioning.
ServoPWM is based on the [rpi_hardware_pwm](https://github.com/Pioreactor/rpi_hardware_pwm) library and assures jitter-free servo control. - The [installation](./installation.md) has been extended with installation of [rpi_hardware_pwm](https://github.com/Pioreactor/rpi_hardware_pwm) required for [ServoPWM](./gpioDevices/ServoPWM.md) - Git now ignores a folder ```prg/raspi-cam-srv/user_code``` and any sub-folders.
This allows putting any bash scripts or python programs in this location for use with [Versatile Buttons](./SettingsVButtons.md) or [Live Buttons](./SettingsLButtons.md). ### Bugfixes - Fixed ```TypeError: object of type 'CompletedProcess' has no len()``` which could occur when the server was restarted with button *Restart Server* in [Settings / Configuration](./SettingsConfiguration.md) - Fixed command execution from [Interactive Commandline](./ConsoleVButtons.md#interactive-commandline): In some situations it could happen that after command execution a different dialog was shown. - Created clearer error message in [Settings/Devices](./SettingsDevices.md) when test or calibration was started while [Event Handling](./TriggerControl.md) is active. Active event handling may have exclusive access on devices. - If a [Device Configuration](./SettingsDevices.md) has been modified while [Event Handling](./TriggerControl.md) is active, a hint to restart Event Handling is now displayed. - Fixed missing values for *Number of Rows* and *Number of Columns* in dialog [Settings/Action Buttons](./SettingsAButtons.md) - Fixed error ```Error in None: Error while executing action STP1-0: StepperMotor.rotate_to() got an unexpected keyword argument 'angle'``` ## V4.7.1 ### Bugfix - Fixed issue with checkboxes in dialog [Photos](./PhotoViewer.md).
For images where the descriptive text (filename) was larger than the image width, the checkbox was covered and could not be individually selected.
Resolves [raspi-cam-srv Issue #86](https://github.com/signag/raspi-cam-srv/issues/86) ## V4.7.0 ### New Features - As an alternative to the Flask buil-in WSGI server (werkzeug), for publicly accessible systems, now [Gunicorn](https://gunicorn.org/) ('Green Unicorn') is supported. Gunicorn is now default for the [Automatic Installer](./installation.md) as well as for the [Docker Image](./SetupDocker.md). If you want to switch your existing installation to run with Gunicorn, just run the [Installer](./installation.md#installer) over your existing installation and confirm to use Gunicorn. - [Info/System](./Information_Sys.md) now includes information on the [WSGI server running](./Information_Sys.md#wsgi-server) ### Changes - The [Info](./Information.md) screens were restructured. [System](./Information_Sys.md) and [Cameras](./Information_Cam.md) are now separated. ### Bugfixes - Fixed error ```TypeError: CameraController.requestStop() got an unexpected keyword argument 'forCam2'``` which could occur in specific cases when a video was recorded. - Fixed error ```Camera.framesUsb - Exception: cannot access local variable 'cfg' where it is not associated with a value``` which could occur in specific situations if a USB camera could not be opened. - Fixed issue in [Settings (No Camera)](./Settings_NoCam.md) where an error occurred when *Use USB Camera* is activated. ## V4.6.1 ### Bugfixes - Fixed deactivation of [Settings / *Use Camera AI*](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features): If a live stream is active with activated AI, the live stream is now restarted without AI. - Fixed [Config](./Configuration.md) for the case when the last active tab was *AI Configuration* and *Use Camera AI* was deactivated in [Settings](./Settings.md). In this case, the [Configuration for AI](./Configuration_AI.md) section was erronously visible. - Fixed errors which occurred during Docker container startup in case of [Running raspiCamSrv as Docker Container](./SetupDocker.md) (resolves [raspi-cam-srv Issue #83](https://github.com/signag/raspi-cam-srv/issues/83)): .. Added ```dpkg-dev``` to the ```Dockerfile``` .. Skipping check of time sync in case raspiCamSrv is running in a container (see hint in [Info dialog](./Z_Legacy_Information.md#raspberry-pi)) - Fixed support of USB WebCams when [Running raspiCamSrv as Docker Container](./SetupDocker.md). .. Added ```v4l-utils``` to the ```Dockerfile``` - Fixed error ```FileNotFoundError: [Errno 2] No such file or directory: '/sys/kernel/debug/imx500-fw:11-001a/fw_progress'``` for [AI Camera Support](./AiCameraSupport.md) in the case when [Running raspiCamSrv as Docker Container](./SetupDocker.md). .. Extended ```docker-compose.yml``` to expose ```/sys/kernel/debug/imx500-fw``` to the container. ## V4.6.0 ### New Features - Support of AI features for the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html):
You can specify the neural network model to be used by the camera (see [Camera AI Configuration](./Configuration_AI.md)) and see inference information visualized in the Live stream or on photos or videos. See [AI Camera Support](./AiCameraSupport.md) ### Changes - The behavior of the Live view at camera start has been changed. Especially, in order to avoid irritation about long startup times for the imx500 [AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html), **raspiCamSrv** shows an [animation while the camera is starting](./UserGuide.md#live-stream-at-camera-start). - Maximum for [Buffer Count](./Configuration.md#buffer-count) in [Camera Configurations](./Configuration.md#configuration-tab) is increased from 6 to 12. The larger value has been used in the [imx500 examples of Picamera2](https://github.com/raspberrypi/picamera2/tree/main/examples/imx500) ## V4.5.0 ### New Features - [Direct Control Panel](./LiveDirectControl.md) added to [Live View](./LiveScreen.md#accessing-the-direct-control-panel) for fine tuning of numeric [Camera Control](./CameraControls.md) parameters. - Added additional endpoints for photo snapshots with high resolution. Previews are available in the [Cam/WebCam](./CamWebcam.md) dialog. Please note the [special restrictions](./CamWebcam.md#photo-snapshot). Covers [raspi-cam-srv Issue #79](https://github.com/signag/raspi-cam-srv/issues/79) ### Changes - Style sheet switched to [W3.CSS](https://www.w3schools.com/w3css) 5.02 ### Bugfixes - Fixed ```TypeError: stat: path should be string, bytes, os.PathLike or integer, not NoneType``` which could occur after a [Reset Server](./Settings.md#configuration) ## V4.4.1 ### Bugfix - Fixed the update procedure with dialog [Settings/Update](./SettingsUpdate.md). The procedure implemented in V4.3.2 did not work correctly. Although it confirmed that the update was successful, the version remained at the old version after restart. If you were running into that issue, you should -- ```ssh``` to the server -- ```cd prg/raspi-cam-srv``` -- ```git reset --hard origin/main``` The [Update Procedure](./updating_raspiCamSrv.md) has been modified, accordingly. For versions 4.4.1 and later, the [Settings/Update](./SettingsUpdate.md) procedure should work correctly. ## V4.4.0 ### New Feature - [Media Viewer](./UserGuide.md#media-viewer) added to all images and videos in the UI. ## V4.3.2 ### New Feature - [raspiCamSrv Version Updates](./SettingsUpdate.md) supports periodical search for updates on GitHub with indication of new versions and version update as well as server restart from the UI. ## V4.3.1 ### Bugfix - Fixed the issue with bad Live Stream quality for Raspberry Pi models ```<``` 5. When starting the live stream, raspiCamSrv usually configurs the camera with all 3 streams (lowres, main, raw), so that the live stream (lowres) can remain active while photos (main), raw photos (raw) or videos (main) are taken. For Raspberry Pi Zero and other models ```<``` 5, it turned out that the stream quality is bad when the raw stream is included in the configuration. Now, for models ```<``` 5, the raw stream is excluded from the configuration for the live stream, resulting in better quality. For these models, when a raw photo is taken, the live stream will be temporarily stopped and reactivated without raw stream configuration after the raw photo has been stored. ## V4.3.0 ### Changes - Documentation has been moved to [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/). The entire documentation will be versioned with the software and Online Help links will point to documentation with the version of the software, rather than to the latest version. ## V4.2.0 ### New Features - For [Motion Detection](./TriggerMotion.md), *Regions of Interest* as well as *Regions of NO Interest* can be specified. These are respected during Motion Detection for CSI as well as USB cameras and for all *Motion Detection Algorithms* - In case of multiple cameras, the camera-related [Ttigger](./Trigger.md) settings ([Trigger/Motion](./TriggerMotion.md) and [Trigger/Camera](./TriggerCameraActions.md)) are now camera-specific. When the active camera is switched, these settings are replaced by those which had been defined and [saved for camera switch](./CamMulticam.md#save-active-camera-settings-for-camera-switch) for the new camera, before. This is especially important when regions of interest are used for motion detection. These settings will not be lost when cameras are switched. - [Backup and Restorage](./SettingsConfiguration.md) of configuration and other stored data. ### Bugfixes - For USB cameras an error occurred when *Focus* was set to 0.0 with *Autofocus Mode* "Manual". This is now fixed. - For [Motion Capturing](./TriggerMotion.md) with USB cameras, the frame rate for video recording was increased from 14.5 to 30. With this frame rate, the video length is in better accord with the configured duration. ## V4.1.0 ### New Features - [Zoom and Pan](./ZoomPan.md) are now also available for USB Cameras. - [Focus handling and Image Controls](./CameraControls_UsbCams.md) are now available for USB Cameras. **NOTE** These features have currently been tested with different Logitech cameras. Please notify if other cameras show unexpected behavior. ### Changes - For USB cameras, it is no longer possible to choose *Sensor Mode* "Custom" for the different [Configuration](./Configuration.md) scenarios. Practical experience with web cams has shown that these do not support other image sizes as those defined in the discrete formats (sensor modes). The camera would always use the smallest standard format which entirely contains the specified area. Therefore, it does not make sense allowing the specification of a custom format, if the camera will not use it. - For USB cameras, the aspect ratio for *Stream Size* of different [Configuration](./Configuration.md) use cases is not synchronized. For these cameras, the checkbox [Sync Aspect Ratio](./Configuration.md#configuration-tab) is unchecked and disabled. ## V4.0.3 ### Extension - In the [Info](./Z_Legacy_Information.md) dialog, the section [Raspberry Pi](./Z_Legacy_Information.md#installed-cameras) was extended with information on the software stack, which can be helpful analyzing errors which could occur with specific versions or installation details. ## V4.0.2 ### Bugfixes - Fixed error which occurred when changing the *Active Camera* in [Settings](./Settings.md) to a USB camera ## V4.0.1 ### Bugfixes - Fixed error "Error `````` : Class Camera has no element when_motion_detected" Cause of the error was a wrong configuration for [Triggers](./TriggerTriggers.md) with *Source* 'Camera', which had included an event 'when_motion_detected'. This event is only available for *Source* 'MotionDetector'. The wrong configuration is now corrected. If you have configured a Trigger for event 'when_motion_detected' with *Source* 'Camera', you need to delete it and replace it with a trigger with *Source* 'MotionDetector' This covers part of [raspi-cam-srv Discussion #77](https://github.com/signag/raspi-cam-srv/discussions/77). ## V4.0.0 ### New Features - Seamless integration of **USB cameras** with CSI cameras (see [Info](./Z_Legacy_Information.md) and [Multi Cam](./CamMulticam.md))
USB cameras are accessed through OpenCV which must have been installed (see [Installation](./installation.md) Step 11.).
The use of USB cameras can be activated or deactivated in the [Settings](./Settings.md).
USB cameras are handled in exactly the same way as CSI cameras and they can be used in all operational functions, except [Focus Stacks](./PhotoSeriesFocus.md) and [Exposure Series](./PhotoSeriesExp.md).
**NOTE**: Currently [Controls](./CameraControls.md) (Focus, Zooming, Ecposure and Image control) cannot be used with USB cameras. However, images can be flipped and resolution can be adjusted with [Configuration](./Configuration.md). - Hot-Plug of USB cameras is supported. See button **Reload Cameras** in [Settings / Configuration](./SettingsConfiguration.md). - **No-Camera** Mode is supported.
Before, when no cameras were installed, **raspiCamSrv** could not be started.
Now, it is possible to use **raspiCamSrv** also without cameras, for example in order to control [GPIO Devices](./SettingsDevices.md) through the [Action Buttons](./ConsoleActionButtons.md) or through the [Event Handling Infrastructure](./Trigger.md) or in order to use the [Versatile Buttons](./ConsoleVButtons.md) Console to issue freely configurable OS commands and see their output. - [Switching Cameras](./CamMulticam.md#switch-cameras) in the [Multi Cam](./CamMulticam.md) dialog now requires confirmation when Camera [Confoguration](./Configuration.md) and/or [Controls](./CameraControls.md) were changed and not yet saved for camera switch. This shall avoid unintended loss of configuration work when switching the cameras. - [Installation Procedure](./installation.md) has been updated for use with **Debian-Trixie**, the successor of **Bullseye**.
Tests have so far only be made with a Raspberry Pi Zero 2 system with connected CSI and USB camera.
Please report any issues, you may run into, in other configurations. ### Bugfixes - Made [Photo Display](./Phototaking.md#photo-display) robust against [deletion of photos](./PhotoViewer.md). Before, when a photo was deleted, the entire display buffer was cleared. Now, only the deleted photos are removed from the display buffer. Also, when the server is started with stored configuration, it is checked whether to stored buffer information is still consistent with the existing photos or videos. ## V3.7.1 ### Bugfixes - Fixed [Notification](./TriggerNotification.md) for [Motion Capturing](./TriggerMotion.md). In special cases of parameters for [Trigger Control](./TriggerControl.md), [Camera Actions](./TriggerCameraActions.md) or Video/Photos to be included in the message (see [Notification Settings](./TriggerNotification.md)), notification mails were not sent or images were not included in the mails. This fix resolves [raspiCamSrv Issue #76 (Notification email)](https://github.com/signag/raspi-cam-srv/issues/76) ## V3.7.0 ### New Features - [Camera Calibration](./CamCalibration.md) uses an image series of a calibration pattern for calibration of a stereo camera pair. - [Stereo Vision](./CamStereo.md) uses a stereo camera setup for creation of 3D videos or depth maps. - The [Multi-Cam](./CamMulticam.md) dialog has a new function to [Synchronize Configurations](./CamMulticam.md#synchronize-configurations). ### Bugfixes - Fixed behavior when [starting server with stored configuration](./Settings.md#configuration) after the camera or one of the cameras has been replaced by a different model. In the previous version, the settings for the second camera have been taken from the stored configuration. Now, if a camera has been changed, the corresponding configuration is reset to default. This will also be indicated with the yellow indicator for [unsaved configuration changes](./UserGuide.md#elements) - Layout of [Multi-Cam](./CamMulticam.md) dialog has been corrected. In the previous version the two columns had a different width. - Fixed wrong status of [Start server with stored Configuration](./Settings.md#configuration) after a [Reset Server](./Settings.md#configuration) - Fixed image distortion when the stream size of a [Configuration](./Configuration.md) has been changed to an aspect ratio which is not that of the default [ScalerCrop](./ScalerCrop.md). In this case, scalerCrop is now activated in the controls, so that it will also be included in the streaming configuration and will also be synchronized with the second camera, if this is the same model as the active one. - Fixed inappropriate status indicators and [Multi-Cam](./CamMulticam.md) menu for systems having just one camera. ### Note - For **Raspberry Pi Zero** running the latest builds of **Bookworm**, there seems to be an issue with MJPEG streaming. It has been observed that, after some time of streaming (a few minutes), the entire system gets stalled and does no longer react without rebooting. The reasons are not yet understood and need to be analyzed. **This problem does not occur on Bullseye systems!** **The issue is no longer observed in Bookworm systems when updated after about mid September 2025!** ## V3.6.2 ### Bugfixes - Fixed "UnboundLocalError" which occurred when creating a new [Trigger](./TriggerTriggers.md) or a new [Action](./TriggerActions.md). ## V3.6.1 ### Bugfixes - Fixed "NameError: name 'photoViewConfig' is not defined" ## V3.6.0 ### New Features - Now supporting alternate or simultaneous photo taking and video recording with both cameras for systems which allow connecting two (non-USB) cameras (currently Raspberry Pi 5). - [API](./API.md) extended with new web services supporting photo taking and video recording with both cameras. - The new [Multi-Cam](./CamMulticam.md) dialog gives access to photo taking and video recording functions for both cameras, if available. ### Bugfixes - Fixes "KeyError: 'GPIO'" which could occur when the [event handling system](./Trigger.md) was stopped without having fired a GPIO trigger during its life time. - Fixed an issue where [Test Motion Detection](./TriggerMotion.md#testing-motion-capturing) did not work if it was started within a period where [Trigger Operation](./TriggerControl.md) was not active. ## V3.5.6 ### Bugfixes - Fixed an issue, where a request did not return when trying to take a photo in cases, where the [configuration](./Configuration.md) for Live View and Photo both use the same stream but with different stream size. - Fixed missing Live view after an error has occurred during photo taking or video recording. In some cases, the Live view needs to be deactivated while taking a photo or recording a video because of incompatible [configuration](./Configuration.md). The deactivation flag is now cleared so that the Live view will show up after an error has occurred. **NOTE:** that you need to push the *Live* menu button for refreshing the screen. - Fixed wrong [Status Indicator](./UserGuide.md#process-status-indicators) for [Trigger](./TriggerTriggers.md) thread after [starting server with stored configuration](./SettingsConfiguration.md) - Improved exception handling for camera access: Exception occurring during camera configuration are now not only logged but also shown within the UI. Low level error messages, indicating the error source as reported by Picamera2, are no longer overwritten by higher level more general messages. ### New Features - The [Settings/Configuration](./SettingsConfiguration.md) screen now shows all unsaved configuration changes which have been made during a session. This can help to decide whether or not the current configuration needs to be saved. Thus, whenever the [Configuration Status Indicator](./UserGuide.md#process-status-indicators) is switched on, a new entry for unsaved configuration changes is made. - [StepperMotor](./gpioDevices/StepperMotor.md) has got new functionality. [wipe](./gpioDevices/StepperMotor.md#wipeangle_from-angle_to-speed-count) will rotate back and forth between given angles for a certain nomber of cycles or until [stop](./gpioDevices/StepperMotor.md#stop) is called. ### Changes - Adapted [Installation procedure](./installation.md) step 11 for version of [matplotlib](https://matplotlib.org/). As discussed in [picamera2 issue \#1211](https://github.com/raspberrypi/picamera2/issues/1211), Picamera2 relies on the numpy version coming with Debian installation, which is numpy 1.x. On the other hand, matplotlib version 3.8 and later seem to require numpy 2.x, which is binary incompatible to version 1.x. - For [StepperMotor](./gpioDevices/StepperMotor.md), the minimum speed (which is achieved by setting *speed=0*) has been reduced by a factor of 10. The angular velocity is now: For speed=0: 164.20 seconds for 360° For speed=1: 4.42 seconds for 360° - Extended capabilities of ```GET api probe``` [API](./API.md) WebService endpoint: Now ```Camera.streamOutput``` and ```Camera.stream2Output``` are accessible which represent the streaming output of the primary and secondary camera. ## V3.5.5 ### Bugfixes - Thread-safe handling of last live stream access. The last access of a client to a camera stream controls automatic shutdown of the streaming server after 10 sec of inactivity (see [Streaming](./UserGuide.md#streaming)). Since streaming clients and servers are executed in different threads, it could happen in rare cases that a client has tried to access a stream in a phase where the server has started but not yet completed to shut down because of inactivity. Since the camera is closed when streaming is shut down, different camera errors could occur, depending on camera shutdown status. This could occur in particular when taking photos or taking [Photo Snapshots](./CamWebcam.md#photo-snapshot) through the Web URL. Now, access to the time of last stream access has been made thread-safe by holding locks while a process is evaluating or changing this value and, in cases where inactivity is detected by the server, the lock is only released after the server has completely shut down. (Fixes [raspi-cam-srv issue #61](https://github.com/signag/raspi-cam-srv/issues/61)) - After an OS upgrade, *Kernel Version* and *Debian Version* in the [Info/Installed Cameras screen](./Z_Legacy_Information.md) did not show the correct values if the server was configured to [start with the stored configuration](./SettingsConfiguration.md). Instead, the values from the stored configuration were shown. This has been fixed. The entry in the [server configuration storage file](./SettingsConfiguration.md#server-configuration-storage) will have the old value until the configuration has been stored. ### Changes - Information on the *Debian Version* in the [Info/Installed Cameras screen](./Z_Legacy_Information.md) now includes information on the system architecture (32-/64-bit) of the installed OS. ### New Features - A new [API](./API.md) WebService endpoint is provided: ```GET api probe``` allows probing oject properties of live objects of an active **raspiCamSrv** server. **NOTE:** This service is mainly intended for error analysis within a live system and requires detailed knowledge of the raspiCamSrv object model. You can specify a set of object attributes for which attribute values shall be queried. As objects, you select from the base singleton objects {Camera(), CameraCfg(), MotionDetector(), PhotoSeriesCfg() or TriggerHandler()} and then specify valid properties with dot-notation. The result is returned in JSON format. Error messages are shown if an attribute is not JSON serializable. ## V3.5.4 ### Bugfixes - Corrected deprecated log level for Picamera2 logging from ```Picamera2.ERROR``` to ```logging.ERROR``` (See [raspi-cam-srv Issue #62](https://github.com/signag/raspi-cam-srv/issues/62)) ## V3.5.3 ### Changes In dialog [Web Cam](./CamWebcam.md), the button **Memorize Configuration and Controls for Camera Change** was renamed to **Save Active Camera Settings for Camera Switch** in order to more clearly express its functionality. See [raspi-cam-srv Issue #60](https://github.com/signag/raspi-cam-srv/issues/60) ## V3.5.2 ### Bugfixes - Fixed a bug which caused [motion detection](./TriggerMotion.md) to be stalled after booting the Raspberry Pi.
The error was caused by a race condition between start of the raspiCamSrv server and syncronization of system time with a time server.
When time synchronization resets the system time while the live view is already active, this will signal inactivity for more than 10 sec and, therefore, immediately stop the live stream and subsequently close the camera.
Since motion detection relies on an active live stream, this could cause blocking of the motion detection thread which then cannot be stopped except by restarting the server.
Now, the system checks the system time synchronization status at startup and, if necessary, waits until time is syncronized with the time server.
This resolves [raspiCamSrv Issue #28: raspi-cam-srv seems to be frozen sometimes](https://github.com/signag/raspi-cam-srv/issues/28)
**NOTE**: This fix does not currently work when running raspiCamSrv as [Docker Container](./SetupDocker.md) - Fixed missing [Config](./Configuration.md) screen for [Docker installations](./SetupDocker.md) ### New Feature - The [Info](./Z_Legacy_Information.md) screen now shows the time when **raspiCamSrv** has recognized time synchronization, which can be considered as the server start time. ## V3.5.1 ### Bugfixes - Fixed video recording during motion tracking for Raspberry Pi models 1, 2, 3:
For models Pi 4 and lower, video recording during motion tracking should use the *Live View* [Configuration](./Configuration.md) because this is usually set to lower resolution and should, therefore, not cause memory issues with these devices.
Only for Pi 5, the *Video* Configuration should be used.
Unfortunately, in versions up to V3.5.0, this was only applied to Pi 4 and Pi Zero, whereas for Pi 1, Pi 2 and Pi 3 the *Video* configuration was used.
This is now corrected and for all models <= Pi 4 the *Live View* configuration is used. ## V3.5.0 ### New Features - [StepperMotor](./gpioDevices/StepperMotor.md) has got new functionality. In addition to the new methods ```swing()``` and ```rotate_to(angle)```, the ```current_angle``` of rotation is tracked and can be set and queried.
```swing()``` allows stepwise rotations within given boundaries whereas ```rotate_to(angle)``` rotates to a specified angle.
This functionality relies on [Calibration](./SettingsDevices.md#calibrating-a-device) and device status tracking.
**NOTE**: If you have already a StepperMotor configured, it will not inherit the new functions. You will need to recreate it. - [Device Configuration](./SettingsDevices.md) for StepperMotor allows [Calibration](./SettingsDevices.md#calibrating-a-device) to set a specific orientation as zero reference. - For devices requiring [Calibration](./SettingsDevices.md#calibrating-a-device) (currently only StepperMotor), the status is continuously tracked and memorized so that it can be restored after a restart or a renewed device access.
**NOTE**: This may not work if the **raspiCamSrv** server is stopped while the StepperMotor is active. - A new Camera [Trigger] is available: ```when_series_photo_taken``` allows assigning [Actions](./TriggerActions.md) when a photo has been taken within a [Photo Series](./PhotoSeries.md).
The trigger does not fire in case of an [Exposure Series](./PhotoSeriesExp.md) or a [Focus Stack](./PhotoSeriesFocus.md) ### Changes - Camera [Actions](./TriggerActions.md) do no longer trigger events. This means: if, for example, a [Trigger](./TriggerTriggers.md) has been specified when a Photo is taken, this trigger will no longer fire if the photo is taken as an action of another trigger. ### Bugfixes - [API](./API.md) endpoint ```api/start_triggered_capture``` now also starts event handling and not only motion detection - [API](./API.md) endpoint ```api/stop_triggered_capture``` now also stops event handling and not only motion detection - Fixed error ```Camera.frames - Exception: argument of type 'NoneType' is not iterable``` which could occur if button [Load Stored Configuration](./SettingsConfiguration.md) has been pressed. - Fixed [Load Stored Configuration](./SettingsConfiguration.md): Now, all background processes are stopped before the stored configuration is loaded, and they are restarted afterwards, if they had been active before. - Fixed [Reset Server](./SettingsConfiguration.md): Now, all background processes are stopped before the configuration is set to default, and they are restarted afterwards, if they had been active before. Some missing configurations which may have led to errors have also been fixed. - Made ```table-layout:fixed``` for [Versatile Buttons](./ConsoleVButtons.md) and [Action Buttons](./ConsoleActionButtons.md) so that all columns have equal width and empty rows are shown. - Multiple SMTP mails for the same [Trigger](./TriggerTriggers.md) are avoided. - Fixed event notification. Due to a timing issue, sent mails could be incomplete and attachments may have been missing. ## V3.4.0 ### New Features - Under [Actions](./TriggerActions.md), it is now possible to configure camera actions:
take_photo
start_video
stop_video
record_video with a configurable duration. - [Actions](./TriggerActions.md) now support SMTP action for sending a mail in case of an event. - [Trigger](./TriggerTriggers.md) allow configuring *MotionDetector* trigger for *CAM-1*: *when_motion_detected*.
This trigger fires when a motion is detected by the cameras [motion detection](./TriggerMotion.md) algorithms. - [Device Types](./SettingsDevices.md#device-type-configuration) for GPIO devices include now additional GPIO base classes which allows integrating more general devices:
- DigitalInputDevice
- DigitalOutputDevice
- OutputDevice - An [indicator](./UserGuide.md#title-bar) has been added which indicates unsaved configuration changes. - The [event log](./TriggerActive.md#log-file) can now be downloaded from the [Calendar view](./TriggerCalendar.md) ### Changes - For [Triggers](./TriggerTriggers.md) with *Source* "Camera" the device names were changed:
"Active Camera" -> "CAM-1"
"Second Camera" -> "CAM-2"
If triggers have been created with the old *Device* names, they will be renamed automatically when data are loaded from the stored configuration. - For [Versatile Buttons](./SettingsVButtons.md) and [Action Buttons](./SettingsAButtons.md) the maximum number of rows and columns was changed from 9 to 99. - Added favicon for browser tab ### Bugfixes - Fixed error
```ERROR in camCfg: Error loading from /home/pi/server/raspi-cam-srv/raspiCamSrv/static/config/serverConfig.json: 'NoneType' object is not subscriptable```
reported in [raspi-cam-srv Issue #55](https://github.com/signag/raspi-cam-srv/issues/55) - Bugfixes and improvements for Class [StepperMotor](./gpioDevices/StepperMotor.md):
For full-step mode, the waiting time is now doubled while speed range is unchanged. (1ms does not work)
New methods *step(steps)* and *rotate(angle)* have been added, which allow positive and negative arguments.
Rotations are now with higher precision. They are now in integers of motor steps rather than geared steps.
For full-step mode, now two coils are activated in each step, instead of only one, which increases torque. - Several tables which can become large, can now be scrolled with fixed headers. ## V3.3.0 ### New Features - Configuration of access to [GPIO-connected devices](./SettingsDevices.md) through the [gpiozero](https://gpiozero.readthedocs.io/en/stable/index.html) library. - Extension of ```gpiozero.OutputDevice``` for support of [Stepper Motors](./gpioDevices/StepperMotor.md) - Configuration of [Triggers](./TriggerTriggers.md) for events from GPIO-connected input devices, such as sensors and buttons. - Implementation of callback hooks for photo-related events ('when_photo_taken', 'when_recording_starts', 'when_recording_stops', 'when_streaming_*_starts', 'when_streaming_*_stops') for integration with the new event handling infrastructure. - Configuring of [Actions](./TriggerActions.md) for GPIO-connected output devices. - [Configuration of Action Buttons](./SettingsAButtons.md) and [Console / Action Buttons](./ConsoleActionButtons.md) for the direct manual execution of [Actions](./TriggerActions.md) with GPIO-connected output devices such as LEDs, motors and servos. This includes also the control of Stepper Motors. - An option *On Dial Marks* has been added to [Photo Series](./PhotoSeries.md) which assures that photos will be taken exactly (within tolerances) at every full hour, half hour, quarter, minute, ..., depending on the chosen interval. ## V3.2.0 ### New Features - The [Settings for Versatile Buttons](./SettingsVButtons.md) now allows setting the commandline in [Versatile Buttons](./Console.md) to be interactive, which allows entering commands directly. - Access to Online Help added to the different application screens.
The *Online Help* button opens the document page on GitHub related to the active dialog. ## V3.1.0 ### New Features - Functionality has bee added for configuration of [Versatile Buttons](./Console.md) for execution of commands on the level of the Raspberry Pi's Linux OS.
This covers part of the request in [Discussion #47](https://github.com/signag/raspi-cam-srv/discussions/47). ## V3.0.0 ## Package version upgrade - Released for Flask 3.1.0
raspiCamSrv can use the current Flask version 3.x
Upgrading Flask in an existing installation is not mandatory.
In order to upgrade from Flask 3.0.0 to the latest version 3.x, proceed as follows:
```cd prg/raspi-cam-srv/```
```source .venv/bin/activate```
```pip install --upgrade "Flask>=3,<4"``` ## V2.13.0 ### New Feature - RaspiCamSrv can now also be deployed in Docker.
See [Running raspiCamSrv as Docker Container](./SetupDocker.md) ## V2.12.0 ### New Features - The [Info Screen](./Z_Legacy_Information.md) has been extended by a section [Streaming Clients](./Z_Legacy_Information.md#streaming-clients) which shows a list of clients which are currently using any of the camera streams. ### Bugfixes - Fixed TypeError which could occur if a paused [Photo Series](./PhotoSeries.md), for which no photos had been taken, was continued. - Fixed an error where for a [Photo Series](./PhotoSeries.md) with *Interval* > 60 sec. an additional photo could have been taken about 1.5 sec. before the expected time when the regular photo is taken. If the waiting time between successive photos is > 60 sec. and if no other process requires the camera, the camera is closed to minimize resource consumption. The waiting time is then shortened by 1.5 sec., to account for the time required for camera wakeup. If, however, the live stream is activated within 60 sec. before the time for the next photo, the camera is already active and this additional time is not required. - Fixed an error for [Photo Series](./PhotoSeries.md) with *Interval* >> 60 sec. for the case when the series was started while the live stream was still active. In this case, the photo series did not stop the camera during the waiting period because it was required by the live stream. If the photo series continued taking the next photo, it did not recognize that the camera was closed in the meantime. An attempt to take a photo caused the thread to stop without error notification.
As a result, the series seemed to be active while it was actually dead. - Fixed an error for [Photo Series](./PhotoSeries.md) with *Interval* > 60 sec. where controls (e.g. zoom/ScalerCrop) were not applied to the photos of the series, except probably for the first one.
The reason was that the camera is closed if the waiting time for the next photo is > 60 sec. and after restart the camara wasn't given time to pick up the controls settings. Now, an additional waiting time of 1 sec. has been added which resolves the issue. ## V2.11.4 ### Bugfix - Fixed [initialization of the raspiCamSrv API](./SettingsAPI.md) which did not work when a secrets file did not yet exist. ## V2.11.3 ### Bugfix - Fixed "TypeError: can only concatenate str", which might occur in special cases for a [Sun-controlled timelapse series](./PhotoSeriesTimelapse.md#). - Fixed wrong display of *Sunset* in [Timelapse series](./PhotoSeriesTimelapse.md#). - Fixed "KeyError: 'UnitCellSize'" for cases where camera_properties do not include information on the physical size of the sensor’s pixels ### Doc - Added [description for setup of stanalone systems](./bp_PiZero_Standalone.md) ## V2.11.2 ### Bugfix - Fixed an issue where photos and videos could not be taken if the [Transform](./Configuration.md#transform) settings for the different configuration were different.
Now, when modifying the *Transform (flip <> or flip v)* are changed in one configuration this change is also applied to all other configurations.
This covers raspiCamSrv Issue #33 [Errors after changing Transform settings](https://github.com/signag/raspi-cam-srv/issues/33) ## V2.11.1 ### Bugfix - Fixed an import error which occurred after having upgraded to V2.11 when package ```flask-jwt-extended``` has not yet been installed. ## V2.11.0 ### New Features - V2.11.0 introduces the new [raspiCamSrv API](./API.md) for interoperability of raspiCamSrv with other software packages.
This resolves the feature request raspi-cam-srv issue #34 [API?](https://github.com/signag/raspi-cam-srv/discussions/34) - Required installation actions:
In order to allow API support, it is necessary to install an additional package.
This can be done before or after the [Update Procedure](./updating_raspiCamSrv.md):
```cd ~/prg/raspi-cam-srv```
```source .venv/bin/activate```
```pip install flask-jwt-extended``` ### Changes - The [Settings](./Settings.md) screen has been restructured to incorporate the additional settings required for the API ## V2.10.5 ### Bugfixes - Allowed port range in [Trigger - Notification](./TriggerNotification.md) extended to [1 ... 65535]
Partly resolves raspi-cam-srv issue #42 [SMTP port issue](https://github.com/signag/raspi-cam-srv/issues/42) - Fixed [Trigger Notification](./TriggerNotification.md) for SMTP servers which do not require authentication.
It can now be specified whether or not the server requires authentication.
Within the connection test it is checked whether the SMTP server requires SSL and authentication.
If the requirements are not consistent with the settings on the [Notification](./TriggerNotification.md) screen, an error message is shown.
Resolves raspi-cam-srv issue #42 [SMTP port issue](https://github.com/signag/raspi-cam-srv/issues/42) ## V2.10.4 ### Bugfix - Fixed function [Load Stored Configuration](./SettingsConfiguration.md) on the [Settings](./Settings.md) screen.
After execution of this function, values shown on the [Settings](./Settings.md) screen were only updated to the values loaded from the stored configuration after the page has been refreshed.
Resolves raspi-cam-srv issue #39 [load_config route assumes LiveStream2 exists (causes crash if non-existent)](https://github.com/signag/raspi-cam-srv/issues/39) ## V2.10.3 ### Bugfixes - Fixed function [Load Stored Configuration](./SettingsConfiguration.md) on the [Settings](./Settings.md) screen.
This function failed in cases when only a single camera is connected to a Raspberry Pi.
Resolves raspi-cam-srv issue #39 [load_config route assumes LiveStream2 exists (causes crash if non-existent)](https://github.com/signag/raspi-cam-srv/issues/39) - Fixed [Switch Cameras](./CamMulticam.md#switch-cameras) on the [Web Cam](./CamWebcam.md) screen.
If working with two cameras, this function caused an error when [Triggered Capture of Videos and Photos](./Trigger.md), a [Photo Series](./PhotoSeries.md) or [Video Recording](./Phototaking.md#video) is currently active.
The user is now asked to stop either of these processes before switching cameras. ## V2.10.2 ### New Features - Added kernel version and Debian version to [Info](./Z_Legacy_Information.md) screen. ## V2.10.1 ### Bugfix - Fixed an issue with platform-specific search of tuning files. ## V2.10.0 ### New Features - Support of [Camera Tuning](./Tuning.md) by selection of alternate tuning files.
Resolves raspi-cam-srv issue #26 [NoIR camera settings](https://github.com/signag/raspi-cam-srv/issues/26)
**Note:** There is still an issue when streaming two cameras. (See Picamera2 Issue #1103 [Tuning file support not thread-safe?](https://github.com/raspberrypi/picamera2/issues/1103)) ### Bugfixes - Fixed error ```The browser (or proxy) sent a request that this server could not understand.``` which ocurred when pressing *Submit* in the [Control](./TriggerControl.md) tab of the [Trigger](./Trigger.md) menu.
Resolves raspi-cam-srv issue #27 [Trigger Control Submit make server error](https://github.com/signag/raspi-cam-srv/issues/27) ## V2.9.2 ### Bugdixes - Disallow changing parameters of a [Photo Series](./PhotoSeries.md) after it had already been started. ## V2.9.1 ### New Feature - [Photo Series](./PhotoSeries.md) can be downloaded (see [Downloading a Series](./PhotoSeries.md#downloading-a-series)) ## V2.9.0 ### New Features - The [Photo Viewer](./PhotoViewer.md) has been enabled to download photos and to delete photos from the Raspberry Pi. ## V2.8.4 ### Bugfixes - When a [Sun-controlled Photo Series](./PhotoSeriesTimelapse.md) was started, it could happen that the series end was recalculated without considering the configured time periods.
Typically, this happened when the configured start time was earlier than the time when the series was actually started.
Because this end time was in many cases earlier than the start of the next time slot, the series may have immediately stopped when the next period started.
This is now fixed. - In some cases when a series was finished because the configured end time has been reached, the background process did not stop and continued to produce photos with zero interval until the configured number of shots has been reached. - When a [Photo Series](./PhotoSeries.md) was paused and continued afterwards, the **Current shots** was incremented without taking a photo with this number. Therefore **current shots** did not represent the real number of photos and the series would be stopped before reaching the configured **Number of shots**
Now, **Current shots** is only incremented if a photo has been taken. - When the waiting time for the next shot in a [Photo Series](./PhotoSeries.md) is larger than 60 sec, RaspiCamSrv stops the camera and restarts it when the time is reached.
However, the restart requires 1.5 sec waiting time to allow the camera to collect statistics for auto-exposure algos.
Therefore, the phototaking is delayed by at least 1.5 sec with respect to the expected times.
This is now compensated.
The observed delay is now considerable smaller and ranges from ~0.2 sec for the first photo to ~0.04 sec for the next ones. ## Changes - When a new [Photo Series](./PhotoSeries.md) is created, the start time is delayed by 1 minute with respect to the current time to give time for further configurations. ## V2.8.3 ### Bugfix - Fixed layout issues in screen [Settings](./Settings.md) for cases where **Show Histograms** and/or **Ext Motion Detection** are not supportet. ## V2.8.2 ### Bugfix - The Bugfix introduced in [V2.8.0](#v280) caused an error on systems like Pi Zero where modules cv2, matplotlib or numpy cannot be installed.
This error is now fixed.
If in your system the [Settings](./Settings.md) screen shows that **Ext. Motion Detection supported** is checked and in screen [Trigger/Motion](./TriggerMotion.md) the **Motion Detection Algorithm** list only shows "Mean Square Diff", you can try the following:
Edit file ```prg/raspi-cam-srv/raspiCamSrv/static/config/triggerConfig.json```
Remove the part highlighted in the following screenshot, if it exists:
![Fix282](./img/RN282_img1.jpg) ## V2.8.1 ### Changes - Removed alternate type hints in module sun.
These were introduced in Python 3.10.
However in Raspberry Pi Zero systems Python 3.9 is installed. ## V2.8.0 ### New Features - For [Photo Series](./PhotoSeries.md), the page [Timelapse Series](./PhotoSeriesTimelapse.md) now allows specification of up to two daily periods depending on sunrise and sunset.
Refers to [Discussion #21](https://github.com/signag/raspi-cam-srv/discussions/21). - On the [Settings](./Settings.md) screen, new parameters for geographical latitude, longitude and elevation as well as a time zone selector have been added.
Non-zero settings for these parameters are required for using [Sun-controlled Photo Series](./PhotoSeriesTimelapse.md) ### Bugfixes - For [Motion Detection](./TriggerMotion.md), the list of supported algorithms had shown only "Mean Square Diff", even if **Ext. Motion Detection supported** was activated in the [Settings](./Settings.md) screen.
Now all available algorithms can be selected and used if the modules cv2, matplotlib and numpy are installed on the system (see [RaspiCamSrv Installation](./installation.md), 11) ## V2.7.1 ### Bugfixes - Images from a photo snapshot URL (see [Web Cam](./CamWebcam.md)) could not be saved using 'save as' from the context menu. The reason was that these images still contained the framing and mime type from MJPEG streaming. This is now fixed. This solves [raspi-cam-srv Issue #22](https://github.com/signag/raspi-cam-srv/issues/22) ## V2.7.0 ### New Features - For streaming access, it can now be configured in the [Settings](./Settings.md) screen whether authentication is required or not. The default is that authentication is not required, as before. This modification has been made for Feature Request [#20](https://github.com/signag/raspi-cam-srv/issues/20) ### Changes - The default log level for libcamera was set to ERROR instead of WARNING in order to suppress V4L2 pixel format warnings. ## V2.6.3 ### Bugfixes - Fixed ```Error starting camera: main stream should be a dictionary``` which accurred at server start, if an active [Photoseries](./PhotoSeries.md) with *Photo Type* "raw+jpg" was configured to be *Continued on Server Start*. - When a [Photoseries](./PhotoSeries.md) is automatically continued on server start/restart, previous versions did not allow seeing the live stream while the Photoseries was active. Now, if the photo series configuration is compatible with live stream configuration, you will see the live stream also after automatic continuation of the series. ## V2.6.2 ### Bugfixes - Fixed ```Exception: 'NoneType' object has no attribute 'get'``` which occurred when taking a video which requres exclusive camera access. The reason was that, while stopping the live stream, it was not recognized that video recording was intended. As a result, the camera was closed and access to the camera during video recording lead to this error. Refers to: [raspi-cam-srv Issue #18](https://github.com/signag/raspi-cam-srv/issues/18). - Fixed ```AttributeError: 'NoneType' object has no attribute 'requestStop'``` which could occur after applying *Reset Server* in the [Settings](./Settings.md) screen. ### Changes - For Raspberry Pi models lower than model 5 (Zero, 1, 2, 3, 4), the [Configuration](./Configuration.md) for *Photo* is initialized with the lowest *Sensor Mode* and the *Buffer Count* for *Video* is set to 2, identical with *Live View*. This makes all configurations compatible and allows for the Live Stream parallel to Video Recording, when using the default configuration. Refers to: [raspi-cam-srv Issue #18](https://github.com/signag/raspi-cam-srv/issues/18). ## V2.6.1 ### Bugfixes - With deactivated [Sync Aspect Ratio](./Configuration.md), the aspect ratio of different configurations was nevertheless synced. This is now fixed. - When activating [Sync Aspect Ratio](./Configuration.md), after it was previously deactivated, all aspect ratios were set to the one of *Live View* and not to the currently selected configuration. - When activating [Sync Aspect Ratio](./Configuration.md), after it was previously deactivated, ScalerCrop was not automatically updated. ## V2.6.0 ### New Features - [Zoom and Pan](./ZoomPan.md) has been completely reworked. It now takes regard of the [ScalerCrop](./ScalerCrop.md) specifics of Raspberry Pi cameras. This allows full control of image areas also for cases with extreme aspect ratios. **Note**: For cases where the height of the *Stream Size* is considerably larger than its width, the live stream in the [Live](./LiveScreen.md) screen may exceed the page height. This cannot currently be avoided without loosing the capability of graphical [focus](./FocusHandling.md#autofocus-windows) and [zoom](./ZoomPan.md#graphically-setting-the-zoom-window). - The [Config](./Configuration.md) screen now has an option to synchronize aspect reatios of *Stream Size*s across all configurations. If this is activated and a non-standard aspect ratio is configured, for example, for the *Live View*, the *Stream Size*s for the other configurations will be adjusted to the same aspect ratio. Then the Live Stream will no longer be distorted because the camera system will select a *ScalerCrop* with the same aspect ratio. - The [Info](./Z_Legacy_Information.md) screen in section [Camera x](./Z_Legacy_Information.md#camera-x) now shows the Sensor Mode in which the camera is currently operating if the camera is currently started. ### Changes - Camera [Configuration](./Configuration.md#) for *Raw Photo* now allows *Custom* *Stream Size*. However, if a *Stream Size* is specified which does not correspond to the output_size of one of the cameras Sensor Modes, the camera will automatically select a suitable Sensor Mode and produce a .dng file with the corresponding size. The reason for this change was that the new option to automatically syncronize aspect ratios across configurations can lead to a *custom* *Stream Size* also for the *Raw Photo* configuration. - The parameter *Stream Size aligned with Sensor Modes* in the [Configuration](./Configuration.md#) now dafaults to False. The reason for this change is that, when specifying identical *Stream Sizes* for *lores* and *main* streams, the camera could produce an error ```Error starting camera: lores stream dimensions may not exceed main stream``` because the automatic alignment might produce effective *Stream Sizes* which violate tis restriction. ### Bugfixes - [Zooming](./ZoomPan.md) did not preserv image center ## V2.5.4 ### Bugfixes - Avoid ```Error starting camera: lores stream dimensions may not exceed main stream``` Now, when specifying any [Camera Configuration](./Configuration.md), it is checked whether the specified *Stream Size* for the different use cases obey the restriction that stream size for 'lores' must be less than stream size for 'main' stream. If the restriction is violated, an error message is shown and the previous values are restored. - In [Camera Configuration](./Configuration.md) for *Photo*, *Stream* could be changed to 'lores' in the dialog, but this change has not been stored. Now fixed - Fixed: --- Logging error --- ... camera_pi.py", line 793, in clearConfig ## V2.5.3 ### Bugfixes - The previous fix was not robust enough and really worked only with debugging activated.. Now, the camera is given a second more time after different steps of switching. ## V2.5.2 ### Bugfixes - Switching the camera caused ```RuntimeError: Unable to stop preview.``` (see [raspi-cam-srv Issue #14](https://github.com/signag/raspi-cam-srv/issues/14)). This is now fixed. Switching the camera can be done from the [Settings](./Settings.md#switching-the-active-camera) screen as well as from the [WebCam](./CamMulticam.md#switch-cameras) screen. ## V2.5.1 ### New Features - During [Motion Capture](./TriggerMotion.md), framerates are also reported for the *Mean Square Diff* algorithm. See [Testing Motion Capturing](./TriggerMotion.md#testing-motion-capturing) ## V2.5.0 ### New Features - [Extended Motion Capturing Algorithms](./TriggerMotion.md) are available, including [Frame Differencing](./TriggerMotion.md#test-for-frame-differencing-algorithm), [Optical Flow](./TriggerMotion.md#test-for-optical-flow-algorithm) and [Background Subtraction](./TriggerMotion.md#test-for-background-subtraction-algorithm) - The [Extended Motion Capturing Algorithms](./TriggerMotion.md) can be run in a testing mode, showing live views of intermediate image processing results which can help for a better understanding of the algorithms and adjustment of their variable parameters. ### Changes - For Motion Detection, Trigger Parameters (see [Database for Events](./TriggerActive.md#database)) have been changed from format "string" to Python Dictionary, allowing multiple parameters for the [Extended Motion Capturing Algorithms](./TriggerMotion.md). Existing database entries with string format are still supported. ## V2.4.3 ### Bugfixes - When data for an ACTIVE [Photo Series](./PhotoSeries.md) were changed, the [status](./PhotoSeries.md#photo-series-state-chart) of the series was set back to "READY" but the thread was still active. Now, for an ACTIVE or PAUSED series, the *Photo Type* and *Start* can no longer be changed. The status will be promoted only for a NEW series. For a series in status FINISHED, data can no longer be modified. - The ERROR ```Could not import SensorConfiguration from picamera2.configuration```, which occured on Bullseye systems was changed to INFO ## V2.4.2 ### Bugfixes - If livestream terminates, camera is closed if a [Photo Series](./PhotoSeries.md) is active (no [Exposure Series](./PhotoSeriesExp.md) or [Focus Stack](./PhotoSeriesFocus.md)) and if the time to the next shot is larger than 60 sec. In the previous version, the camera would not have been closed if a Photo Series was active at the time when the livestream terminated. If, for example, the interval for the Photo Series would have been 1 hour and if the livestream would have been activated shortly after a shot was taken, the camera would have been open and started for about one hour and only be closed after the next shot of the series. ## V2.4.1 ### New Features - Process information for the Flask server process and its threads has been added to the [Info screen](./Z_Legacy_Information.md) - Camera status information has been added to the [Info screen](./Z_Legacy_Information.md) ### Improvements - Cameras are now stopped and closed in times when they are not active. As a consequence, the number of active threads and CPU utilization is reduced in phases when cameras are not streaming and no other background processes (video recording, photo series, motion capturing) are active. For more details, see [Camera Status and Number of Threads](./Z_Legacy_Information.md#camera-status-and-number-of-threads) ### Bugfixes - [Code Generation](./Troubelshooting.md#generation-of-python-code-for-camera) did not generate import statements. - Error status for [Triggered Capture of Videos and Photos](./Trigger.md) which had been stored with [Store Configuration](./SettingsConfiguration.md) are now cleared if server is started with stored configuration ## V2.4.0 ### New Features - Photo Series can be set to be [automatically continued](./PhotoSeries.md#series-configuration) on server start if they had been interrupted by a server stop or system shotdown or reboot. ### Bugfixes - The active [Photo Series](./PhotoSeries.md) had always been set to the alphabetically last series in case of a server start/restart, even if another series had been active at the time when the server was stopped. Now, if a series with status "ACTIVE" is found when the server is started, this series will be set as active series. ## V2.3.6 ### Bugfixes - Fixed error ```[Errno 12] Cannot allocate memory``` for Raspberry Pi 3. (See [raspi-cam-srv Issue #9](https://github.com/signag/raspi-cam-srv/issues/9)) Lower values for buffer_count are now also used for Pi 3, Pi 2 and Pi 1. in the same way as for Pi 4 and Pi Zero. ## V2.3.5 ### Bugfixes - Fixed issue with **endpoints photo_feed and photo_feed2**: These endpoints use the live streams for the available cameras. However, if the live stream was not active at the time when a client requested this endpoint, no photo was shown. Only when live streams were activated through the **raspiCamSrv** Web UI, photos were shown. Now, when these endpoints are requested, the system automatically starts a live stream if it is currently not active and delivers a photo. ## V2.3.4 ### New Features - e-Mail notification on motion capturing events (see [Notification](./TriggerNotification.md)) ## V2.3.3 ### Bugfixes - Starting server with stored Configuration ([Settings](./SettingsConfiguration.md)) did not correctly set a previously configured [Zoom](./ZoomPan.md) (*ScalerCrop*). Instead, *ScalerCrop* was set to the active camera's pixel array size (see [raspiCamSrv Issue #7](https://github.com/signag/raspi-cam-srv/issues/7)). This was done only during initial system start and not after manually applying **Load Stored Configuration** in [Settings](./SettingsConfiguration.md). Now, the stored *ScalerCrop* is no longer overwritten, if a zoom (<>100%) has been explicitely applied ("include_scalerCrop": true in controls.json). ## V2.3.2 ### Improvements - Error handling has been improved. Server errors, also from background threads, are routed to the web client. This does not apply to errors occurring in encoders which are running in own threads. Exceptions thrown in these threads are currently not handled by **raspiCamSrv**. Error reasons are mostly invalid combinations of [Configuration](./Configuration.md) parameters, especially with *Stream Format* ### Bugfixes - After applying **Swith Cameras** in page *Web Cam*, Title and metadata for the second camera were identical to those of the first camera. - [Reset Server](./SettingsConfiguration.md) may have caused errors in streaming or other functions - In [Config](./Configuration.md), *raw* stream can no longer be configured for *Live View*, *Photo*, and *Video* ## V2.3.1 ### Bugfixes - Avoid flooding with console error message "Motion detection thread did not stop within 5 sec". Now assuming that thread does no longer exist. - Fixed error ```TypeError: can only concatenate str (not "NoneType") to str``` which could occur in ```motionDetector.py``` if video recording failed after motion detection. In this case, there has been an error message in [events logfile](./TriggerActive.md#log-file) - Encoder Bitrate is no longer specified when recording a video (before it was set to 10000000) - Changed loglevel from ```debug``` to ```error``` when an exception occurred during video recording - Added error log when encoder could not be started after motion capture. Previously, the error was only shown in the [events logfile](./TriggerActive.md#log-file) - For Raspberry Pi 4, the default sensor mode is set to 0 (lowest resolution) in order to avoid encoder errors. - For Raspberry Pi 4, motion capture videos are recorded from the *lowres* stream with *Live View* configuration - For Raspberry Pi 4, default buffer count was reduced to 2 for live view and 4 for video ## V2.3.0 ### New Features - Streaming of second camera added (see [Webcam](./CamWebcam.md) page). A single **raspiCamSrv** server can now simultaneously stream both cameras connected to a Raspberry Pi 5. - The camera configuration and controls for the active camera can be preserved also for a situation when this camera acts as "other" camera. - Streaming configurations for both cameras are stored together with the entire configuration (see [Settings](./SettingsConfiguration.md)) and can be loaded on server restart. ## V2.2.3 ### Bugfixes - For Raspberry Pi Zero, use the *lowres* stream (Live View Configuration) for recording videos during motion capture. During motion capture, the *Live View* camera configuration is used because the live stream is required for detecting motion. However, the *Buffer Count* of 2, used for this configuration for Pi Zero (see [V2.1.2](#v212)), is too small for video recording with the resolution of the *Video* configuration. - Fixed an error which could occur when [viewing events](./TriggerEventViewer.md) when placeholder photos for videos were not yet read from the database. ## V2.2.2 ### New Feature - Added an option to automatically start motion capture with the Flask server. Thus, if server start is done in a service, motion capturing will automatically be active if the device is booted. (See [Triggered Capture of Videos and Photos](./Trigger.md)) ## V2.2.1 ### Bugfixes - Prevent changing settings while the trigger-capture process is active - Prevent changing camera configuration while the trigger-capture process is active - Prevent starting an Exposure Series or a Focus Stack Series while the trigger-capture process is active - Fixed "ValueError: could not convert string to float: ''" which may have ocurred for Exposure Series or Focus Stack Series with a camera having no focus support ## V2.2.0 ### Installation Hints This version has a new database schema with tables used for captured events. After an update with ```git pull```, you need to initialize the database with ```flask --app raspiCamSrv init-db``` before starting the server. This will also recreate the user database and requires new registration. Services should be stopped during upgrade ### New Feature - Introduced basic motion capturing (see [Triggered Capture of Videos and Photo](./Trigger.md)) ## V2.1.2 ### Bugfix - For Raspberry Pi Zero, the "Buffer Count* in the [Configuration](./Configuration.md) for *Live View* and *Video* has been reduced to 2 and 4, respectively because of memory issues. Also, the default *Sensor Mode* for *Video* has been set to the lowest (0) mode, rather than to the highest. ## V2.1.1 ### Known issues - On Pi Zero, there seems to be issues with parallel live stream on *lores* and video recording or phototaking on *main*. Got ```Camera frontend has timed out!``` exception. Probably, this feature needs to be deactivated on these platforms. Need to study in more details. ### New Features - The Camera [Information](./Z_Legacy_Information.md) screen now shows also information on the Raspberry Pi version and board version. ### Bugfix - For Raspberry Py systems Pi 4 and earlier, the *Stream Format* for *Live view* is initialized with "YUV420". According to the [Picamera2 Manual](./picamera2-manual.pdf) ch. 4.2, p. 16, this format must be used for these systems for the *lowres* stream which is now the default for *Live View*. The list of values for the *lowres* stream in the [Config](./Configuration.md) dialog is not restricted to YUV format, however, if an other format is selected, an error message is shown and the parameter remains at "YUV420". - On Bullseye systems (Pi Zero), the package *picamera2.configuration* does not currently include the class *SensorConfiguration*. Also the *CameraConfiguration* class does not contain the element *sensor*. This caused an "Import Error" when starting the server. This error is now captured and, if it occurs, the *sensor* element in the configuration is ignored. ## V2.1.0 ### New Features - Added endpoint for photo snapshots ([raspi-cam-srv Issue #5](https://github.com/signag/raspi-cam-srv/issues/5)) (see [Web Cam](./CamWebcam.md)) ## V2.0.0 ### New Features - Major modification of camera control to allow non-exclusive access to the camera from parallel tasks. Phototaking, video recording and photoseries do no longer interrupt the live stream if the required camera configurations are compatible. (See [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md)) - Added code generation to the camera module. The code used for interaction of **raspiCamSrv** with Picamera2 is logged into a file specific for each server run. This generates executable Python code, suitable to 'replay' the entire camera interaction of a raspiCam Server run. This can be used for testing and error analysis. (See [Generation of Python Code for Camera](./Troubelshooting.md#generation-of-python-code-for-camera)) ### Changes - The camera configuration for VIDEO is now initialized with the sensor mode with the largest stream size in order to allow simultaneous use of main stream for Photo and Video. ### Refactoring - General refactoring of "Timelapse series" to "Photo Series". Timelapse series are now just a special kind of photo series. - The folder ```raspi-cam-srv/raspiCamSrv/static/timelapse``` is no longer used. Instead, photo series are now stored in folder ```raspi-cam-srv/raspiCamSrv/static/photoseries``` This folder will be automatically created at the first server start. If you have stored photoseries under the ```timelapse``` folder, you can move them to the ```photoseries``` folder and then delete the ```timelapse``` folder. For each series, you need to exchange ```/timelapse/``` with ```/photoseries/``` in the ```*_cfg.json``` files ================================================ FILE: docs/ScalerCrop.md ================================================ # Image Cropping and Sensor Modes [![Up](img/goup.gif)](./ZoomPan.md) ## Pixel Array Size and Sensor Modes Raspberry Pi cameras have sensors with various Pixel Array Sizes which are shown in the [Camera Properies](./Information_CamPrp.md) section of the [Info](./Information.md) screen. For example, the V3 camera (Imx708) has a PixelArraySize of 4608 x 2592 pixels. ![SensorModes](./img/Cropping_SensorModes.jpg) A camera can operate in a limited number of **Sensor Modes** (e.g. Sensor Modes 0, 1, 2 for the Imx708). Information on Sensor Modes is shown in the [Sensor Mode x](./Information_Sensor.md) section of the [Info](./Information.md) screen. Each Sensor Mode is characterized by (among others) - a Bit Depth - a Frame Rate - a specific field of view (Crop Limits) which either spans the entire PixelArraySize or a subarea of it. - an output size which specifies the resolution of the image obtained with this Sensor Mode. Here, an image pixel corresponds to a single sensor pixel or a group of 2x2 pixels. The output sizes for Imx708 are Sensor Mode 0: 1536 x 864 Sensor Mode 1: 2304 x 1296 Sensor Mode 2: 4608 x 2592 ## Cropping Raspberry Pi cameras can deliver images from a subarea of the sensor. This area is specified by the [Camera Controls](./CameraControls.md) parameter [ScalerCrop](./ZoomPan.md#current-scalercrop-zoom) which can be specified in the [Zoom and Pan](./ZoomPan.md) section of the [Live](./LiveScreen.md) screen of **raspiCamSrv**. The effective ScalerCrop rectangle (ScalerCrop Zoop/Pan) is restricted by parameters which can be obtained from the ```camera_controls``` (see [Picamera2 Manual](./picamera2-manual.pdf), Appendix B) **NOTE**: All rectangles are specified by a tuple (xOffset, yOffset, width, height) - ScalerCrop Maximum This is the largest rectangle in which the effective ScalerCrop rectangle must be completely enclosed. This rectangle is limited by the Crop Limits of the Sensor Mode. - Scaler Crop Minimum This is the smallest area which can be delivered by the camera. Only width and height are relevant and width and height of the effective ScalerCrop rectangle must not be smaller. - Scaler Crop Default This is the default ScalerCrop rectangle which will always be chosen by the camera if an effective ScalerCrop rectangle is not requested within the Camera Properties. Whereas in a standard case, ScalerCrop Maximum and ScalerCrop Default cover the entire PixelArraySize, the pictures below show the situation for two extreme cases: ![ScalerCrop Sensor Mode 0](./img/Cropping_ScalerCrop_0.jpg)   ![ScalerCrop Sensor Mode 0](./img/Cropping_ScalerCrop_2.jpg) ## Strategy The strategies, by which the camera operates, are not fully documented in detail. However, systematic experiments with the relevant parameters show the following bahavior: - Use the [Camera Configuration](./Configuration.md) to specify the **Stream Sizes** for different use cases. The most 'relevant' configuration seems to be the **raw** stream. A special option (*Sync Aspect Ratio*) assures consistent aspect ratios for all configurations. - Depending on the 'relevant' Stream Size, the camera will automatically choose a suitable Sensor Mode. The active Sensor Mode can be seen in the [Installed Cameras](./Information_Cam.md) screen for the active camera if it is currently open and started. **NOTE**: **raspiCamSrv** will normally use configurations where all 3 streams (raw, main, lores) are configured in order to allow simultaneous camera access with different intents. - From the Crop Limits of the Sensor Mode the ScalerCrop Maximum rectangle is determined. - The ScalerCrop Default is the largest rectangle, which has the aspect ratio of the 'relevant' Stream Size, and which is fully inside the Crop Limits of the active Sensor Mode, and which is horizontally and vertically centered. - Zooming and Panning allows scaling the rectangle and panning it within the area of the ScalerCrop Maximum rectangle. Zooming and Panning with **raspiCamSrv** always preserves the aspect ratio. - Finally, the effective ScalerCrop area is scaled to the *Stream Size* of the different streams Because the aspect ratio of the ScalerCrop Zoom/Pan rectangle is determined from the aspect ratio of the 'relevant' stream, the Live Stream will be distorted if the aspect ratio for the *Live View* configuration is different from that of the configuration for the 'relevant' stream. ================================================ FILE: docs/Settings.md ================================================ # raspiCamSrv Settings [![Up](img/goup.gif)](./UserGuide.md) The Parameters section of the Settings screen is used for specification of general parameters. Other sections focus on - [Server Configuration](./SettingsConfiguration.md) - [User Management](./SettingsUsers.md) - [API Management](./API.md) - [Versatile Buttons](./SettingsVButtons.md) - [Action Buttons](./SettingsAButtons.md) - [Live Buttons](./SettingsLButtons.md) - [Devices](./SettingsDevices.md) - [Update](./SettingsUpdate.md) *Users* and/or *API* may be invisible, depending on context. For the case that no camera is connected, see [Settings (No Camera)](./Settings_NoCam.md). ![Settings](img/Settings.jpg) The General Paramenters include - *Active Camera* allows explicitely [setting the active camera](#switching-the-active-camera) for systems with multiple cameras. - *Use USB Cameras*, when activated will allow connected USB cameras to be used as *Active* or *Second Camera* (see also [Multi Cam](./CamMulticam.md))
**NOTE**: When deactivating *Use USB Cameras*, make sure that none of the USB cameras is currently streaming. - [Audio settings](#recording-audio-along-with-video) for systems with microphones if sound is to be recorded along with videos - *Path for Photos/Videos* shows the path where media will be stored. - File types for *Photo*, *Raw* Photo and *Video* - *Use Stereo Vision* allows activating [Stereo capabilities](#activating-and-deactivating-stereo-vision) for systems having 2 non-USB cameras of the same type connected. - *Show Histograms* allows [activatig/deactivating Histograms](#activating-and-deactivating-histograms) display of histograms - *Ext. Motion Detection supported* shows whether the actually installed libraries allow support of [Extended Motion Tracking Algoritms](#extended-motion-detection-support) - *Req. Auth for Streaming* controls whether [streaming requires authentication](#configuring-authentication-for-streaming) - *Allow access through API* shows whether the installed libraries allow secure [API access](#api-access).
Also if it is supported, it can be deactivated. - The geo-coordinates *Latitude*, *Longitute*, *Elevation* as well as the *Time Zone* are required for sun-calculations in [Sun-controlled Timelapse Photo Series](./PhotoSeriesTimelapse.md). ## Switching the active Camera On systems which allow connection of multiple cameras (e.g. Pi 5), it is possible to switch the active camera. (see also [Information / Cameras](./Information_Cam.md)) ![Camera Switch](img/Settings_CamSel.jpg) ## Disabling Use of USB Cameras If you have connected one or more USB cameras to a Raspberry Pi, you can exclude them from being available in **raspiCamSrv**. They will still be shown in the [Info](./Information.md) screen but they are no longer offered as choice for camera selection in the Settings or [Multi Cam](./CamMulticam.md) screen. If a USB camera was set as *Active Camera* or as *Second Camera*, this will be replaced by a CSI camera. If no CSI cameras are present, the UI will switch to the "No-Camera" mode ([Settings (No Camera)](./Settings_NoCam.md)). ## Configuring Authentication for Streaming It can be configured whether streaming of videos or photos requires authentication: ![Settings](img/Settings_Auth_Streaming.jpg) - If the checkbox is not checked, the system allows access to video streams or photos for everybody without authentication. - If the checkbox is checked, video streams or photos can only be accessed if a valid session is active. If a streaming URL is entered in a browser and there is no valid Flask session, the login screen is shown and, after having entered valid credentials, the [Live](./LiveScreen.md) screen is shown. Now, the desired streaming URL can be directly entered or selected from the [Web Cam](./CamWebcam.md) screen. A valid Flask session exists, if login has been passed once within an active browser instance, either in another tab of the browser window intended for streaming or within another window of the **same** browser. Closing all windows of a browser kills the session. ## Activating and Deactivating Stereo Vision If your system has 2 non-USB cameras of the same model connected, you can use these as [stereo camera system](https://en.wikipedia.org/wiki/Computer_stereo_vision). **raspiCamSrv** supports basic stereo features such as [3D Videos and Depth Maps](./CamStereo.md) as well as [Calibration and Rectification](./CamCalibration.md). These features can only be activated if it has been recognized at system startup that 2 non-USB cameras of the same model are connected to the system. In addition, it is required that OpenCV, and numpy are installed (see [RaspiCamSrv Installation](./installation.md) Step 11). If any of these conditions is not met, the reason will be indicated: ![NoStereo](img/Settings_noStereo.jpg) **NOTE**: *Stereo Vision* should only be activated if both cameras are mounted (or at least arranged) in a typical stereo camera setup.
I am using a 3D-printed [Raspberry Pi Camera 3 Stereo Case](https://makerworld.com/en/models/1742837-raspberry-pi-camera-3-stereo-case) ![Stereo](img/Pi_Camera_3_Case_Stereo_front.JPG) ## Activating and Deactivating the use of Camera AI Features AI features are currently only supported with the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) which uses the Sony IMX500 imaging sensor to provide low-latency, high-performance AI capabilities. The camera can be used like any other Raspberry Pi CSI camera. However to exploit the AI capablities of the camera, specific packages need to be installed. If any of the required packagas is missing or if no AI camera is connected, this will be indicated and the checkbox is deactivated: ![NoAI](img/Settings_no_AI.jpg) In order to enable AI features for an active AI camera, activate the checkbox. Then, an additional dialog [Camera AI Configuration](./Configuration_AI.md) will be available where specific AI features can be configured and activated. If *Use Camera AI* is deactivated and a specific AI model is currently enabled for the Active Camera or/and for the Second Camera, this will be disabled automatically. ## Activating and Deactivating Histograms **raspiCamSrv** can show histograms for photos. Histograms are generated with [OpenCV](https://de.wikipedia.org/wiki/OpenCV). This requires that the packages OpenCV, numpy and matplotlib are installed (see [RaspiCamSrv Installation](./installation.md) Step 9) If these packages are installed, you can select whether or not to *Show Histograms*. The default on first server start is to show histograms. It may be necessary on smaller systems (Raspberry Pi Zero W, Raspberry Pi Zero 2 W) to deactivate this option because of memory restrictions. If the option is deactivated, the modules are not loaded and histograms will not be displayed, even if all packages are installed. The system will automatically detect whether or not the required packages are installed and accessible. If this is not the case, this will be indicated and the checkbox will be disabled: ![NoHistograms](img/Settings_noHistogram.jpg) ## Extended Motion Detection Support In all installations, [Motion Capturing](./TriggerMotion.md) with the *Mean Square Difference* algorithm are supported. In order to also be able to use the extended algorithms, the following modules must be installed (see [Installation procedure, Step 11](./installation.md)): - OpenCV - numpy - matplotlib When the server starts up, it will be checked whether these modules can be imported. If the import had failed, this will be indicated on the Settings screen in the same way as for [Histograms](#activating-and-deactivating-histograms), above. Then, only the *Mean Square Difference* algorithm will be offered for choice on the [Trigger/Motion](./TriggerMotion.md) tab. ## Recording Audio along with Video ### Preconditions If a microphone, such as a USB microphone is connected to the Raspberry Pi, it is possible to record audio along with videos. Picamera2 accesses the microphone through [PulseAudio](https://wiki.archlinux.org/title/PulseAudio). PulseAudio daemons (```pulseaudio.socket``` and ```pulseaudio.service```) are running as [user units](https://wiki.archlinux.org/title/Systemd/User) and not as system units. In order to access the microphone, **raspiCamSrv** needs to run in the user environment, too. This is automatically the case when the Flask service is directly started from the command line in the **raspiCamSrv** virtual environment with ```flask --app raspiCamSrv run --debug --host=0.0.0.0``` Alternatively, **raspiCamSrv** can be configured as **user** service as described in [README / Service Configuration for Audio Support](./service_configuration.md#service-configuration-for-audio-support) ### Configuration **raspiCamSrv** will automatically detect whether a microphone is connected and accessible through PulseAudio. If this is the case, the default microphone will be shown in the Settings screen: ![SettingsMic](img/Settings_microphone.jpg) Also the checkbox *Record Audio along with Video* is enabled for change. If the checkbox is checked, audio will be recorded when a video will be recorded. If no microphone is connected or the microphone is not accessible through PulseAudio (because **raspiCamSrv** runs as system service), this will be indicated as ![SettingsMic](img/Settings_no_microphone.jpg) and the *Record Audio along with Video* checkbox is disabled. Microphones can be plugged in/out without stopping the system. After a refresh of the *Settings* screen, the system will detect the changed setup. If multiple microphones are plugged in, PulseAudio will automatically select a default microphone. If the selected microphone is not the intended one, plug it out temporarily. Pulse Audio will automatically select another default and keep it. ### Audio/Video Synchronization Due to timing issues of audio and video subsystems, there may be a delay between video and audio. The discrepancy is typically in subsecond range. Test videos should be made with something like a clapperboard. In case of delays, the *Audio Timeshift* value should be adjusted (it can be positive or negative) until video and audio are in sync. ## API Access API access to **raspiCamSrv** is protected through JSON Web Tokens (JWT).
This requires the module ```flask_jwt_extended```, which is first used in **raspiCamSrv V2.11**. If the upgrade to this version has been done without installing this module (see [Release Notes V2.11](./ReleaseNotes.md#v2110)), the system will show a hint ![SettingsAPI](./img/Settings_API_na.jpg) and also hide the *API* section In this case, the module can be installed (see [Release Notes V2.11](./ReleaseNotes.md#v2110)) and after the server has been restarted, it shows as ![SettingsAPI](./img/Settings_API_a.jpg) which now allows activating or deactivating API support. If the setting is changed, it is necessary to 1. [Store the configuration](./SettingsConfiguration.md) 2. Make sure that the server is configured to [Start with stored Configuration](./SettingsConfiguration.md) 3. Restart the server (Button *Restart Server* in [Settings/Configuration](./SettingsConfiguration.md)) This will be indicated through the hint ![SettingsAPI](./img/Settings_API_change.jpg) ================================================ FILE: docs/SettingsAButtons.md ================================================ # Settings - Action Buttons [![Up](img/goup.gif)](./Settings.md) On this screen, you can 'design' the [Console Action Buttons](./ConsoleActionButtons.md) by assigning visual attributes and [Actions](./TriggerActions.md) to Buttons arranged on a grid. ![ActionButtons](./img/Settings_AButtons.jpg) The general usage of this screen is similar to that for configuring [Versatile Buttons](./ConsoleVButtons.md), except that an [Actions](./TriggerActions.md) needs to be assigned to a button. ================================================ FILE: docs/SettingsAPI.md ================================================ # Settings API [![Up](img/goup.gif)](./Settings.md) In this section of the Settings screen, parameters for protection of the API through [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWT) can be configured. **raspiCamSrv** uses the [Flask implementation of JWT](https://flask-jwt-extended.readthedocs.io/en/stable/index.html), requiring installation of the ```flask_jwt_extended``` package (see [Release Notes for 2.11](./ReleaseNotes.md#v2110)) This section will only be visible if this package is installed and if the [Geleral Parameter](./Settings.md) *Allow access through API* is checked. ![Settings API](./img/Settings_API_1.jpg) JWT requires a secret private key to sign tokens. **raspiCamSrv** will store this key in a secrets file and will not publish it in [configuration exports](./SettingsConfiguration.md#server-configuration-storage).
Therefore, in order to activate API support, the location of the secrets file needs to be specified. It is recommended using the folowing path:
```/home//.secrets/raspiCamSrv.secrets```
This file will usually also be used to store credentials of the mail server for [notification](./TriggerNotification.md) on motion detection. The following parameters need to be configured: - *JWT Secret Key File Path*:
The full path to the secrets file
The path as well as the file will be automatically created if they do not exist. - *Access Token Expiration in Minutes*
The Access Token is used for normal access to API endpoints.
For higher security, it is recommended to limit the livetime of this token.
This requires, however, that clients are able to react on an expiration error with refreshing the token.
If this is not possible, expiration can be deactivated by specifying the value 0. - *Refresh Token Expiration in Days*
The refresh token can be used to authenticate for receiving a fresh Access Token.
Both, Access Token and Refresh Token are obtained with the ```/api/login``` endpoint.
If an expiration period > 0 for the Refresh Token is specified, a new login is required if the refresh token has expired. ## API Status Information The status of API support is indicated by the colored status line. A status change will only take effect for clients if the server is restarted with the necessary configuration: 1. [Store the configuration](./SettingsConfiguration.md) 2. Make sure that the server is configured to [Start with stored Configuration](./SettingsConfiguration.md) 3. Restart the server (see [Update Procedure, step 4](./updating_raspiCamSrv.md)) ### API enabled but not active ![Settings API](./img/Settings_API_2.jpg) This is the initial status before specification of the path to the secrets file. ### API configuration completed but not yet active ![Settings API](./img/Settings_API_3.jpg) This status is obtained after valid JWT data have been submitted and if the server has not yet been restarted with these data. ### API active ![Settings API](./img/Settings_API_4.jpg) This status is shown after the server has been started with valid JWT settings. ## Generation of Access Token If a client system is not able to correctly obtain valid tokens from the server, it is possible to use an Access Token which does not expire and generate it with the **raspiCamSrv** UI: ![Settings API](./img/Settings_API_5.jpg) This token can be copied into the clipboard and used as bearer token in API calls.
**raspiCamSrv** will never persist or publish this token, except once on this screen after it has been generated. ================================================ FILE: docs/SettingsConfiguration.md ================================================ # Settings / Server Configuration [![Up](img/goup.gif)](./Settings.md) The *Settings* screen includes a *Configuration* section with functions to control the **raspiCamSrv** configuration. The list of *Unsaved Configuration Changes* lists all actions with their time of execution, which have been made during the current session and which have not yet been saved to the server. ![Configuration](./img/Settings_Config.jpg) - Button *Store Configuration* generates a set of JSON files which include the entire configuration of the **raspiCamSrv** server (see [below](#server-configuration-storage)).
**NOTE**: This does not include [Photo Series](./PhotoSeries.md). These are persisted automatically and independently. It also does not include [Events](./TriggerActive.md). - Button *Load Stored Configuration* replaces the current configuration with the previously stored configuration.
[Photo Series](./PhotoSeries.md) and [Events](./TriggerEventViewer.md) are not affected.
**NOTE**: If you had activated [API](./SettingsAPI.md) access before, this will be deactivated when the stored configuration is loaded. You need to restart the server to activate it again. - Button *Reload Cameras* resets and reloads the entire camera configuration (see [Reloading Cameras](#reloading-cameras)). This fuction must be applied when USB cameras have been unplugged or new USB cameras plugged in while the server was active (hot plug). This will adjust the entire camera configuration to the new setup.
**NOTE**: Use this function **immediately** after unplugging a USB camera. Otherwise errors can occur when using other functions
**NOTE**: This has no effect when CSI cameras have been plugged in or out. This requires rebooting the Raspberry Pi, to be effective. - Button *Reset Server* stops any background activity (live stream, video, photo series, motion capturing and event handling) and replaces the current configuration with the default configuration.
[Photo Series](./PhotoSeries.md) and [Events](./TriggerEventViewer.md) are not affected. Any associated resources remain unchanged. However, an active [Photo Series](./PhotoSeries.md) will be paused and needs to be continued.
**NOTE**: If you had activated [API](./SettingsAPI.md) access before, this will no longer be available when the configuration is reset.
The same applies to [Notification Settings](./TriggerNotification.md) which need to be reconfigured.
**NOTE**: If you had activated *Start Server with Stored Configuration*, this will be deactivated. Probably, you might want to store the new configuration bofore activating this again. - *Start server with stored Configuration* controls whether a server start shall use the default configuration or the stored configuration. - Button *Backup Stored Data* With this button, you can create a [backup](#backups) of all data currently stored in the file system. Before pressing the button, you need to enter a unique name for the backup. - Button *Restore Backup* With this button, you can restore the selected backup. After restore is completed and confirmed by the status message, you need to restart the server with the *Restart Server* button. - Button *Remove Backup* With this button, you can remove the selected Backup. - Button *Restart Server* will restart the raspiCamSrv Flask server. The system will automatically detect whether the server was started as system unit, as user unit or from the command line. In the latter case, you are asked to stop the server manually. When the server restarts, the browser will lose connection. Press the browser's **Back** button until you see the recently used raspiCamSrv screen and then push any of the upper menu options to reconnect with the restored configuration. #### Server Configuration Storage When the configuration is stored with the *Store Configuration* button, a set of files is created/replaced in the ```raspi-cam-srv/raspiCamSrv/static/config``` folder: ![Config](./img/Settings_ConfigStore.jpg) - _loadConfigOnStart.txt
This is just an empty marker file. If the file exists, the server will initiate its configuration with configuration data stored in the other files.
Otherwise, default configuration settings will be applied. - cameraConfigs.json
This is currently not used - cameraProperties.json
This file contains the camera properties of the actice camera, which are shown in [Camera Properties](./Information_CamPrp.md).
Camera properties are always read directly from the camera. - cameras.json
This file contains the installed cameras with information shown in [Installed Cameras](./Information_Cam.md)
Installed cameras are always directly queried from the camera system. - controls.json
This file includes all the camera configuration settings as shown in the upper right part of the Live screen [Camera Controls](./LiveScreen.md#top-right-quarter) - LiveViewConfig.json, photoConfig.json, rawConfig.json, videoConfig.json
contain the camera configuration settings for the different use cases as shown in the [Config screen](./Configuration.md) - rawFormats.json
contain a list of formats which can be used for raw photos.
This information is extracted from the different [Sensor Modes](./Information_Sensor.md and is always directly obtained from the camera system. - serverConfig.json
This file includes configuration settings for the **raspiCamSrv** dialog system, such as information included in the [Settings](./Settings.md) dialog, or the configuration of the [Display Buffer](./LiveScreen.md#bottom-left-quarter) and some navigation details. - streamingCfg.json contains, for each camera, the [Tuning](./Tuning.md) configuration, the [Live View Configuration](./Configuration.md) settings and the [Camera Controls](./CameraControls.md) which will be used for streaming. The included Video Configuration is stored because Picamera2 always requires the *main* stream to be configured. This will not be used for streaming. - triggerConfig.json contains the configuration settings for triggered capture of videos and photos (motion capture) - tuningConfig.json contains the settings maintained in the [Tuning](./Tuning.md) dialog ## Reloading Cameras When the function **Reload Cameras** is applied, the system will 1. [Detect](./Information_Cam.md#detection-of-cameras) the currently connected cameras 2. [Identify](./Information_Cam.md#identification-of-usb-cameras) USB cameras, if connected 3. Then determine Camera Properties (e.g. model) and Sensor Modes for CSI and [USB](./Information_Cam.md#determining-camera-properties-for-usb-cameras) cameras. 4. Create a list of supported cameras, considering whether [Use of USB cameras is disabled](./Settings.md#disabling-use-of-usb-cameras). Before applying the function the list of supported cameras may look like | Num | Model | USB | Device | |-----|------------------------|-----|-------------| | 0 | imx708 | No | | | 1 | imx219 | No | | | 2 | Logi 4K Stream Edition | Yes | /dev/video0 | | 3 | C922 Pro Stream Webcam | Yes | /dev/video4 | After remofing the *imx219* and unplugging the *Logi 4K*, the list will be: | Num | Model | USB | Device | |-----|------------------------|-----|-------------| | 0 | imx708 | No | | | 1 | C922 Pro Stream Webcam | Yes | /dev/video4 | When comparing the lists, the system will look for matching Num, Model, USB and Device. If one of these parameters differs, the camera with that number will be initialized based on the current Camera Properties and Sensor Modes. Any previously specified [Configuration](./Configuration.md) or [Controls](./CameraControls.md) will be reset to default values for the respective camera type. In the example above, Camera 0 will keep their settings and Camera 1 will be reset. ## Backups Backups preserve the currently stored data structures of **raspiCamSrv** so that they can be consistently restored later. This includes: - all [configuration data](#server-configuration-storage) stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/config``` - all photos stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/photos``` - all [photo series](./PhotoSeries.md#photo-series-in-the-file-system) stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/photoseries``` - all [event data](./TriggerActive.md#event-data) stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/events``` - The [Camera calipration photos](./CamCalibration.md#calibration-data-storage) for a [Stereo Camera System](./CamStereo.md) stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/calib_photos``` - The Stereo Camera calibration parameters stored under folder ```~/prg/raspi-cam-srv/raspi-cam-srv/static/calib_data``` - The SQLite database with [User data](./Authentication.md) and [Event data](./TriggerActive.md#database) stored as ```~/prg/raspi-cam-srv/instance/raspiCamSrv.sqlite``` When a backup is created, all these data are stored underneath ```~/prg/raspi-cam-srv/backups/``` where `````` is the name given to the backup: ![Backup](./img/Settings_Backup.jpg) ================================================ FILE: docs/SettingsConfiguration_NoCam.md ================================================ # Settings / Server Configuration (No Camera) This is a variant of the [Settings / Server Configuration](./SettingsConfiguration.md) screen for the case when no camera is available. [![Up](img/goup.gif)](./Settings_NoCam.md) The *Settings* screen includes a *Configuration* section with functions to control the **raspiCamSrv** configuration. The list of *Unsaved Configuration Changes* lists all actions with their time of execution, which have been made during the current session and which have not yet been saved to the server. ![Configuration](./img/Settings_Config_no_cam.jpg) For details, see [Settings / Server Configuration](./SettingsConfiguration.md) When currently no camera is available in **raspiCamSrv** but one or more USB cameras have been plugged in while raspiCamSrv was active, button **Reload Cameras** will reset the entire camera configuration and usually detect new cameras. A confirmation is required: ![Conf](./img/Settings_ConfigReloadCamConf.jpg) ================================================ FILE: docs/SettingsDevices.md ================================================ # Settings - Devices [![Up](img/goup.gif)](./Settings.md) On this Settings screen you can configure devices connected to the Raspberry Pi through GPIO of the [40-pin header](#40-pin-gpio-header). ![Devices](./img/Settings_Devices.jpg) **IMPORTANT**: To preserve any configurations over server restart, you need to [store the configuration and activate *Start Server with stored Configuration*](./SettingsConfiguration.md) ## Creating a Device To create a new device, select one of the preconfigured Device Types and enter a unique ID by which the device will be identified within **raspiCamSrv**. Pressing *Create* will open the *Device Configuration* where the device can be configured in detail. A graphic with wiring information in the upper right area will help connecting the device. ## Configuring a Device In **raspiCamSrv**, GPIO-connected devices are controlled using the [gpiozero](https://gpiozero.readthedocs.io/en/stable/index.html) library which is deployed with Raspberry Pi OS. **raspiCamSrv** supports all 'regular' input and output device classes of gpizero as well as StepperMotor which is an own extension of ```gpiozero.OutputDevice```. - *Config Status*
shows whether the device is completely configured.
This requires normally, that valid numbers have been set for all pin parameters. - *Device Type*
shows the class name through which the device is accessible. - *Usage*
shows whether the device is an Input or Output device.
This information is used when [Triggers](./TriggerTriggers.md) and [Actions](./TriggerActions.md) are configured where either one or the other can be selected. - *gpiozero Doc*
A link to the *gpiozero* page for the specific class can help understanding the meaning of the different interface parameters as well as for the specific behavior of the class.
Only the link for the StepperMotor links to a **raspiCamSrv** page with information about this class. Typically, most of the device types have an individual set of parameters with different data types and value ranges. These parameters are preconfigured in **raspiCamSrv** (see [Device Type Configuration](#device-type-configuration)) and are shown with their default values from *gpiozero*. In almost all cases, only the parameters for the GPIO pins need to be configured. Only, when all GPIO pins are configured, the *Config Status* is set to "OK", which is a precondition that the device can be used. ## Device Overview The right side of the dialog shows an overview of all configured devices with their associated GPIO pins and their *Config Status* The information about *Unused GPIO Pins* can help finding places for new devices.
This information only considers the devices configured in **raspiCamSrv**. ## Modifying Device Configuration You can modify configuration parameters for a device after selecting the device ID in the *Device Configuration* section. If a device is selected, also the device Type and the image in the upper area are adjusted. The ID of a device can not be modified. ## Deleting a Device The *Delete* button allows deletion of the selected device. This requires an additional confirmation. If a device is used in one of the [Triggers](./TriggerTriggers.md) or [Actions](./TriggerActions.md) configurations, it cannot be deleted. An error message will be shown only after deletion has been confirmed. ## Testing a Device After a device has been configured, it should be tested that it is working correctly. When the *Config Status* is "OK", the *Device Test* section is shown, which initially consists only of the *Test* button. When it is pressed, a preconfigured set of test steps will be executed. **IMPORTANT**: Before pressing *Test*, make sure that any moving devices (e.g. Motors, Servos) can move freely. After the test is completed, the return values of the configured test methods will be shown. For a test either the entire test or the individual steps may have preconfigured durations. So you need to wait until the test is completed and observe the device. ## Calibrating a Device Some devices require state tracking and calibration. ### Calibration Types Different devices may require different calibration procedures. This applies currently to #### StepperMotor: The StepperMotor itself does not have knowledge about its current position and when the class is instantiated, the *current_angle* is set to zero. For usage of the StepperMotor it is, however, essential to know the position at any time. It is, therefore, necessary to 1. set a certain state as reference "Zero" 2. track and memorize any movements 3. set the last state whenever the device class is instantiated #### ServoPWM For Servos, the situation is slightly different. Servos (except 360° Servos) have a limited range of operation with a minimum and maximum angle. The actual position is controlled through the pulse width of a PWM signal and the position is kept when the signal is switched off. In order to avoid unexpected movements when a servo is activated, we need to keep track of the last position and set this position when the servo is started. Furthermore, we usually want to have a certain position as reference or "Zero". The exact choice of this position should be adustable by calibration. The choice of a specific position as "Zero" does not change the range of operarion. ### Calibration Support raspiCamSrv supports both types of calibration which can be configured for a device type in the [Device Type Configuration](#device-type-configuration). For device types requiring calibration, a **Calibrate** button will be shown when a device with this type is created or modified. Pressing **Calibrate** will show additional buttons for calibration as well as the current "internal" state (```current_angle``` for StepperMotor, ```current_angle-calibration``` for ServoPWM): ![Calibration](./img/Settings_Devices_Calibration.jpg) You can now change the device status using the arrow buttons until you reach the desired zero. Pushing **OK** will then - for StepperMotor
set the current state as reference - for ServoPWM
set the parameter *calibration* to the current "internal" state - in both cases hide the calibration buttons. ## State Tracking **raspiCamSrv** will track all status changes in a JSON file named after the device ID: ![Status](./img/Settings_Devices_Calibration_State.jpg) **ATTENTION**: If you are using such devices in any triggered [Actions](./TriggerActions.md) you should avoid shutting down the server while event handling is activated. The system might not be able to memorize the latest device state and may, therefore start with an incorrect state. ## Device Type Configuration The device types, supported by **RaspiCamSrv** are preconfigured in the file ```gpioDeviceTypes.py```. Below is an example for the ```DistanceSensor```: ```json gpioDeviceTypes = [ { "type":"DistanceSensor", "usage":"Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#distancesensor-hc-sr04", "image": "device_DistanceSensor.jpg", "params": { "echo": { "value": "", "type": "int", "min": 0, "max": 27, "isPin": True }, "trigger": { "value": "", "type": "int", "min": 0, "max": 27, "isPin": True }, "queue_len": { "value": 9, "type": "int", "min": 0, "max": 99 }, "max_distance": { "value": 1.0, "type": "float", "min": 0.0, "max": 100.0 }, "threshold_distance": { "value": 0.3, "type": "float", "min": 0.0, "max": 100.0 }, "partial": { "value": False, "type": "bool" } }, "testMethods":[ "distance", "value" ], "events":[ "when_in_range", "when_out_of_range" ], "eventSettings":{ "threshold_distance": 0.0 }, "control":{ "bounce_time": 0.0 } }, ] ``` The different elements are used for different purposes: - *type*
identifies the class name for the device type. - *usage*
distinguishes Input and Output devices - *docURL*
is the URL for class documentation
If ```/latest/``` is found in the URL, this will be replaced by the document version for the current software version, encapsulated with "/". - *image*
identifies the image shown in the dialog - *params*
characterizes the class constructor interface with parameter name, default value, type (with some non-Python declarations) as well as the valid range.
The "isPin" sub-element identifies parameters which correspond to GPIO pins. - *testMethods*
is a list of test methods, if necessary with parameters, which are executed during the test. - *testDuration* or *testStepDuration* specify the duration of the entire test or of every test step. - *events*
occur in Input devices and identify events which are captured by the device class and to which callback routines can be assigned.
This will be used in the specification of [Triggers](./TriggerTriggers.md). - *eventSettings*
is a list of parameter assignments which will be set **before** callbacks are assigned to the event parameters.
An example is the ```threshold_distance``` which is required for ```DistanceSensor``` to distinguish between *in_range* and *out_of_range*. - *actionTargets*
occur in Output devices and identify methods which can be used in [Actions](./TriggerActions.md).
Whereas the sub-element "method" identifies the method name (which can be a method or a property which **raspiCamSrv** determines through inspection), the "params" element specifies the parameters which can be configured for the method call or the property assignment.
These parameters serve also as 'templates' for type checks which are done during [Action](./TriggerActions.md) configuration. - *control* elements
which can occur as sub-elements of "actionTarget" for Output devices as well as for Input devices, are not part of the class interface but are used to control specific behavior in **raspiCamSrv**.
For input devices it can, for example specify the ```bounce_time``` for software controlled bouncing suppression beyond that what might already be provided by *gpiozero*.
For Output devices and a specific action target, it can, for example specify a duration for which the action shall last, for example how long an LED shall be enlighted. ### Calibration Configuration Devices requiring calibration, have an element ```"calibration"``` in their Device Type Configuration: ``` "calibration": { "fbwd": { "method": "rotate_by", "params": {"angle": -10.0}, }, "bwd": { "method": "rotate_by", "params": {"angle": -1.0}, }, "calibrate": { "param": "calibration", }, "fwd": { "method": "rotate_by", "params": {"angle": 1.0}, }, "ffwd": { "method": "rotate_by", "params": {"angle": 10.0}, }, }, ``` Here you find 5 sub-elemnts which control the function of the 5 calibration buttons in the Settings/Devices dialog: - fbwd
defines the method to be called (or attribute to be set) for "fast backward" (```<<```) - bwd
defines the method to be called (or attribute to be set) for "backward" (```<```) - fwd
defines the method to be called (or attribute to be set) for "foreward" (```>```) - ffwd
defines the method to be called (or attribute to be set) for "fast foreward" (```>>```) - calibrate
specifies the calibration procedure to be applied when the ```OK``` button is pressed #### Calibration Procedures The following alternatives are supported for specifying the calibration procedure: ##### "Servo-like" Calibration ``` "calibrate": { "param": "calibration", }, ``` This specifies that a specific class parameter (here ```calibration```) needs to be set to the current (intrinsic) value. This must be a parameter of the class constructor which cannot be changed for an existing object. ##### "Stepper-motor-like" Calibration ``` "calibrate": { "method": "value", "params": {"value": 0.0}, }, ``` This specifies that a specific attribute is set to the given value (typically 0.0) or that a specific method is called with the specified parameter signature. This method (or attribute setter) must not physically change the position (or state) of the device but only change the internal variable, representing this position, to the given value. ## 40-Pin GPIO Header ![GPIO Header](./img/GPIO_Pins.jpg) ================================================ FILE: docs/SettingsLButtons.md ================================================ # Settings - Live Buttons [![Up](img/goup.gif)](./Settings.md) On this screen, you can 'design' the [Live Ctrl Buttons](./CameraControls_Ctrl.md) by assigning visual attributes and [Actions](./TriggerActions.md) or OS commands to Buttons arranged on a grid. ![LiveButtons](./img/Settings_LButtons.jpg) The general usage of this screen is similar to that for configuring [Versatile Buttons](./ConsoleVButtons.md), except that you can assign either an OS Command or an [Action](./TriggerActions.md) to a button. ================================================ FILE: docs/SettingsUpdate.md ================================================ # Settings - Update [![Up](img/goup.gif)](./Settings.md) This dialog can be used to update raspiCamSrv to the latest version available on [GitHub](https://github.com/signag/raspi-cam-srv) ![Update 2](./img/Settings_Upd_2.jpg) If the installed version is lower than the latest version on GitHub, the version number on the title bar is shown in yellow. The Update dialog shows the following fields: - *Check for Updates*
This switch can be used to activate or deactivate checking for updates. - *Latest Version*
This is the latest version available on GitHub. raspiCamSrv determines the latest version by analyzing the [Release Notes](./ReleaseNotes.md).
GitHub is checked for the latest version at every start of the Flask server or with the specified time interval when executing any function of the [Live](./LiveScreen.md) dialogs. - *See Release Notes*
The link opens the latest release notes for inspection of new features. - *Installed Version*
This field shows the currently installed version. - *Update to Vx.y.z*
This button is only visible if you can update to a newer version.
Pressing the button will issue a ```git pull origin main --depth=1``` command to update raspiCamSrv.
After the update has completed successfully, the button will turn to (see below): - *Restart Server*
This button will restart the raspiCamSrv Flask server to switch to the new version. - *Notify on Version later than*
Initially, this field shows the current version.
If you want to skip updates to the indicated latest version, you can press the *Ignore ...* button and the field will show the latest version.
Information on possible updates will only be given, if the latest version on GitHub is later than the version shown in this field. - *Latest Version checked at*
This shows the time when the indicated *Latest Version* was retrieved from GitHub. - *Check Interval (Hours)*
Is the interval with which raspiCamSrv will check for updates. - *Check Now*
Check GitHub for a new version now. ## Dialog after successfull Update ![Update 2](./img/Settings_Upd_3.jpg) Now, you need to restart the server with the *Restart Server* button. **NOTE**: After completion of the update, the active server is still on the old version.
When the server restarts, the browser will get an empty response. You should press the *Back* button of the browser to return to the previous screen and then press any menu options of the **black** menu bar. If the server is still on the old version after restart, you can follow the [manual update procedure](./updating_raspiCamSrv.md). ## Dialog with *Check for Updates* deactivated ![Update 1](./img/Settings_Upd_1.jpg) ================================================ FILE: docs/SettingsUsers.md ================================================ # Settings Users [![Up](img/goup.gif)](./Settings.md) For management of users, the *Settings* screen has an additional section *Users* which is visible only for the SuperUser: ![User Management](./img/Auth_UserManagement.jpg) The list shows all registered users with - unique user *ID* - user *Name* - *Initial*, indicating whether the user has been initially created by the SuperUser and needs to change password on first log-in. - *SuperUser*, indicating the user registered as SuperUser The SuperUser can - register new users using the *Register New User* button - remove users which have been selected in the list ================================================ FILE: docs/SettingsVButtons.md ================================================ # Settings - Versatile Buttons [![Up](img/goup.gif)](./Settings.md) This Settings screen allows configuration of function buttons which will be shown on the [Console](./Console.md) screen. ![vButtons](./img/SettingsVButtons.jpg) The screenshot above is an example layout. Initially, the *Button Settings* area is empty. ## Button Layout Buttons on the [Console](./Console.md) screen are arranged in an N x M grid where the *Number of Rows* and *Number of Columns* can be configured. Once non-zero values have been specified and submitted, the *Buttons Settings* area will show a list of parameters for specification of button properties. If an existing grid layout is modified by changing either the number of rows and/or the number of columns, the configured rows and columns of buttons will be preserved if the new values are larger than the old ones. Superfluous rows or columns will be removed in case that new dimensions are smaller than the old ones. The checkbox *Interactive Commandline* controls whether [Console](./Console.md) will show an interactive commandline where commands can be directly entered. **IMPORTANT**: You need to [Store Configuration](./SettingsConfiguration.md) if you want the button settings to survive a server restart! ## Button Settings - *Row*
The row in which the button will be placed. - *Col*
The column in which the button will be placed. - *Visible*
When the checkbox is activated, a button will be shown in the given grid cell, otherways the grid cell will remain empty. - *Shape*
The shape of each button can be selected from a small set of standard shapes (Rectangle, Rounded, Circular, Square).
The example layout of the above configuration is shown for the [Console](./Console.md) screen. - *Color*
The Color of each button can be selected from a small set of standard Colors (Black, Red, Green, Yellow, Blue).
The example layout of the above configuration is shown for the [Console](./Console.md) screen. - *Button Text*
Text to be shown on the button. - *Command*
Linux command to be executed on OS level.
You may use available Linux commands or run your own scripts.
It is recommended to test these commands on an OS prompt before configuring and running them out of **raspiCamSrv**
The working directory is that of the service (see [Service Configuration](./service_configuration.md)). - *Conf*
If the checkbox is checked, the respective button will require a confirmation before the command will be executed. Any changes for these settings need to be submitted with the button underneath the table ## User Scripts and Programs Any scripts or Python programs can be put inside a folder ```~/prg/raspi-cam-srv/user_code``` or any sudirectory structure. This folder structure is excluded from Git and will, therefore, not be touched when upgrading or updating. For addressing these, it is sufficient to use the path relative to the root ```~/prg/raspi-cam-srv```, e.g. ```user_code/my_program.py``` Python programs can use all packages installed in the virtual environment when called out of the Flask server, for example when pressing a [Versatile Button](./ConsoleVButtons.md). ================================================ FILE: docs/Settings_NoCam.md ================================================ # raspiCamSrv Settings (No Camera) [![Up](img/goup.gif)](./UserGuide_NoCam.md) This is a variant of the general [Settings](./Settings.md) screen, which shows up when no camera is available. Other sections focus on - [Server Configuration](./SettingsConfiguration.md) - [User Management](./SettingsUsers.md) - [API Management](./API.md) - [Versatile Buttons](./SettingsVButtons.md) - [Action Buttons](./SettingsAButtons.md) - [Devices](./SettingsDevices.md) *Users* and/or *API* may be invisible, depending on context. ![Settings](img/Settings_no_cam.jpg) The General Paramenters include - *Use USB Cameras* This option is only visible if the system has detected at least one USB camera (see [Info](./Information.md)). Activating the checkbox will activate the connected cameras for **raspiCamSrv**. - *Allow access through API* shows whether the installed libraries allow secure [API access](#api-access).
Also if it is supported, it can be deactivated. - The geo-coordinates *Latitude*, *Longitute*, *Elevation* as well as the *Time Zone* are not currently used when there are no cameras. ## Enabling Use of USB Cameras If this option is shown, **raspiCamSrv** has identified at least one USB camera, but currently this is not available. If you enable use of USB cameras, the system will select one of the USB cameras as *Active Camera* and another one, if present, as *Second Camera* The UI will then automatically switch to the mode with cameras ([Information](./Information.md)) ## API Access API access to **raspiCamSrv** is protected through JSON Web Tokens (JWT).
This requires the module ```flask_jwt_extended```, which is first used in **raspiCamSrv V2.11**. If the upgrade to this version has been done without installing this module (see [Release Notes V2.11](./ReleaseNotes.md#v2110)), the system will show a hint ![SettingsAPI](./img/Settings_API_na.jpg) and also hide the *API* section In this case, the module can be installed (see [Release Notes V2.11](./ReleaseNotes.md#v2110)) and after the server has been restarted, it shows as ![SettingsAPI](./img/Settings_API_a.jpg) which now allows activating or deactivating API support. If the setting is changed, it is necessary to 1. [Store the configuration](./SettingsConfiguration.md) 2. Make sure that the server is configured to [Start with stored Configuration](./SettingsConfiguration.md) 3. Restart the server (see [Update Procedure, step 4](./updating_raspiCamSrv.md)) This will be indicated through the hint ![SettingsAPI](./img/Settings_API_change.jpg) ================================================ FILE: docs/SetupDocker.md ================================================ # Running **raspiCamSrv** as Docker Container [![Up](img/goup.gif)](./getting_started_overview.md) A container image is available for **raspiCamSrv** at [https://hub.docker.com/repository/docker/signag/raspi-cam-srv](https://hub.docker.com/repository/docker/signag/raspi-cam-srv) **ATTENTION**: Running raspiCamSrv in Docker is still somehow 'experimental'. Successful tests have been done only on Pi 4 and Pi 5. However, not all functions have so far been systematically tested. On Pi Zero W and Pi Zero 2 W, deployment of the image was not successful, probably because of its size (~840 MB). Limited memory may force using lower resolutions, especially with two CSI cameras on a Pi 5. **1. Preconditions** - [Installation of Docker on a Raspberry Pi](#installation-of-docker-on-a-raspberry-pi) - [Check Contiguous Memory (CMA)](#checking-contiguous-memory-cma) - [Deactivate raspiCamSrv Service from manual Installation](#deactivate-raspicamsrv-service-from-manual-installation) **2. Compose Service Definition** In an arbitrary working directory (e.g. ```~/docker```), create ```compose.yaml```: ```yml services: raspi-cam-srv: image: signag/raspi-cam-srv container_name: raspi-cam-srv network_mode: "host" ports: - "5000:5000" devices: - /dev/video0:/dev/video0 - /dev/gpiochip0:/dev/gpiochip0 volumes: # Uncomment resource mappings, if required # Configure and prepare container-external folders #- ./resources/database/:/app/instance/ #- ./resources/calib_data/:/app/raspiCamSrv/static/calib_data/ #- ./resources/calib_photos/:/app/raspiCamSrv/static/calib_photos/ #- ./resources/config/:/app/raspiCamSrv/static/config/ #- ./resources/events/:/app/raspiCamSrv/static/events/ #- ./resources/photos/:/app/raspiCamSrv/static/photos/ #- ./resources/photoseries/:/app/raspiCamSrv/static/photoseries/ #- ./resources/tuning/:/app/raspiCamSrv/static/tuning/ - /dev:/dev - /sys:/sys - /run/udev/:/run/udev:ro - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - GPIOZERO_PIN_FACTORY=lgpio - SYSTEMD_BUS_ADDRESS=unix:path=/run/systemd/private restart: unless-stopped privileged: true ``` The *volumes* attribute includes prepared mappings of resource folders for database, [camera calibration](./CamCalibration.md), [configuration](./SettingsConfiguration.md), [events](./TriggerActive.md), [photos](./PhotoViewer.md), [photoseries](./PhotoSeries.md#photo-series-in-the-file-system) and [tuning](./Tuning.md), which can/should be mapped to container-external folders in order to allow easy access and preserve the data also in case the container is removed or reset after a new image version has been pulled.
**NOTE**: For a quick test, the container can also be run without these mappings. Consider that the database holds [user data](./Authentication.md) as well as [event data](./TriggerActive.md) which will be lost if the database exists only within the container and if the container is removed. **3. Pull raspi-cam-srv Image** ```docker compose pull raspi-cam-srv``` Wait until all pulls are completed. **4. Create Container** ```docker compose create raspi-cam-srv``` ![Create Container](./img/docker_CreateContainer.jpg) **5. Start Container** ```docker compose start raspi-cam-srv``` ![Start Container](./img/docker_StartContainer.jpg) **6. Initialize Database** This step is only required if the ```/app/instance/``` folder, containing the database, has been mapped to a container-external folder. The image includes already an initialized database. However, if the ```/app/instance/``` is mapped, this database is not visible for the container and an empty database is created which needs to be initialized. This can be done on an interactive command prompt for the container: ```docker compose exec raspi-cam-srv sh``` ```flask --app raspiCamSrv init-db``` ![Initialize DB](./img/docker_InitDb.jpg) **7. Connect to raspiCamSrv** For usage of **raspiCamSrv** see the [User Guide](./UserGuide.md) ## Useful Docker commands See [Docker Reference](https://docs.docker.com/reference/cli/docker/compose/) - Pull latest image
```docker compose pull raspi-cam-srv``` - Start container
```docker compose start raspi-cam-srv``` - Show server logs
```docker compose logs raspi-cam-srv``` - Open shell for interactive prompt
```docker compose exec raspi-cam-srv sh``` - List containers
```docker container ls``` - List images used by the created containers
```docker compose images``` - Stop container
```docker compose stop raspi-cam-srv``` - Remove container
```docker compose rm raspi-cam-srv``` - List images
```docker image ls``` - Remove an image with a given ID
```docker image rm IMAGE_ID``` - Show docker disk usage
```docker system df``` - Remove unused data
```docker system prune``` ## Update Procedure Changes in the [raspi-cam-srv Git repository](https://github.com/signag/raspi-cam-srv) will automatically trigger a new build of the [raspi-cam-srv Docker Image](https://hub.docker.com/repository/docker/signag/raspi-cam-srv). To update to the latest version, proceed as follows: 1. cd to your working directory, e.g. ```cd ~/docker``` 2. ```docker compose pull raspi-cam-srv``` 3. ```docker compose stop raspi-cam-srv``` 4. ```docker compose rm raspi-cam-srv``` 5. ```docker compose create raspi-cam-srv``` 6. ```docker compose start raspi-cam-srv``` ## Installation of Docker on a Raspberry Pi The [Docker](https://www.docker.com/) documentation includes descriptions on how to [Install Docker Engine on Raspberry Pi OS](https://docs.docker.com/engine/install/raspberry-pi-os/) The most convenient way is using the [convenience script](https://docs.docker.com/engine/install/raspberry-pi-os/#install-using-the-convenience-script) provided by the Docker team: 1. Connect to the Pi using SSH:
```ssh @```
with `````` and `````` as specified during setup with Imager. 2. Update the system
```sudo apt update```
```sudo apt full-upgrade``` 3. Install Docker using the [convenience script](https://docs.docker.com/engine/install/raspberry-pi-os/#install-using-the-convenience-script):
```curl -sSL https://get.docker.com \| sh``` 4. Add current user to the ```docker``` group:
```sudo usermod -aG docker $USER``` 5. Log out and log in to activate the modified group assignment:
```logout```
```ssh @``` 6. Check that Docker is working correctly:
```docker run hello-world``` ## Checking Contiguous Memory (CMA) Cameras on Raspberry Pi use CMA memory (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), chapter 8.3). The default size of CMA memory is different for different Raspberry Pi models and can be shown with ```cat /proc/meminfo``` On a Pi 5, the available CMA memory (~65 MB) was found to be too small for accessing cameras from a Docker container when larger image sizes are used. The value for ```CmaTotal``` should be at least the value found for Raspberry Pi Zero of 262144 kB. If the value on your system is smaller, it needs to be increased: 1. Edit the Raspberry Pi configuration file:
```sudo nano /boot/firmware/config.txt``` 2. Find the line
```dtoverlay=vc4-kms-v3d```
and replace it with
```dtoverlay=vc4-kms-v3d,cma-512``` for > 2GB systems
```dtoverlay=vc4-kms-v3d,cma-384``` for > 1GB systems
```dtoverlay=vc4-kms-v3d,cma-320``` for smaller systems 3. Reboot
```sudo reboot``` ## Deactivate raspiCamSrv Service from manual Installation If you have installed raspiCamSrv manually, you need to deactivate the service: 1. Stop the service:
```sudo systemctl stop raspiCamSrv.service```
or
```systemctl --user stop raspiCamSrv.service``` 2. Disable the service so that it does not automatically start with boot:
```sudo systemctl disable raspiCamSrv.service```
or
```systemctl --user disable raspiCamSrv.service``` ================================================ FILE: docs/Trigger.md ================================================ # Introduction to Event Handling and Triggered Capture of Videos and Photos [![Up](img/goup.gif)](./TriggerOverview.md) **raspiCamSrv** can capture events from camera and GPIO input devices and let these process actions by the camera and GPIO output devices. ##### Supported Triggers - [Triggers](./TriggerTriggers.md) from GPIO-connected sensors ##### Additional Triggers when a Camera is available - [Triggers](./TriggerTriggers.md) from camera events such as start and stop of video recording or streaming or [detection of motion](./TriggerMotion.md). - [Motion Capturing](./TriggerMotion.md) through image analysis - [Active Motion Capture](./TriggerActive.md) ##### Supported Actions - [Actions](./TriggerActions.md) with GPIO-connected devices, such as LEDs, motors, servos or sound devices. - SMTP [actions](./TriggerActions.md) for sending an eMail to the [configured recipient](./TriggerNotification.md). - [Trigger-Actions](./TriggerTriggerActions.md) define which trigger will execute which action(s). - Under [Notification](./TriggerNotification.md), you configure the general mail recipient as well as specifics for notification on [motion detection through the camera](./TriggerMotion.md). ##### Additional Actions when a Camera is available - Camera [actions](./TriggerActions.md), such as taking photos, starting or stopping video recording or recording a video with a given length. - [Camera Actions](./TriggerCameraActions.md) specify the camera actions in case of [motion detection through the camera](./TriggerMotion.md). - Under [Notification](./TriggerNotification.md), you configure whether mail notification shall include photos or videos from [motion detection through the camera](./TriggerMotion.md). ##### Event Dashboard - [Event Viewer](./TriggerEventViewer.md) - [Calendar](./TriggerEventViewer.md) ## Event Handling Infrastructure **raspiCamSrv** comes with two different types of event handling: ### 1. Motion Capturing Originally supported was [Motion Capturing](./TriggerMotion.md) with photo-taking and video recording actions as well as notification by mail. The relevant dialogs for configuration are [Motion](./TriggerMotion.md), [Camera](./TriggerCameraActions.md) and [Notification](./TriggerNotification.md). ### 2. General Event Handling Since version V3.3.0, **raspiCamSrv** supports a more general approach to event handling which includes not only the camera but also various kinds of input and output devices which can be connected to the Raspberry Pi's GPIO pins. If you have no devices connected to your Raspberry Pi, you just stay with [Motion Capturing](./TriggerMotion.md). If you have input devices, such as sensors or buttons and/or output devices, such as LEDs, buzzers, relais or motors connected to your Pi, in addition to a camera, you can benefit from the fully integrated powerful event handling of **raspiCamSrv**. - You start with configuring the connected devices in the [Settings/Devices](./SettingsDevices.md) screen. - Then, for the configured input devices, you configure [Triggers](./TriggerTriggers.md) which can also be based on camera events, such as start or stop of video recording, streaming or motion capturing. - As next, you configure any type of [actions](./TriggerActions.md) which you want to see, such as LEDs being switched on, a stepper motor executing a certain number of steps or photos and/or videos being taken with the camera.
In addition, you can also configure an SMTP action for being informed about an event by mail. - Once this is done, you need to specify for each of the triggers, which actions shall be processed once an event has been triggered. This is done in dialog [Trigger Actions](./TriggerTriggerActions.md). - In the [Triggers](./TriggerTriggers.md) and [Actions](./TriggerActions.md) dialogs, you also have the possibility to deactivate or activate triggers and actions, respectively. ### Integration The two types of event handling exist independently from each other and can be used separately or simultaneously. Events from [Triggers](./TriggerTriggers.md) defined as part of the [General Event Handling](#2-general-event-handling) can also be [logged](./TriggerActive.md#log-file) and visualized in the [Event Viewer](./TriggerEventViewer.md) if their *event_log* control parameter is set to "True". SMTP actions for mailing use the [Notification](./TriggerNotification.md) settings. Whether photos and/or videos, created during action processing, shall be included in a mail, can be configured independently for each SMTP [action](./TriggerActions.md). In order to receive a mail on an event, you just activate one of your configured SMTP actions for the intended trigger. While all actions are started simultanously in individual threads, the thread for the SMTP action will wait for completion of all other actions of the same trigger and include any information and rosources from the other action threads. ### Where do Photos and Videos go? Photos and videos taken in case of [motion detection](./TriggerMotion.md) will always be stored in the [event folder](./TriggerActive.md#event-data) while events are logged in the [log file](./TriggerActive.md#log-file) and the [database](./TriggerActive.md#database). Old data can be removed with the [Cleanup](./TriggerCalendar.md#cleanup) function. The same applies to triggers and actions of [General Event Handling](#2-general-event-handling) if the control parameter *event_log* for the trigger is set to "True*. If this parameter is "False", photos and videos will be stored at the same location as if they were taken on the [Live](./LiveScreen.md) screen and they can be inspected in the [Photo Viewer](./PhotoViewer.md). ### Restrictions You can use [motion capturing through the camera](./TriggerMotion.md) as trigger. Whereas you can associate any kind of GPIO actions with such a trigger, you can not associate any camera or SMTP action. This is because camrea- and SMTP actions are handled already in the [Motion Capturing](#1-motion-capturing) infrastructure, which must be activated in order to capture motion detection events. ================================================ FILE: docs/TriggerActions.md ================================================ # Actions [![Up](img/goup.gif)](./TriggerOverview.md) This screen is used to specify actions which can be started by **raspiCamSrv**, either as a reaction on a [Trigger](./TriggerTriggers.md) or manually through an [Action Button](./ConsoleActionButtons.md). ![Actions1](./img/Trigger_Actions1.jpg) **IMPORTANT**: To preserve any configurations over server restart, you need to [store the configuration and activate *Start Server with stored Configuration*](./SettingsConfiguration.md). ## Creating an Action 1. In field *Action Source*, select the source system for which the action is defined: (Camera will be available as option only if a camera is available) ![Action2](./img/Trigger_Actions2.jpg) 1. This will open a list of devices defined for the chosen source system: ![Action3](./img/Trigger_Actions3.jpg) For the GPIO system, these are the **Output** devices configured on [Settings/Devices](./SettingsDevices.md)
**NOTE**: For SMTP, a device will only be shown if a mail account has been specified and verified in dialog [Notification](./TriggerNotification.md).
If this is the case, the configured *SMTP Server* will be shown as device. 1. After a device has been selected, the system will show the device type with a link to related gpiozero documentation as well as the action methods which can be executed for this device (this information is taken from the [fixed configuration for the device type](./SettingsDevices.md#device-type-configuration)): ![Action4](./img/Trigger_Actions4.jpg) 1. When the method has been chosen, the sytem will display any parameters which may be required for this method: ![Action5](./img/Trigger_Actions5.jpg) Now you need to specify values for these parameters, unless you leave the defaults, and enter a unique name for the action. In this step, the *Submit* button will be activated. 1. Pressing the *Submit* button will create the action and show it in the *Action Overview*. ### Parameters The parameters, for which values can be specified, are parameters of the method signature for the device class. Information about their type and value range, as well as about their function can be obtained from the *gpiozero* class documentation accessible through the link. **raspCamSrv** will check the data type by analyzing the datatype of the [configured template](./SettingsDevices.md#device-type-configuration). However, the allowed value range is currently not checked. You need to consult the *gpiozero* documentation. Sometimes, the 'Action Method' is in fact a property and not a callable method. In this case, **raspiCamSrv** will just assign the value to the property and ignore the parameter name. ### Control Control parameters are not part of the class interfaces but they can affect how **raspiCamSrv** processes an action method: - *duration*
With duration, you can specify the length of the time interval, during which the device will stay in the state achieved through the method, for example the 'on' state of an LED.
After this time, the system will check, whether the device object has a method off() (which is the case for LEDs and Buzzer) or a method stop() (which is the case for Motor and TonalBuzzer).
If either of these methods is found, it is applied.
In effect, the device will be in an inactive state afterwards. - *steps*
This is the number of steps in which the device shall reach the intended state within the given duration.
The intention here is that one might want a smooth rather than an abrupt movement, for example for a Servo.
**NOTE**This feature is currently not yet supported. - *burst_count*
This is a a parameter for method "take_photo". You can specify the number of photos which shall be taken as part of a photo burst in a series with a given interval. - *burst_intvl*
This is the interval you can specify for a photo burst. - *attach_photo*, *attach_video*
for an SMTP action, you can specify whether or not photos and/or videos shall be attached to the mail which have been created as part of the actions of the triggered event. ### Restrictions At a given time, only one action can be executed on a specific device type. ### Timing of Action Execution Whereas action execution is synchronous, when invoked through an [Action Button](./ConsoleActionButtons.md) (the user needs to wait until the action is completed), this is different for the case when an action is triggered by a [Trigger](./TriggerTriggers.md). In the latter case, action execution is done in an own thread which allows the action to be completed independently from the event handling thread which can treat other events in the meantime. This means that actions are always completed and not interrupted. This applies to actions with a configured duration, such as an LED which shall be 'on' for a certain time or a video with a given duration. However, it applies also to actions with an inherent time consumtion. For example the movement of a StepperMotor can consist of hundreds of steps with a waiting time of 1 to 4 ms after each step. This requires in total several seconds to complete. If for such an action a new action is requested before the previous action is completed, it will wait until the device is no longer busy. When stopping the event handling system, **raspiCamSrv** will wait for active actions to complete. ## Activation of Actions If the event-handling thread is currently active: - The *Active* check boxes are locked. If the event-handling thread is not active: - The *Active* check boxes are active You can activate/deactivate Actions by changing the *Active* check box and submitting the change. ## Deletion of Actions You can select one or multiple actions for deletion in the *Del* column and submit the selection. The *Del* column will only be accessible for change if the event-handling thread is currently not active. You cannot delete an action if it is used in an [Action Button](./SettingsAButtons.md). When an action is deleted, also its reference in the [Trigger-Actions](./TriggerTriggerActions.md) will be removed. ## Testing an Action It is recommended to test a new action, before it is used in a [Trigger assignment](./TriggerTriggerActions.md) or in an assignment to an [Action Button](./SettingsAButtons.md) or a [Live Button](./SettingsLButtons.md). For testing, you select a single action in the *Test* column and submit the selection. ## Changing Actions Changing of actions is currently not possible. However, you can easily create a new similar one with different parameters and deactivate or delete the old one. ================================================ FILE: docs/TriggerActive.md ================================================ # Triggered Capture of Videos and Photos [![Up](img/goup.gif)](./TriggerOverview.md) # Active Motion Capturing The *Start* button on the *Control* page starts the trigger capturing process. ![ActiveCapture](./img/Trigger_Active.jpg) An active capture process is indicated with red [Process Status Indicator](./UserGuide.md#process-status-indicators) The *Start* button has changed to *Stop*, which allows stopping the process. If motion capturing is currently active but operation paused because of schedule settings, this is indicated by a yellow process status indicator: ![Capturepaused](./img/ProcessIndicator8.jpg) ## Event Data Event data are stored in directory ```./prg/raspi-cam-srv/raspiCamSrv/static/events```: ![Eventstorage](./img/Trigger_Storage.jpg) Storage includes a log file as well as video and photo files. ## Parallel Activities While motion capturing is active, the live stream process will be kept active because this is used for motion detection. While motion capturing is active, you may continue working with **raspiCamSrv**. You may even take photos, videos or photo series. However, you should avoid changing camera controls or configuration because this might restart the camera. ### Dos and Don'ts #### Blocked: - Changing [Settings](./Settings.md) - Starting a [Exposure Series](./PhotoSeriesExp.md) or a [Focus Stack Series](./PhotoSeriesFocus.md) - Changing [Camera Configuration](./Configuration.md) #### Changing Zoom This can be done while trigger capturing is active. However the moment when the new zoom setting is activated will be registered as motion event. #### Changing Focus To improve the focus for camera model 3, you may change [Focus Settings](./FocusHandling.md) and [Trigger Autofocus](./FocusHandling.md#trigger-autofocus) #### Changing Camera Controls In order to change the quality of videos and photos, you may change any [Camera Controls](./CameraControls.md) while motion capturing is active. ## Log File While events are registered and videos and photos are taken, the system maintains a log file (```events/_events.log```) with antries for all events and the times when photos are captured or videos are started and stopped: ![EventLog](./img/Trigger_Logfile.jpg) The log file of the above screenshot shows examples without delayed actions as well as with configurations with 4 photos in the *Photo Burst*. ## Database Events and event actions are also stored in the SQLite3 database stored at ```./prg/raspi-cam-srv/instance/raspiCamSrv.sqlite```. The primary purpose of the database is providing fast access to event data over a longer period for the [Event Viewer](./TriggerEventViewer.md) ![TriggerDB](./img/Trigger_DB.jpg) Table *events* holds all individual events: ![DBEvents](./img/Trigger_DB_Events.jpg) Table *eventactions* holds the actions taken for each event: ![DBEventactions](./img/Trigger_DB_Eventactions.jpg) ================================================ FILE: docs/TriggerCalendar.md ================================================ # Trigger / Event Calendar [![Up](img/goup.gif)](./TriggerOverview.md) The calendar gives an overview on the number of events which have been registered for a specific day: ![EventCalendar](./img/Trigger_Calendar.jpg) Clicking on a red field navigates to the [Events](./TriggerEventViewer.md) display for this specific day. You can change the active month using the date control and navigation arrows, or return to the current month with the *Now* button. ## Download Log You can download the [Log file](./TriggerActive.md#log-file) including a timeline of all events and associated actions.
Note that only those triggers and their associated actions will be included in the log, for which the [control parameter](./TriggerTriggers.md#control) *event_log* has the value "True". ## Cleanup The *Cleanup* button can be used for removing old events. This requires that the process is stopped. After pressing the button, a confirmation is required: ![CleanupConfirm](./img/Trigger_ConfirmCleanup.jpg) The *Retention Period* for cleanup, shown in this confirmation, has been specified on the [Trigger/Control](./TriggerControl.md) page. For all events older than the *Retention Period*, cleanup will - remove all log file entries - delete all photo and video files - delete related database entries ================================================ FILE: docs/TriggerCameraActions.md ================================================ # Camera Actions [![Up](img/goup.gif)](./TriggerOverview.md) ![Action](./img/Trigger_Action.jpg) This section allows specification of aspects for photos and/or videos recorded in reaction an an event: - *Video Recording Type* With *Normal*, video recording starts with the event or, if configured, after a specified dalay. With "Circular*, the system continuesly captures video in a circular buffer with a capacity of a few seconds. In case of an event, also the seconds before the event will be available in the video. Currently, only *Normal* is supported. - *Pre-Record Length (sec)* is the number of seconds, the system shall look 'backwards' from the time of an event. - *Video Duration* specifies the length of videos captured in case of an event. If a new event is registered while video recording from the previous event is still active, this will be stopped before recording for the new event starts. - *Photo Burst - Number of Photos* allows specifying a number of photos which will be successively captured in case of an event. If video is recorded, at least one photo must be specified. - *Photo Burst Interval* is the interval after the previous photo when the system will capture a new photo if there is still motion detected. If no motion is detected after this interval, no photo will be taken. - *Action data path* is the path where pictures and logs for events will be stored. ================================================ FILE: docs/TriggerControl.md ================================================ # Trigger / Control [![Up](img/goup.gif)](./TriggerOverview.md) With this screen, you control scheduling of event handling and motion detection. ![Triggercontrol](./img/Trigger_Control.jpg) In the *Control* section, you may specify basic aspects of triggered actions: Where not otherwise stated, this applies to 'legacy' [motion capturing](./Trigger.md#1-motion-capturing) as well as to [General Event Handling](./Trigger.md#2-general-event-handling). - Under *Triggers*, you select the triggers to be used. You can activate Motion detection and/or the other *Configured Triggers* - Under *Actions* you specify the actions to be taken in case of [motion detection through the camera](./TriggerMotion.md). You may select among video recording and photo taking. In case *Record Video* is selected, also at least one photo must be taken. This photo will serve as placeholder for the video in the [Event Viewer](./TriggerEventViewer.md). With *Notification* you specify whether or not you want to be informed by e-Mail about an event. The details need to be specified on the [Notification](./TriggerNotification.md) tab. **ATTENTION**: If you have chosen to only activate *Configured Triggers* without *Motion Detection*, the listed Actions will be deactivated because they are only supported with *Motion Detection*.
From the *Configured Triggers*, the configured [Trigger-Actions](./TriggerTriggerActions.md) will be executed, which may also include camera actions. - With *Operation Weekdays*, you specify the weekdays when triggering shall be active. - *Operation Start* specifies the daytime when triggering is activated on each active weekday. - *Operation End* specifies the daytime when triggering is paused. - *Automatic Start with Server* When activated, the trigger capturing process can be automatically started with the server. When you change this parameter, you need to go to [Settings](./Settings.md) and store the current [Server Configuration](./SettingsConfiguration.md) If you want automatic start, you also need to select *Start Server with stored Configuration*. **Note** In case you start the Flask server manually, do not use the ```--debug``` option. This will cause an exception (see [Flask Issue #5437](https://github.com/pallets/flask/discussions/5437)). - *Detection Delay* allows specifying a delay in seconds. When an event is triggered, the configured action (video and/or photo, Notification) will be delayed by the specified number of seconds. Normally, this will be 0.
This applies to motion-captured events only. - *Detection Pause* specifies a 'dead time' after an event has been registerd. Within this time no new event will be registered although the system will not stop detecting motion. This setting prevents from being flooded with registered events, for example if motion persists for a longer time. Detection pause (and alse *Detection Delay*), configured here, does not apply to the configured [Triggers](./TriggerTriggers.md). For these, it is possible to specify *bouncing-time* individually for every trigger. - *Retention Period* specifies the number of days for which event data will be retained when a [cleanup](./TriggerCalendar.md#cleanup) is done.
This does not apply for photos or videos which have been taken on triggers for which *event_log* was set to "False" Data changes will not be persisted unless the **Submit** button has been pressed. ## Starting Trigger capturing Trigger- and event handling is activated using the *Start* button. Depending on the selected Triggers, *Motion Detection* and/or *Configured Triggers* are started. Which process is currently active is indicated by the [status indicators](./UserGuide.md#process-status-indicators): - Motion detection only: ![Proc13](./img/ProcessIndicator14.jpg) - Configured Triggers only: ![Proc13](./img/ProcessIndicator15.jpg) - Both ![Proc13](./img/ProcessIndicator13.jpg) Note that, whenever Motion Detection is active, also the live stream will be kept active because this is used to detect motion. For active of motion capturing, see [Active Motion Capturing](./TriggerActive.md) ================================================ FILE: docs/TriggerEventViewer.md ================================================ # Trigger / Events [![Up](img/goup.gif)](./TriggerOverview.md) Event Details are shown in the Event Viewer for a specific day: ![Event Viewer](./img/Trigger_Events.gif) In the top area, you may - change the active day using the date control or the arrow buttons. Single arrows shift by day, double arrows by week. In each case the starting hour is set to 00:00h. - change the start time from which on events will be shown. Here, single arrows shift by a quarter of an hour, double arrows by an hour. - You can select whether you want to see videos, photos, both or none. In case of videos, the first photo is always shown instead of the video on the left side, however clicking on the photo will open the video viewer. - whether a video or a photo is represented by the small picture can be distinguished by information on the video length. Selecting a video or photo shows it in the detail area on the right side. ## Event Card The Event Card for each event ![EventCard](./img/Trigger_EventCard.jpg) shows, from top to bottom: - Event Type - Event date - Event time - Event trigger - Event trigger algorithm [Mean Square Diff](./TriggerMotion.md) [Frame Diff.](./TriggerMotion.md#test-for-frame-differencing-algorithm) [Optical Flow](./TriggerMotion.md#test-for-optical-flow-algorithm) [BG Subtraction](./TriggerMotion.md#test-for-background-subtraction-algorithm) - Trigger parameter (see [Motion](./TriggerMotion.md) tab) cam : Camera Num by which motion was detected roi : Index of the [Region of Interest](./TriggerMotion.md#regions-of-interest-and-regions-of-no-interest) in which motion was detected **NOTE**: Motion detection analysis is stopped whenever motion has been detected in one of the RoIs. The index of this ROI is reported here. msd : *Mean Square Threshold* BBox_thr : *Bounding Box Threshold* IOU_thr : *IOU Threshold* Motion_thr : *Motion Threshold* Model : *Background Subtraction Model* (1=MOG2, 2=KNN) You may use the information to fine tune the algorithm parameters on the [Motion](./TriggerMotion.md) tab. ## Photos/Videos with ROI/RoNI When [Photos/Videos with RoI/RoNI](./TriggerMotion.md) has been activated, borders of RoIs/RoNIs are shown on photos and videos (for "Mean Square Diff" not no videos): - Red borders represent *Regions of Interest* where motion was detected first. - Green borders represent the other specified *Regions of Interest. - Blue borders represent *Regions of NO Interest. In case that photos have been taken together with videos, this is represented as shown below. Videos show the video length in the footer. ![EventsVodeoPhoto](./img/Trigger_Events_Photo.jpg) ================================================ FILE: docs/TriggerMotion.md ================================================ # Trigger / Motion Detection Configuration [![Up](img/goup.gif)](./TriggerOverview.md) ![Motion](./img/Trigger_Motion.jpg) ## Algorithms With Motion Capturing, you may trigger actions in case that **raspiCamSrv** has detected changes in the visual area of the active camera. Sensitivity of detection is strongly dependent on the algorithm used for detection. **raspiCamSrv** currently supports 4 different algorithms: - *Mean Square Difference* between pixel color levels of successive frames. - *Frame Differencing* - *Optical Flow* - *Background Subtraction* The latter 3 variants are implementations of algorithms proposed by Isaac Berrios in [Introduction to Motion Detection](https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2). Whereas the *Mean Square Difference* is available in general, the other algorithms require [special preconditions for Extended Motion Capturing](./Settings.md#extended-motion-detection-support). ## Configuration This screen allows specification of motion capturing aspects: - *Motion Detection Algorithm* allows selecting the algorithm by which the system will recognize motion through its camera. ![Motion Algos](./img/Trigger_Motion_Algos.jpg) Depending on the selected algorithm, the relevant parameters are editable and can be adjusted. - *Mean Square Threshold* is the value of the mean square difference above which the system detects a motion event. - *Bounding Box Threshold* is the threshold for acceptable contour sizes (see [IB-1](https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2)) - *IOU Threshold* is the Threshold for "Intersection Over Union or IOU" of overlapping bounding boxes (see [IB-1](https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2)) - *Motion Threshold* is the minimum flow threshold for motion used in *Optical Flow* algorithm (see [IB-2](https://medium.com/@itberrios6/introduction-to-motion-detection-part-2-6ec3d6b385d4)) - *Background Subtraction Model* is the model used for generating a background model in *Background Subtraction* algorithm. (see [IB-3](https://medium.com/@itberrios6/introduction-to-motion-detection-part-3-025271f66ef9)) - *Video with Bounding Boxes* allows selection of the type of video recorded for a motion event, if activated on the [Control](./TriggerControl.md) tab. If activated, the video will show bounding boxes around areas for which motion has been detected. Otherwise, normal videos will be recorded. - *Use Regions of Interest* If selected (and submitted) it will be possible to specify a set of rectangular areas which serve as *Regions of Interest* / *Regions of NO Interest*. For *Regions of Interest* (RoI), motion will only be detected when occurring within these regions (see [below](#regions-of-interest-and-regions-of-no-interest)). *Regions of NO Interest* (RoNI) will be generally excluded from motion detection. - *Regions of Interest* This field holds the *Regions of Interest* as a tuple of tuples where each tuple represents one region with (x offset, y offset, width, height). The values refer to the [Pixel Array Size](./Information_CamPrp.md) of the sensor. The field is not editable; it is populated when *Regions of Interest* are drawn. - *Regions of No Interest* This field holds the *Regions of No Interest* as a tuple of tuples where each tuple represents one region with (x offset, y offset, width, height). The values refer to the [Pixel Array Size](./Information_CamPrp.md) of the sensor. The field is not editable; it is populated when *Regions of No Interest* are drawn. - *Photos/Videos with RoI/RoNI* This switch decides whether or not *Regions of Interest* and/or *Regions of NO Interest* are drawn on photos or videos captured while *Motion Detection* is active. **NOTE**: When *Algorithm* "Mean Square Diff" is used, RoIs/RoNIs can only be shown on photos but not on videos. Any changes must be submitted with the **Submit** button. Changes will be effective after the Motion Capturing Process has been started the next time. For example, trees or leaves moving in the wind are normally not of interest. ## Regions of Interest and Regions of NO Interest **NOTE**: This feature is only supported if OpenCV is installed. In many cases, it is desirable to restrict the motion-sensitive region of the camera view to specific areas. **raspiCamSrv** supports two type of areas: - *Regions of Interest* (RoI) restrict motion detection to these areas - *Regions of NO Interest* (RoNI) exclude motion detection from specific areas. Both can be used simultaneously or alternatively. When no RoI is defined, the entire cropping area will be the Region of Interest. RoNIs will only have an effect if at least a part of them is within a RoI. **NOTE**: Regions of Interest may be automatically adjusted when the Live View is [zoomed or panned/tilted](./ZoomPan.md) After *Use Regions of Interest* has been activated (and submitted), a Live View will be shown with a canvas activated on which the intended regions can be drawn: ![Motion RoI](./img/Trigger_Motion_RoI.gif) You first need to select the type of region to be drawn. Afterwards, you draw the region with left mouse key pressed. *Regions of Interest* are shown with green border line. *Regions of No Interest* are shown as blue filled rectangle While drawing a new region, the borders of previously drawn regions are invisible. All borders are shown as soon as the mouse pointer has left the drawing canvas. To remove previously drawn regions, just deactivate and activate the *Use Regions of Interest* checkbox without submitting. After all intended regions are finally drawn, submit the Motion Detection Configuration settings. ## Testing Motion Capturing In order to optimize the parameters for the intended application, **raspiCamSrv** allows test runs for the selected algorithm. During a test run, no events are generated. Instead, a preview of different aspects of the chosen algorithm is shown. Detected motion events are indicated by the occurrence of bounding boxes. An active test run is indicated by the turquoise status indicator. - For the *Mean Square Difference* algorithm, there is no test. However, pressing the **Test Motion Detection** button will show the current framerate in the message line, if motion detection is active. - For the other algorithms, pressing the **Test Motion Detection** button will stop an active Motion Detection server and start a test run. A set of 4 intermediate images are presented which are calculated from the last, or the last two, frames. If Regions of Interest are defined, these will be considered and visualized during the test. ### Test for *Frame Differencing* Algorithm For a detailed description of this algorithm, see [Introduction to Motion Detection: Part 1](https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2) by Isaac Berrios. ![Frame Difference Test](./img/Trigger_Motion_FrameDiff_Test_l.gif) ### Test for *Optical Flow* Algorithm For a detailed description of this algorithm, see [Introduction to Motion Detection: Part 2](https://medium.com/@itberrios6/introduction-to-motion-detection-part-2-6ec3d6b385d4) by Isaac Berrios. ![Optical Flow Test](./img/Trigger_Motion_OpticalFlow_Test_l.gif) ### Test for *Background Subtraction* Algorithm For a detailed description of this algorithm, see [Introduction to Motion Detection: Part 3](https://medium.com/@itberrios6/introduction-to-motion-detection-part-3-025271f66ef9) by Isaac Berrios. ![Background Suntraction Test](./img/Trigger_Motion_BGSubtract_Test_l.gif) This algorithm can normally be expected to give best results. The fact that the shown example has a lower quality compared to *Optical Flow* may be attributed to the 'stationary' movement of the object which 'burns' itself into the background model because each frame contributes to the model. ## Performance The performance requirements for the different algorithms have an impact on the frame rates which can be achieved during testing and an active Motion Capturing process. In order to get information on the frame rates, these are measured during a test and displayed at the bottom of the screen. Reliable values can only be obtained after the test has run some time. Therefore, it is necessary to refresh the screen using the button aside of the displayed value. Unfortunately, framerates had not been refreshed before recording the GIFs, above. The the following table shows framerates observed with a Pi 5 and a camera model 3: | Algorithm | Stream | Sensor Mode | Stream Size | Framerate | |------------------------|--------|-------------|-------------|-----------| | Mean Square Diff | lores | default | 640 x 360 | ~14 | | Frame Differencing | lores | default | 640 x 360 | ~14 | | Optical Flow | lores | default | 640 x 360 | ~5 | | Background Subtraction | lores | default | 640 x 360 | ~14 | | | | | | | | Mean Square Diff | lores | 0 | 1536 x 864 | ~14 | | Frame Differencing | lores | 0 | 1536 x 864 | ~14 | | Optical Flow | lores | 0 | 1536 x 864 | ~1 | | Background Subtraction | lores | 0 | 1536 x 864 | ~5 | | | | | | | | Mean Square Diff | lores | 1 | 2304 x 1296 | ~10 | | Frame Differencing | lores | 1 | 2304 x 1296 | ~6 | | Optical Flow | lores | 1 | 2304 x 1296 | ~0.3 | | Background Subtraction | lores | 1 | 2304 x 1296 | ~2 | Below is a load profile taken wit a Pi5, 8GB memory, built in a standard case with fan. Steaming was never active and no motion was tracked during recording. ![Load Profile](./img/Trigger_LoadProfile.jpg) Motion tracking with different algorithms was run for 30 minutes in the following sequence: - 18:15 - System idle - 18.30 - Mean Square Diff - 19:00 - Frame Differencing - 19:30 - Background Subtraction - 20:00 - Optical Flow - 20:30 - Motion tracking stopped - 20:45 - raspiCamSrv stopped Although there is a significant impact on CPU utilization, especially for the *Optical Flow* algorithm, CPU temperature is within reasonable ranges. ## Recorded Videos If the option to record videos with bounding boxes has been chosen, the videos are generated frame by frame within the algorithm. The framerate needs to be specified before frames are added to the video. Currently, the video is started when a motion event has been detected (final number of detected bounding boxes > 0). However, at this time, the real frame rate is not yet known. Therefore a rough estimate is used for the individual algorithms which is close to the values shown in figures, above. This results in videos with motion speed close to real life. If, however, the achieved rates on a specific system differ from these values, the video speed may be timelapse or slow-motion. ================================================ FILE: docs/TriggerNotification.md ================================================ # Trigger / Notification [![Up](img/goup.gif)](./TriggerOverview.md) On this tab, you specify the details required for notification on an event by e-Mail: ![TriggerNotification0](./img/Trigger_Notification0.jpg) ## Mail Server Settings - *SMTP Server* is the server address, for example "smtp.gmail.com" - *Port* is the server port to be used - *Use SSL* specifies whether or not SSL (Secure Sockets Layer) is to be used - *Server requires Authentication* must be checked if the mail server requires authentication with user and password. - *User* is the user name to be used for login to the server. This is typically identical with the e-Mail address. - *Password* is the password required for authentication ## Handling of Mail Server Credentials Credentials for authentication to the mail server are not part of the normal raspiCamSrv configuration. They ere never exported to JSON files when configuration is stored ([Settings/Configuration](./SettingsConfiguration.md)) Therefore, they can also not be imported when the server restarts. **raspiCamSrv** offers two alternatives for secure handling: ### 1. Storage in a Secrets File This is activated by checking *Store Credentials in File* and by specifying the full path of that file in *Credentials File Path*. For example, the path could be ```/home//.secrets/raspiCamSrv.secrets```. If the path and the file do not exist, they will be automatically created. Access to this file should be restricted to the user running **raspiCamSrv** as service or from the command line. The advantage of this method is, that on server restart the system can automatically load user name and password for connection to the mail server. Thus, notification will automatically be activated, provided that Triggered Capture is automatically started with server start. ### 2. Manual Entry Alternatively to storage in a secrets file, *User* and *Password* can be manually entered. **raspiCamSrv** will keep this information in memory during the server livetime. After server restart, these credentials are initally not available and, therefore, notification will not be active until the credentials have been entered once on this screen. ## Mail Settings - *From e-Mail* is the e-Mail address to be shown in the *From* field. Mail servers may replace this with the e-Mail address of the account. - *To e-Mail* is the e-Mail address of the recipient to whom the notification is to be sent. - *Subject* Is the text for the *Subject* field of the mail to be sent. - With *Notification Pause*, you can specify a pause in seconds in which no further notification mail wil be sent. A value smaller than the *Detection Pause* (see [Trigger Control](./TriggerControl.md)) will have no effect, so that every event will be notified. If the value is chosen as a multiple (N) of the *Detection Pause*, only every Nth event will be notified. - *Include Video* specifies whether or not the event video will be included in the mail. If this is selected, the mail will be sent not earlier than video recording has terminated. This is determined by *Video Duration* (see [Camera Actions](./TriggerCameraActions.md)). Keep in mind that the video size should not exceed the maximum mail size allowed by the provider. - *Include Photos* specifies whether or not photos should be attached to the mail. If this is selected and a number > 1 has been specified for *Photo Burst* (see [Camera Actions](./TriggerCameraActions.md)), the mail will not be sent before the last photo has been taken or the next event has been registered. ## Submitting Configuration Settings ![TriggerNotification1](./img/Trigger_Notification1.jpg) When submitting configuration entries, the system will automatically try to connect to the mail server using the specified credentials. ![TriggerNotification3](./img/Trigger_Notification3.jpg) ![TriggerNotification3a](./img/Trigger_Notification3a.jpg) If the connection test was successful, this is indicated in the message area, otherwise, an error message is shown. User and password are removed from the screen and need to be entered again, if necessary. Whether or not connection to the mail server has been verified, is allways shown in the top of the screen. ## Entering Credentials after Server Restart After the server has been restarted and credentials are not stored in a secrets file, they need to be entered once: ![TriggerNotification2](./img/Trigger_Notification2.jpg) ## Notification Errors Sending a mail may require several seconds, especially if the mail includes larger attachments. Therefore an additional thread is started for each mail to be sent, in order not to block capturing events. If an error occurs while trying to send a mail, an error status is set in the Trigger configuration settings. The error message is shown on the *Control* Tab of the *Trigger* screen: ![TriggerNotificationError](./img/Trigger_NotificationError.jpg) The message will vanish when Triggered Capture is restarted or when the server is restarted. Errors occurring in the sending process do not stop motion capturing. ================================================ FILE: docs/TriggerOverview.md ================================================ # Trigger - Triggers and Actions [![Up](img/goup.gif)](./UserGuide.md) ![Cam Menu](img/TriggerMenu.jpg) **raspiCamSrv** can capture events from camera and GPIO input devices and let these process actions by the camera and GPIO output devices. For example, pressing a button could trigger photo taking, or starting/stopping video recording could trigger switching an LED, or detection of motion could cause e-Mail notification. **NOTE**: If no camera is connected, the camera-related menu items are missing. - [Introduction](./Trigger.md) - [Control](./TriggerControl.md) - [Triggers](./TriggerTriggers.md) - [Actions](./TriggerActions.md) - [Trigger-Actions](./TriggerTriggerActions.md) - [Motion](./TriggerMotion.md) - [Camera](./TriggerCameraActions.md) - [Notification](./TriggerNotification.md) - [Events](./TriggerEventViewer.md) - [Calendar](./TriggerCalendar.md) ================================================ FILE: docs/TriggerTriggerActions.md ================================================ # Trigger-Actions [![Up](img/goup.gif)](./TriggerOverview.md) On this page, you specify which actions are invoked in case of a specific Trigger event. ![TriggerActions](./img/Trigger_TriggerActions.jpg) **IMPORTANT**: To preserve any configurations over server restart, you need to [store the configuration and activate *Start Server with stored Configuration*](./SettingsConfiguration.md) The dialog will only allow changes when the event handling thread is not active. Just activate the check box for every event you want to be triggered by a specific trigger. ================================================ FILE: docs/TriggerTriggers.md ================================================ # Triggers [![Up](img/goup.gif)](./TriggerOverview.md) This page is used for spacification of triggers. Triggers are registered events occurring for the camera system or for GPIO input [devices](./SettingsDevices.md) which can be used to initiate one or multiple [actions](./TriggerActions.md). ![Trigger0](./img/Trigger_Trigger1.jpg) **IMPORTANT**: To preserve any configurations over server restart, you need to [store the configuration and activate *Start Server with stored Configuration*](./SettingsConfiguration.md) ## Creating a Trigger 1. In field *Trigger Source*, select the source system for which the trigger is defined: (*Camera* and *Motion Detector* will be available as option only when a camera is available) ![Trigger1](./img/Trigger_Trigger2.jpg) 1. This will open a list of devices defined for the chosen source system: ![Trigger1](./img/Trigger_Trigger3.jpg) for the GPIO system, these are the **Input** devices configured on [Settings/Devices](./SettingsDevices.md) 1. After a device has been selected, the system will show the device type with a link to related gpiozero documentation as well as the events which can be trapped for this device (this information is taken from the [fixed configuration for the device type](./SettingsDevices.md#device-type-configuration)): ![Trigger1](./img/Trigger_Trigger4.jpg) 1. When the event has been chosen, the sytem will display any parameters which may be required for this event: ![Trigger1](./img/Trigger_Trigger5.jpg) Now you need to specify values for these parameters, unless you leave the defaults, and enter a unique name for the trigger. In this step, the *Submit* button will be activated. 1. Pressing the *Submit* button will create the trigger and show it in the *Trigger Overview*. ### Parameters The parameters, for which values can be specified, are properties or methods of the device class. Information about their function can be obtained from the gpiozero class documentation accessible through the link. ### Control Control parameters are not part of the class functionality but they can affect how **raspiCamSrv** processes a captured event: - *bounce_time*
This is a time interval given in seconds.
After an event has been processed, other events occurring within this interval will be ignored.
This shall avoid jitter in triggered actions. For some devices (e.g. Button), bounce time can already be specified for the device and will be handled by gpiozero. - *event_log*
The value of this parameter decides whether or not a trigger and its [associated actions](./TriggerTriggerActions.md) will be treated as events and included in [event logging](./TriggerActive.md) and [event viewer](./TriggerEventViewer.md).
If **False**, events triggered by the trigger will not be logged in [event logging](./TriggerActive.md) and they will also not be visible in the [event viewer](./TriggerEventViewer.md). Instead, if camera actions are associated with the trigger, the resulting photos and videos will be visible in the normal [Photo Viewer](./PhotoViewer.md). ### Restrictions Only one active trigger can be configured for a specific device-event. If another trigger is configured for the same event, only one trigger will remain active. If you want multiple actions on a specific trigger, you will specify this in [Trigger-Actions](./TriggerTriggerActions.md). ## Activation of Triggers If the event-handling thread is currently active: - The *Active* check boxes are locked. - If this is the only trigger for the chosen device-event, the trigger will be activated. - If another trigger exists for the same device-event, the new trigger will not be activated. If the event-handling thread is not active: - The *Active* check boxes are active - If this is the only trigger for the chosen device-event, the trigger will be activated - If another trigger exists for the same device-event, this will be deactivated and the new trigger will be activated. You can activate/deactivate triggers by changing the *Active* check box and submitting the change. If you tried to activate several triggers for the same device-event, the system will leave only one of them active. ## Deletion of Triggers You can select one or multiple triggers for deletion in the *Delete* column and submit the selection. The *Delete* column will only be accessible for change if the event-handling thread is currently not active. ## Changing Triggers Changing of triggers is currently not possible. However, you can easily create a new similar one with different parameters and deactivate the old one. ================================================ FILE: docs/Troubelshooting.md ================================================ # raspiCamSrv Troubleshooting ## Errors during Installation This section deals with errors which may occur while running the [Automated Installer](./installation.md) or while [Installing manually](./installation_man.md) **ERROR: pip's dependency resolver** ``` ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts. types-flask-migrate 4.0 requires Flask-SQLAlchemy>=3.0.1, which is not installed. ``` This type of messages, mentioning different missing packages, may occur, for example, in
- Step 10: Installing Flask ...
- Step 11.2: Installing numpy ...
- Step 11.3: Installing matplotlib ...
- Step 11.4: Installing flask-jwt-extended ...
These are actually not real errors but just warnings, which say that some other packages have unmet dependencies.
As long as the installation of the intended package is successful, this can be ignored.
Successful installation is, for example, confirmed through
```Successfully installed numpy-2.4.2```
In case of failing installation, the automated installer will stop at this point. **OSError: [Errno 12] Cannot allocate memory**
This error can occur when Picamera2 tries to allocate CMA memory (See also [Checking Contiguos Memory (CMA)](./SetupDocker.md#checking-contiguous-memory-cma)).
The context is usually ``` File "/usr/lib/python3/dist-packages/picamera2/dma_heap.py", line 98, in alloc ret = fcntl.ioctl(self.__dmaHeapHandle.get(), DMA_HEAP_IOCTL_ALLOC, alloc) ``` During installation, this error can occur in
- Step 12: Initializing database ...
The error has mainly been observed in RPI Zero and RPI Zero 2 systems.
Experience has shown that this is a temporary issue and that memory allocation can be successful later.
Therefore the [Automated Installer](./installation.md) uses up to 5 attemts to initialize the database with a pause of 5 sec inbetween.
Should execution of this step not be successful, the installer will stop.
You can then try to run the installer again later ## Errors during Operation [![Up](img/goup.gif)](./UserGuide.md) This section intends to collect information on how to deal with errors or problems which may occur while running **raspiCamSrv**. - **Password forgotten** If you have your password forgotten, there are two alternatives:
1. Somone else is Superuser:
Ask him to remove your user entry and create a new one (See [Settings / Users](./SettingsUsers.md)).
2. You are the Superuser.
You need to reset the database where user entries are stored.
You do this with with ```flask --app raspiCamSrv init-db``` (see [RaspiCamSrv Installation](./installation.md) Step 11).
At the next Login, you need to Register as new Superuser (see [Authorization](./Authentication.md)) - **ERROR in motionDetector: Exception in _motionThread: OpenCV(4.6.0)** This error may occur when trying to use [extended motion capturing](./TriggerMotion.md) while the 'YUV420' stream format is set for the [Live View Configuration](./Configuration.md).
It seems that OpenCV is not capable to handle images with this format. This error is typically observed on Pi3 and Pi4 where the YUV stream format is mandatory for the lores stream according to the [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), ch. 4.2.
As a workaround, you may try setting the "main" stream for the Live View configuration with "RGB888" Stream Format. To avoid performance issues, also a low Stream Size (e.g. 640x400) should be chosen.
See [raspi-cam-srv Issue #48](https://github.com/signag/raspi-cam-srv/issues/48) - **No Connection to server although server has been started as service**. This may happen (see [raspi-cam-srv Issue #8](https://github.com/signag/raspi-cam-srv/issues/8)) if the service has been started before the network interfaces are ready. The systemd journal will indicate that the Flask server is only listening to *localhost* (127.0.0.1) In this case, more restrictive settings in the *After* clause of the [service configuration](./service_configuration.md) file may be required (see [systemd Network Configuration Synchronization Points](https://systemd.io/NETWORK_ONLINE/)) - **SystemError: No cameras were found on the server's device** See [raspi-cam-srv Issue #6](https://github.com/signag/raspi-cam-srv/issues/6) - **ERROR in camera_pi: Could not import SensorConfiguration from picamera2.configuration. Bypassing sensor configuration** This message may occur when running on Bullseye systems. Currently, it can be ignored because the missing *SensorConfiguration* class has currently no impact on **raspiCamSrv** functionality. *SensorConfiguration* is a class in Picamera2 which is referenced in the CameraConfiguration (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf) chapter 4.3) It includes information on the output size and bit depth of a stream. In Bullseye systems, this class is missing. Currently, raspiCamSrv does not require the *SensorConfigiuration* but it is included in the data model because Picamera2 uses it. The error occurs when trying to import the class. - **WARN RPiSdn sdn.cpp:39 Using legacy SDN tuning - please consider moving SDN inside rpi.denoise** This is just a warning from the libcamera system that the tuning file should be updated. It is currently not known that there is an impact on raspiCamSrv functionality. - **ERROR V4L2 v4l2_videodevice.cpp:1906 /dev/video4[16:cap]: Failed to start streaming: Broken pipe** See [picamera2 Issue #104](https://github.com/raspberrypi/libcamera/issues/104) from Feb 1, 2024 The recommended solution was to go back to kernel release 6.1.65 with ```sudo rpi-update d16727d``` - **ModuleNotFoundError: No module named 'picamera2'** See [raspi-cam-srv Issue #4](https://github.com/signag/raspi-cam-srv/issues/4) - **TypeError: memoryview: casts are restricted to C-contiguous views** See [picamera2 Issue #959](https://github.com/raspberrypi/picamera2/issues/959) ## Logging The **raspiCamSrv** server uses Python logging. Logging is initialized in module ```__init__.py```. All lines controlling the way of logging or [code generation](#generation-of-python-code-for-camera) are preceeded with a comment line, starting with ```#>>>>>``` By default, a StreamingHandler is added to all loggers which outputs log information to sys.stderr. If desired, the prepared FileHandler can be activated. The log level for all loggers is initialized with level ERROR. This can be modified for all or for specific modules. ### Flask logging Flask logging is controlled by ```app.logger``` ### Werkzeug logging Werkzeug implements WSGI, the standard Python interface between applications and servers. Werkzeug logs basic request/response information. Werkzeug logging is controlled by ```logging.getLogger("werkzeug")```. The log level is initialized in ```__init__.py``` with INFO, in order to enable informative logging during server start. After the server has been started, the log level is raised to ERROR. This is done in ```auth.py``` in function ```login_required(view)```. ### raspiCamSrv Logging Logging can be controlled individually for each module. ### libcamera logging The libcamera library is the basic C++ camera library on which Picamera2 is based. The log level is controlled through an environment variable LIBCAMERA_LOG_LEVELS. This is set in ```__init__.py``` to WARNING. Other allowed log levels are listed in the comment. For more details, see [Picamera2 manual](./picamera2-manual.pdf), chapter 8.6.2 ### Picamera2 logging Picamera2 logging is initialized in ```__init__.py``` with ERROR For more details, see [Picamera2 manual](./picamera2-manual.pdf), chapter 8.6.1 ## Generation of Python Code for Camera The system can generate a file with Python code including the entire interaction of **raspiCamSrv** with Picamera2. This file can then be used for debugging and error analysis. A specific logger ("pc2_prg") with with level DEBUG is used for code generation. The logger can be activated by setting ```prgLogger.setLevel(logging.DEBUG)``` in ```__init___.py``` The code file is located in ```/home//prg/raspi-cam-srv/logs``` with name ```prgLog_YYYYMMDD_hhmmss.log``` A new file will be generatet at every server start. To run the files, you neet to change the file type from ```.log``` to ```.py``` Generating the files with ```.py``` extension does not work because Flask seems to recognize these files and does strange things. All photo and video output generated by these files will be located at ```/home//prg/raspi-cam-srv/output``` with the same file names as in the original session. ================================================ FILE: docs/Tuning.md ================================================ # raspiCamSrv Camera Tuning [![Up](img/goup.gif)](./UserGuide.md) The algorithms of Raspberry Pi cameras are affected by tuning files which are specific for each camera type and Raspberry Pi version. These files are automatically loaded when the camera system is instantiated. A set of default tuning files for all camera models are part of the Raspberry Pi OS distribution. (For details, see the [Raspberry Pi Camera Algorithm and Tuning Guide](https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf)) With **raspiCamSrv**, you can control which files are used and you may tweak specific features within these files to optimize the camera behavior for your specific needs. This functionality can be accessed through menu *Config*, submenu *Tuning*: ![Tuning1](./img/Tuning1.jpg) The configuration provides three parameters: - *Name of Tuning File*
This is the name of the tuning file to be loaded for the active camera.
Raspberry Pi OS distributions include default tuning files with '.json' type and a name which is identical to the name of the camera model.
If you provide own tuning files, you may choose an arbitrary name. If you are using multiple camera models simultaneously, you should be able to correctly associate a file with a camera model. - *Full path to folder with tuning files*
If this is empty (None), Picamera2 will search a system-specific list of likely installation folders for the required tuning file.
raspiCamSrv supports usage of a custom folder (```/home//prg/raspi-cam-srv/raspiCamSrv/static/tuning```) which allows keeping own tuning files separate from the default ones. - *Load Tuning File*
If this checkbox is activated, raspiCamSrv will request Picamera2 to load the specified tuning file instead of the default one.
Otherwise, Picamera2 will load the default tuning file. Any changes of one of these parameters needs to be submitted with the *Submit & Apply* button. If *Load Tuning File* is checked, a restart of the live stream will be requested so that the result of the chosen tuning file can be directly seen in the [Live](./LiveScreen.md) view. ## Switching between Custom and Default Folder - Button *Custom Folder* will switch the folder for tuning files to the custom folder ```/home//prg/raspi-cam-srv/raspiCamSrv/static/tuning```
If there is already a tuning file with the specified name in this folder, it will be used.
If the tuning file does not yet exist in the custom folder, the standard tuning file will be copied to the custom folder. - Button *Default Folder* (toggled) will switch to the default folder.
This folder is system-specific, e.g. ```/usr/share/libcamera/ipa/rpi/pisp``` ![Tuning2](./img/Tuning2.jpg) ## Modification of Tuning Files Modification of tuning files with raspiCamSrv can only be done in the custom folder. If you are not directly accessing the tuning files on the Raspberry Pi, you can download a file, rename it, if desired, and modify it on the client machine. Afterwards you can upload it to the custom folder. When done, the file will be available for selection. ### Deleting a Tuning File The button *Delete Tuning File* will only be active, if - the custom folder is activated - *Load Tuning File* is unchecked - No parameter changes have beenn made after the last *Submit & Apply* This restriction avoids inadvertently deleting the wrong file if the file has been changed without submitting. ### Download Tuning File A tuning file can be downloaded from the default folder or from the custom folder. ### Uploading Tuning Files The buttons for upload will only be active if the custom folder is selected. 1. You start pushing the *Select Tuning File for Upload* button
![Tuning3](./img/Tuning3.jpg) 2. If a single file has been selected, its name will be shown on the button:
![Tuning4](./img/Tuning4.jpg) 3. If a multiple files have been selected, the number of selected files will be shown on the button:
![Tuning5](./img/Tuning5.jpg) 4. Finally, you need to the *Upload selected File* button to upload:
![Tuning6](./img/Tuning6.jpg) ## Using a different Custom Folder If you have already prepared tuning files in a specific folder on the Raspberry Pi, which is different from the Custom Folder used by raspiCamSrv, you can also refer to this folder. You first need to manually enter the *Full Path* to this folder and push *Submit & Apply*. Afterwards, the .json files in this folder will be available for selection as *Name of Tuning File* ## Tuning with Multiple Cameras Tuning files are specific for a camera and not for a camera model. If two cameras are used with a Pi5, the tuning file needs to be individually specified for each camera, also if both are the same model. To preserve the tuning configuration for a camera, it must be memorized using the *Memorize Configuration and Controls for Camera Change* in screen [Multi Cam](./CamMulticam.md) Because of a Threading issue in Picamera2 (see Picamera2 Issue #1103 [Tuning file support not thread-safe?](https://github.com/raspberrypi/picamera2/issues/1103)), the streams from the two cameras in dialog [Web Cam](./CamWebcam.md) may look as if the tuning file of the first camera would have also been applied to the second one. Nevertheless, the handling in raspiCamSrv is correct. You may proof this in the following way: 1. Activate a dialog without live view, e.g. [Info](./Information.md). 2. Wait at least 10 sec. until both streaming threads have terminated (refresh the screen from time to time) 3. In another browser window, stream just one camera (endpoint video_feed or video_feed2).
You should then see the effect of the correct tuning file.
However, if you later start streaming the other camera, you may see that the tuning file for the previously started camera has been applied. ================================================ FILE: docs/UserGuide.md ================================================ # RaspiCamSrv User Guide [![Up](img/goup.gif)](./index.md) The variant of the user interface, described on this page, refers to the case where **at least one camera** (CSI or USB) is available. If this is not the case, refer to the [reduced user interface](./UserGuide_NoCam.md). #### Startup When the server on a Pi is running and the Pi is reacheable through the network (usually WiFi), you can connect with a browser using the Pi address ('raspi05' in the example below) with the Flask port number, usually 5000, e.g.: ```http://raspi05:5000``` #### Login The system will request an initial [registration and a login](./Authentication.md) and subsequently open the **Live** application screen. For error handling, see [raspiCamSrv Troubleshooting](./Troubelshooting.md) For interoperability, **raspiCamSrv** provides an [API](./API.md) which allows access to selected functions through web services. #### CSI-/USB-Cameras In addition to CSI cameras (2 for Pi 5), you can connect as many USB cameras as physical USB ports are available. However, at a time, **raspiCamSrv** will only operate two of them simultaneously. Related to usability in the UI, you will almost see no difference between CSI and USB cameras. Where they are important, they are handled in the UI and described in their context. #### AI Camera **raspiCamSrv** supports AI features with the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html). See [AI Camera Support](./AiCameraSupport.md). ## Application Screen ![Main Screen](img/Live_start.jpg) ### Elements #### Title bar On the left side, the title bar shows - the current version of raspiCamSrv
If an update to this version is available, the version number is shown in yellow:
![New Release Indicator](./img/CanUpdate_Indicator.jpg)
(See [Settings/Update](./SettingsUpdate.md) dialog) On the right side, the title bar shows - the current server connection - the active camera as advertised by Picamera2 - the active user - A special icon will indicate unsaved configuration changes:
![Changes](./img/UnsavedChangesIndicator.jpg)
It will vanish after changes have been saved with [Settings/Configuration/Store Configuration](./SettingsConfiguration.md) or after a stored configuration has been loaded. The icon can be pressed (2 times) to save unsaved changes. On the left side, the title bar shows the application name (raspiCamSrv) and the current screen. #### Main Menu The main menu (black background) allows navigation to different screens: - **Live** shows the [Live Screen](./LiveScreen.md) which includes functionality for image control as well as photo- and video taking - **Config** gives access to camera [Tuning](./Tuning.md) and camera [Configuration](./Configuration.md) where basic camera configurations can be specified for different scenarios. - **Info** opens the [Camera Information](./Information.md) page with information on the system and installed cameras as well as Properties and Sensor Modes of the active camera. - **Photos** shows the [Photos](./PhotoViewer.md) where the currently available photos and videos can be browsed and inspected in detail. - **Photoseries** opens the [Photo Series](./PhotoSeries.md) page for definition and control of photo series. - **Trigger** Allows configuring and controlling [triggered actions](./Trigger.md), based on [Motion Capturing](./TriggerMotion.md) as well as on configured [events from GPIO-connected sensors](./TriggerTriggers.md) - **Cam** gives access to the dialogs for [Web Cam](./CamWebcam.md) access; [Multi Cam](./CamMulticam.md) for multi-camera control. If a [Stereo Camera configuration](./Settings.md#activating-and-deactivating-stereo-vision) is activated, it hosts also the dialogs for [Camera Calibration](./CamCalibration.md) as well as for [Stereo Vision](./CamStereo.md). - **Console** is dedicated to manually controlled interactions with the [Raspberry Pi OS](./ConsoleVButtons.md) or with [GPIO-connected Actors](./ConsoleActionButtons.md), such as motors, servos or LEDs. - **Settings** opens the [Settings](./Settings.md) page for all kinds of static configurations for **raspiCamSrv**. - **Log Out** will log the active user out and direct to the [Log-In Screen](./Authentication.md) **NOTE:** Selecting an option on the main menu will issue a request to the server with a specific URL and, thus, refresh the screen. #### Submenu Many of the **raspiCamSrv** pages, selected by a [Main Menu](#main-menu) option have a submenu. Submenues are indicated by a green background. **NOTE:** Selecting an option on a submenu will **not** issue a new request and, thus, will **not** refresh the screen with new information from the server. Instead, submenu options activate different sections of the currently loaded page. However, *Submit* buttons on a page section apply only to data shown on the active section and not to data on other sections of the same page. #### Process Status indicators On the right side of the menu bar there is a group of status indicators for the different [background processes](./Background%20Processes.md): ![Status Indicators](./img/ProcessIndicator1.jpg) ![Status Indicators](./img/ProcessIndicator3.jpg) From right to left, these indicate the status of - Live stream thread for active camera - Video thread for the active camera - Recording [audio](./Settings.md#recording-audio-along-with-video) along with video for the active camrera - [Photo Series](./PhotoSeries.md) thread - [Motion Capture](./Trigger.md) thread - [Trigger](./TriggerTriggers.md) thread - Live stream thread for the second camera, if available (see [Web Cam](./CamWebcam.md) or [Multi-Cam](./CamMulticam.md)) - Video thread for the second camera, if available (see [Multi-Cam](./CamMulticam.md)) Red color indicates that a process is active whereas gray indicates that it is inactive. In the case of [motion capture](./TriggerMotion.md) or [event handling](./Trigger.md), - yellow color indicates that the process is active but currently not scheduled to register events - turquoise color indicates that the motion capture process runs in [test mode](./TriggerMotion.md#testing-motion-capturing) ![MotionPaused](./img/ProcessIndicator8.jpg) ![MotionPaused](./img/ProcessIndicator12.jpg) #### Message Line At the bottom of the screen, there is a message line where application messages will be shown when necessary. ## Streaming **raspiCamSrv** supports streaming MJPEG video. The straming URLs are ```http://:/video_feed``` for MJPEG video with Active Camera ```http://:/photo_feed``` for photo snapshots with Active Camera and low resolution ```http://:/photo_feed_hr``` for photo snapshots with Active Camera and high resolution ```http://:/video_feed2``` for MJPEG video with Second Camera ```http://:/photo_feed2``` for photo snapshots with Second Camera and low resolution ```http://:/photo_feed2_hr``` for photo snapshots with Second Camera and high resolution All URLs can be accessed without authentication if the checkbox *Req. Auth for Streaming* on the [Settings](./Settings.md) screen is deactivated. If this checkbox is activated, a user must have logged in to raspiCamSrv once in the same browser session which shall be used for streaming. A streaming or snapshot request in a browser session without login will redirect to the login screen. In the web client, an active streaming server is indicated with the process status indicators as ![ProcessStatusIndicator](./img/ProcessIndicator1.jpg) if only the active camera is streaming or ![ProcessStatusIndicator](./img/ProcessIndicator10.jpg) if both cameras are streaming or ![ProcessStatusIndicator](./img/ProcessIndicator11.jpg) if if only the second camera is streaming A live stream is shown in in the [Live Screen](./LiveScreen.md) for the active camera or on the [Web Cam](./CamWebcam.md) or [Multi-Cam](./CamMulticam.md) pages for both cameras. If [Stereo Vision](./CamStereo.md) is active, both cameras are streaming: ![ProcessStatusIndicator](./img/ProcessIndicator21.jpg) ![ProcessStatusIndicator](./img/ProcessIndicator20.jpg) When these indicators turn yellow, this indicates that the additional stereo camera process is active, serving the stereo vision stream. The streaming servers are automatically shut down if no client has been streaming within the last 10 seconds. This is independently controlled for both cameras as well as for the stereo camera process. For example if one is working in other dialogs rather than *Live Screen*, straming is not used and the streaming servers are shut down, which is indicated by ![ProcessStatusIndicator](./img/ProcessIndicator0.jpg) Streaming is automatically reactivated, if a streaming client connects, for example if the *Live Screen* is activated. Other clients, either connecting directly through the streaming URL or by using the **raspiCamSrv** web client, will also activate the streaming servers. Streaming of the active camera can be deactivated, if a **raspiCamSrv** task is executed which requires exclusive access to the camera because of a specific [Configuration](./Configuration.md) which is not compliant with the configuration required for streaming (for more details, see [raspiCamSrv Tasks and Background Processes](./Background%20Processes.md)). **NOTE** For a full understanding of application details, users should familiarize with the official document [Raspberry Pi - The Picamera2 Library](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf). The document version, on which this raspiCamSrv release is based, is also included in this documentation: [picamera2-manual.pdf](./picamera2-manual.pdf) ## Media Viewer In several dialogs, the raspiCamSrv UI shows photos or videos which have been taken with the camera system. Although most of these are larger than typical thumbnails, they may be too small for more detailed inspection of quality or image details. Therefore, raspiCamSrv provides a Media Viewer which can be started by clicking on the image or video. The availability of a Media Viewer is indicated by a modified cursor when hovering over the image. ![MediaViewer Start](./img/MediaViewer_1.jpg) Klicking on the image, will open a new browser tab with the selected image or video: ![MediaViewer Start](./img/MediaViewer_2.jpg) The tab can be separated from the main browser window and zoomed to screen size or full screen mode. The file name of the image/video is shown as tab title. ### Media Viewer for videos When videos are shown with controller icons for play/stop etc., only the upper area is sensitive for starting the media viewer whereas the lower area is used for controlling the video playback. ![MediaViewer Start](./img/MediaViewer_3.jpg) The Media Viewer browser tab will include its own set of controls: ![MediaViewer Start](./img/MediaViewer_4.jpg) ### Live Stream The live stream, available in dialogs [Live](./LiveScreen.md), [Cam](./Cam.md) and [Trigger/Motion](./TriggerMotion.md) are not enabled for Media Viewer activation. Instead, the live stream can be directly opened in a separate window using the [Streaming URL](./CamWebcam.md). ### Live Stream at Camera Start When a camera starts up, there is usually a short delay of a few seconds until the first frames are delivered by the camera. This time is required by different algorithms (e.g. auto exposure or automatic white balance) to collect information on the scene. For the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html), if AI features are enabled (see [Camera AI Configuration](./Configuration_AI.md)), the camera needs to load the specified neural network model. This can take significantly more time compared to the start of normal cameras and may lead to irritation when waiting for the live stream to start. The Picamera2 IMX500 package therefore issues a warning note about long startup times or a progress bar when running the [imx500 demo programs](https://github.com/raspberrypi/picamera2/tree/main/examples/imx500) on the Debian UI. ![imx500 Start](./img/Live_imx500_start_journal.jpg) **raspiCamSrv** shows an animation while the camera is starting up until the first frames are delivered for the Live stream: ![Live animation](./img/Live_Animation.gif) This animation is also shown for other cameras. Only for systems where OpenCV is not installed, the system will wait for the first frames until the Live stream is shown. For the imx500 camera, the system log will show activities while the model is being loaded to the camera. Experience has shown that, especially when the model is changed, the process may take a long time, even on a Raspberry Pi 5. ================================================ FILE: docs/UserGuide_NoCam.md ================================================ # RaspiCamSrv User Guide (No Camera) [![Up](img/goup.gif)](./index.md) This is a special variant of the general **raspiCamSrv** [User Interface](./UserGuide.md) for the case when no camera is available. This applies to the following situations: - There is currently no camera at all connected to the Raspberry Pi, neither CSI camera nor USB camera. - Currently, only one or more USB cameras are connected and have been recognized by the system (see [Info/Cameras](./Information_Cam.md)) but usage of USB cameras has been deactivated in [Settings](./Settings_NoCam.md). ## Application Screen Initailly, the system starts up with the [Info/System](./Information_Sys.md) dialog activated: ![Main Screen](img/Live_start_no_cam.jpg) ### Elements #### Title bar On the right side, the title bar shows - the current server connection - An information that currently no camera is available - the active user - A special icon will indicate unsaved configuration changes:
![Changes](./img/UnsavedChangesIndicator_no_cam.jpg)
It will vanish after changes have been saved with [Settings/Configuration/Store Configuration](./SettingsConfiguration.md) or after a stored configuration has been loaded. The icon can be pressed to save unsaved changes. On the left side, the title bar shows the application name (raspiCamSrv) and the current screen. #### Main Menu The main menu (black background) allows navigation to different screens: - **Info** opens the [Camera Information](./Information.md) page with information on installed cameras as well as Properties and Sensor Modes of the active camera. - **Trigger** Allows configuring and controlling [triggered actions](./Trigger.md), based on configured [events from GPIO-connected sensors](./TriggerTriggers.md) - **Console** is dedicated to manually controlled interactions with the [Raspberry Pi OS](./ConsoleVButtons.md) or with [GPIO-connected Actors](./ConsoleActionButtons.md), such as motors, servos or LEDs. - **Settings** opens the [Settings](./Settings.md) page for all kinds of static configurations for **raspiCamSrv**. - **Log Out** will log the active user out and direct to the [Log-In Screen](./Authentication.md) **NOTE:** Selecting an option on the main menu will issue a request to the server with a specific URL and, thus, refresh the screen. #### Submenu Most of the **raspiCamSrv** pages, selected by a [Main Menu](#main-menu) option have a submenu. Submenues are indicated by a green background. **NOTE:** Selecting an option on a submenu will **not** issue a new request and, thus, will **not** refresh the screen with new information from the server. Instead, submenu options activate different sections of the currently loaded page. However, *Submit* buttons on a page section apply only to data shown on the active section and not to data on other sections of the same page. #### Process Status indicators On the right side of the menu bar there is space status indicators for [background processes](./Background%20Processes.md): When a camera is not available, there is only the status indicator for the [Trigger](./TriggerTriggers.md) thread: ![Status Indicators](./img/ProcessIndicator0_no_cam_1.jpg) Gray color indicates that a process is inactive whereas red indicates that it is active. ![Status Indicators](./img/ProcessIndicator0_no_cam_2.jpg) Yellow color indicates that the process is active but currently not scheduled to register events ![TriggerPaused](./img/ProcessIndicator0_no_cam_3.jpg) #### Message Line At the bottom of the screen, there is a message line where application messages will be shown when necessary. ================================================ FILE: docs/Z_Legacy_Information.md ================================================ # raspiCamSrv Information on Camera System [![Up](img/goup.gif)](./UserGuide.md) This screen contains several tabs with information on the camera system: ## Installed Cameras ![Cameras](img/Info-Cameras.jpg) ### Raspberry Pi This section shows information on the server hardware with *Model* and *Board Revision* For the operating system, the kernel version (result of ```uname -r```), the Debian version (result of *Description* from ```lsb_release -a``` and ```cat /etc/debian_version```) and the system architecture (32-/64-bit) (result from ```dpkg-architecture --query DEB_HOST_ARCH```) are shown. *Process Info* shows current process information for the raspiCamSrv server process (result of Linux ```ps -eLf``` command) - *PID*: Process ID of Flask process (PID) - *Start*: Process start time (STIME): either start time (HH:MM) at current day or day (MonDD) when process was started. - *#Threads*: Number of threads (NLWP) - *CPU Process*: CPU time of process (TIME for LWP == PID) in HH:MM:SS - *CPU Threads*: Sum of CPU time for threads ((TIME for LWP != PID)) in %H:MM:SS *FFmpeg Info* shows information on an ffmpeg process if encoding of .mp4 videos is currently active. Recording of .mp4 videos may have been [started manually](./Phototaking.md) or as an action within [motion capturing](./Trigger.md) *raspiCamSrv Start* shows the time when the raspiCamSrv server has been started. At server start, raspiCamSrv checks whether or not the Raspberry Pi system time is synchronized with the time server. When the device is booted and raspiCamSrv is automatically started, the time synchronization will occasionally be done after the Flask server has already been started. In this case, in order to avoid timing issues, raspiCamSrv will wait at startup until time synchronization is completed. The time shown here is the system time at the moment when the check for time synchronization was successful. raspiCamSrv analyzes the output of command ```timedatectl``` to check the system clock synchronization status. If this check fails or times out (60 sec), raspiCamSrv will start nevertheless. In this case, the information "System time not synced at raspiCamSrv start" will be shown here. If the server is running in a Docker container (see [Running raspiCamSrv as Docker Container](./SetupDocker.md)), the time is assumed to be synchronized and the check will be skipped. This is indicated through ![Container](img/Info-Container.jpg) *Software Stack* shows information on installed packages with Version (*Ver*) and the path from which the packages were loaded (*Loc*). ### Camera x The tab lists all cameras currently connected to the system. Each camera has an identifying number (0, 1, ...) shown in the title above each parameter list. The assignment of camera number to physical camera may change when CSI cameras are plugged into a different CSI port (Pi 5) or when USB cameras are plugged into a different USB port. When the server starts up, the first camera in the [list of cameras](#detection-of-cameras) is selected as active camera, unless a specific camera is activated when a [stored configuration](./SettingsConfiguration.md) is loaded at startup.. You may later switch to another camera on the [Settings](./Settings.md) screen or the [Multi Cam](./CamMulticam.md) screen The active camera is indicated in the list. The active camera will also be shown in the title bar of the application after log-in. For USB cameras, the device through which the camera is accessible is also shown (See [Detection of Cameras](#detection-of-cameras)). The information "(Not in use)" for a USB camera indicates that the camera has been detected by **raspiCamSrv** but it is not in use because USB cameras have been deactivated in the [Settings](./Settings_NoCam.md). In this case, the USB camera cannot be selected as active or second camera in the [Settings](./Settings.md) screen or the [Multi Cam](./CamMulticam.md) screen. #### Status *Current Status* shows the status of the camera: - open / closed - started / stopped - current [Sensor Mode](#sensor-modes) This is only shown for the currently active camera if it is started. If the Sensor Mode cannot currently be determined, 'unknown' is shown. The Sensor Mode is usually automatically selected by the camera and normally corresponds to the largest [Stream Size](./Configuration.md#stream-size-width-height), requested by one of the [Camera Configurations](./Configuration.md). - inactive
is shown for USB cameras which are currently not in use as active or second camera. - excluded
is shown for a USB camera which is, in principle, available for being used with **raspiCamSrv**, but currently excluded in the [Settings](./Settings.md) - not supported (OpenCV missing)
Is shown if a detected USB camera cannot be used within **raspiCamSrv** because OpenCV is not installed. See [Camera Status and Number of Threads](#camera-status-and-number-of-threads) Under *Tuning File*, you can see whether the Default or a custom tuning file are currently in use. See [raspiCamSrv Camera Tuning](./Tuning.md). #### AI Features This shows whether AI Features of a camera are available and active. Currently, this applies only to the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) with Sony IMX500 sensor. - Not Available
Indicates that the camera has no AI capabilities - Available
Indicates that the camera has AI capabilities and that AI features are enabled in the [Settings](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features).
Whether or not the camera is currently running a neural network model can be controlled in the [Camera AI Configuration](./Configuration_AI.md) - Disabled in Settings
Indicates that the camera has AI capabilities. However AI features are disabled in the [Settings](./Settings.md#activating-and-deactivating-the-use-of-camera-ai-features) #### Camera connected but not in the list? If you have a USB camera connected which does not show up in the list, you may have plugged in the camera while **raspiCamSrv** was running. In this case, you can use function [Reload Cameras](./SettingsConfiguration.md) to identify hot-plugged cameras. ### Streaming Clients ![Streaming Clients](./img/Info-StreamingClients.jpg) The tab lists the clients which are currently using one of the camera streams. Along with the IP address of the client, a list of streams is shown which the client is using: - *live_view*
[The Live View](./LiveScreen.md) stream
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLiveActive.jpg) - *video_feed*
The [video Stream](./CamWebcam.md#video-stream) for the active camera
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLiveActive.jpg) - *video_feed2*
The [video Stream](./CamWebcam.md#video-stream) for the second camera, if available
indicated by [Process Status Indicator](./UserGuide.md#process-status-indicators) ![indicator_live](./img/ProcessIndicatorLive2Active.jpg) ## Camera Properties ![Camera Properties](img/Info-CamProps.jpg) These are the properties of the camera which is currently active. (See [Determining Camera Properties for USB Cameras](#determining-camera-properties-for-usb-cameras)) ## Sensor Modes The camera system advertises the supported Sensor Modes with their characteristics. These are referred to within the [Camera Configuration](./Configuration.md). The characteristics vor every Sensor Mode are shown on an individual tab: ![Sensor Mode](img/Info_SensorMode.jpg) USB cameras may advertise a large number of sensor modes. In this case, the remaining buttons for sensor mode selection can be found in a drop-down list: ![Sensor Mode](img/Info_SensorModeUsb.jpg) ## Camera Status and Number of Threads The number of threads used by the server process depends on the status of the camera(s). - When all cameras are closed, there is just the server process and, in case of Bookworm systems, 2 threads which are started with the import of Picamera2. Thus, there is a minimum of three threads (1 for Bullseye). - Opening a camera starts additional threads which remain active while the camera is open. The number of threads may depend on the camera infrastructure specific for the operating system. - Starting a camera and/or starting an encoder starts additional threads depending on the chosen camera function and encoder. - **raspiCamSrv** also uses threads for background processes, such as live stream, video recording, photo series and motion detection. These ramain active while these processes are running. - Stopping and closing a camera will also stop the dependent threads and thus reduce the number of active threads. - If .mp4 video is currently recorded ([started manually](./Phototaking.md) or as an action within [motion capturing](./Trigger.md)), there will be an additional ffmpeg process with additional threads. - In case of .mp4 video recording with H264Encoder and FfmpegOutput there seems to be an issue with threads: In this case, there may be threads surviving when the encoder is stopped (see [picamera2 Issue #1023](https://github.com/raspberrypi/picamera2/issues/1023)). So, when .mp4 videos have been recorded, the number of threads may not go down to 3 (1 for Bullseye) after all camaras have been closed. Experience shows that such threads may survive for a longer time but typically, they show only minor or no CPU utilization. Often, they vanish after the camera has been closed after live stream has stopped. **raspiCamSrv** closes the camera in case it is not used: - When the [live stream](./LiveScreen.md) stops after 10 seconds of inactivity, the camera used for the live stream will be stopped and closed. - After [photos have been taken or videos have been recorded](Phototaking.md), the camera will be stopped and closed. - For [Photo Series](./PhotoSeries.md), the camera will be stopped and closed after a shot if the interval to the next shot is >60 sec. This does not apply to [Exposure Series](./PhotoSeriesExp.md) and [Focus Stacks](./PhotoSeriesFocus.md). - If [motion detection](./Trigger.md) is active, the live stream is kept activated which keeps the camera open and started. - In case of [Stereo Vision](./CamStereo.md), the live streams for both cameras are kept active, ## Detection of Cameras **raspiCamSrv** uses the ```Picamera2.global_camera_info()``` list (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), ch. 8.7) to identify the currently connected cameras. For each camera, the information provided by Picamera2 includes - ```Num```: The camera number by which a camera is identified within Picamera2 as well as in raspiCamSrv. - ```Model```: The model name of the camera, as advertised by the camera driver - ```Location```: A number reporting how the camera is mounted, as reported by libcamera. - ```Rotation```: How the camera is rotated for normal operation, as reported by libcamera - ```ID```: An identifier string for the camera, indicating how the camera is connected.
You can tell from this value whether the camera is accessed using I2C or USB. ## Identification of USB Cameras A camera is identified as USB camera, if ```usb``` is found in the ```ID```. In **raspiCamSrv**, USB cameras are accessed through [OpenCV](https://opencv.org/) rather than through Picamera2, which provides only very limited support for USB cameras. However, with OpenCV, a camera cannot be accessed through the Picamera2 camera number (```Num```). Instead, the ```/dev/videoX``` of the Linux kernel must be used. For mapping of the Picamera2 camera number (```Num```) to the device number, **raspiCamSrv** uses the following algorithm: Assuming that the ```ID``` is structured in the following way: e.g.: ```/base/axi/pcie@1000120000/rp1/usb@200000-2:1.0-046d:085c``` | Component | Meaning |---------------------------------|------------ | ```/base/axi/pcie@1000120000``` | Root of the system-on-chip’s PCIe controller | ```/rp1/usb@200000``` | The RP1 I/O controller’s USB host controller (i.e. USB root hub) | ```-2:1.0``` | USB device address and interface: port 2, interface 1.0 | ```-046d:085c``` | Vendor ID : Product ID (046d = Logitech, 085c = C922 Pro Stream Webcam) Now, with Video for Linux (V4L2), we can list all video devices: ```v4l2-ctl --list-devices``` reveals, for example: ``` ... rpi-hevc-dec (platform:rpi-hevc-dec): /dev/video19 /dev/media1 Logi 4K Stream Edition (usb-xhci-hcd.0-1): /dev/video2 /dev/video3 /dev/video4 /dev/video5 /dev/media4 C922 Pro Stream Webcam (usb-xhci-hcd.0-2): /dev/video0 /dev/video1 /dev/media3 ``` Each header within the list shows the camera's model and port (```(usb-xhci-hcd.0-2)``` indicates port 2) Now, by mapping model and port from the Picamera2 ```ID``` with corresponding information from ```v4l2-ctl```, we can identify the group for each USB camera. The first entry in the list of devices for this group is assigned to the camera number, assuming that this represents the main camera stream, whereas subsequent entries are for alternative functions. ## Determining Camera Properties for USB Cameras Whereas camera properties of CSI cameras are directly provided by Picamera2 (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), Appendix D), this is not the case for USB cameras. **raspiCamSrv** maps properties of USB cameras as far as possible to the Picamera2 datastructure for seamless integration of USB cameras. The USB camera properties are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --all``` giving: ``` Driver Info: Driver name : uvcvideo Card type : C922 Pro Stream Webcam Bus info : usb-xhci-hcd.0-2 Driver version : 6.12.47 Capabilities : 0x84a00001 Video Capture Metadata Capture Streaming Extended Pix Format Device Capabilities Device Caps : 0x04200001 Video Capture Streaming Extended Pix Format Media Driver Info: Driver name : uvcvideo Model : C922 Pro Stream Webcam Serial : A9382BFF Bus info : usb-xhci-hcd.0-2 Media version : 6.12.47 Hardware revision: 0x00000016 (22) Driver version : 6.12.47 Interface Info: ID : 0x03000002 Type : V4L Video Entity Info: ID : 0x00000001 (1) Name : C922 Pro Stream Webcam Function : V4L2 I/O Flags : default Pad 0x01000007 : 0: Sink Link 0x0200001f: from remote pad 0x100000a of entity 'Processing 3' (Video Pixel Formatter): Data, Enabled, Immutable Priority: 2 Video input : 0 (Camera 1: ok) Format Video Capture: Width/Height : 640/480 Pixel Format : 'YUYV' (YUYV 4:2:2) Field : None Bytes per Line : 1280 Size Image : 614400 Colorspace : sRGB Transfer Function : Rec. 709 YCbCr/HSV Encoding: ITU-R 601 Quantization : Default (maps to Limited Range) Flags : Crop Capability Video Capture: Bounds : Left 0, Top 0, Width 640, Height 480 Default : Left 0, Top 0, Width 640, Height 480 Pixel Aspect: 1/1 Selection Video Capture: crop_default, Left 0, Top 0, Width 640, Height 480, Flags: Selection Video Capture: crop_bounds, Left 0, Top 0, Width 640, Height 480, Flags: Streaming Parameters Video Capture: Capabilities : timeperframe Frames per second: 30.000 (30/1) Read buffers : 0 User Controls brightness 0x00980900 (int) : min=0 max=255 step=1 default=128 value=128 contrast 0x00980901 (int) : min=0 max=255 step=1 default=128 value=128 saturation 0x00980902 (int) : min=0 max=255 step=1 default=128 value=128 white_balance_automatic 0x0098090c (bool) : default=1 value=1 gain 0x00980913 (int) : min=0 max=255 step=1 default=0 value=0 power_line_frequency 0x00980918 (menu) : min=0 max=2 default=2 value=2 (60 Hz) 0: Disabled 1: 50 Hz 2: 60 Hz white_balance_temperature 0x0098091a (int) : min=2000 max=6500 step=1 default=4000 value=4000 flags=inactive sharpness 0x0098091b (int) : min=0 max=255 step=1 default=128 value=128 backlight_compensation 0x0098091c (int) : min=0 max=1 step=1 default=0 value=0 Camera Controls auto_exposure 0x009a0901 (menu) : min=0 max=3 default=3 value=3 (Aperture Priority Mode) 1: Manual Mode 3: Aperture Priority Mode exposure_time_absolute 0x009a0902 (int) : min=3 max=2047 step=1 default=250 value=250 flags=inactive exposure_dynamic_framerate 0x009a0903 (bool) : default=0 value=1 pan_absolute 0x009a0908 (int) : min=-36000 max=36000 step=3600 default=0 value=0 tilt_absolute 0x009a0909 (int) : min=-36000 max=36000 step=3600 default=0 value=0 focus_absolute 0x009a090a (int) : min=0 max=250 step=5 default=0 value=0 flags=inactive focus_automatic_continuous 0x009a090c (bool) : default=1 value=1 zoom_absolute 0x009a090d (int) : min=100 max=500 step=1 default=100 value=100 ``` By parsing this information, relevant data for camera properties can be retrieved and mapped to camera property elements. The ```PixelArraySize``` is determined as the maximum size of the Sensor Modes found (see [below](#determining-sensor-modes-for-usb-cameras)) ## Determining Sensor Modes for USB Cameras Whereas sensor modes of CSI cameras are directly provided by Picamera2 (see [Picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), ch. 4.2.2.3), this is not the case for USB cameras. **raspiCamSrv** maps video formats of USB cameras as far as possible to the Picamera2 sensor mode datastructure for seamless integration of USB cameras. The USB camera video formats are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --list-formats-ext``` giving: ``` ioctl: VIDIOC_ENUM_FMT Type: Video Capture [0]: 'YUYV' (YUYV 4:2:2) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) Size: Discrete 160x90 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ... Size: Discrete 2304x1536 Interval: Discrete 0.500s (2.000 fps) [1]: 'MJPG' (Motion-JPEG, compressed) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ... Size: Discrete 1920x1080 Interval: Discrete 0.033s (30.000 fps) Interval: Discrete 0.042s (24.000 fps) Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Interval: Discrete 0.100s (10.000 fps) Interval: Discrete 0.133s (7.500 fps) Interval: Discrete 0.200s (5.000 fps) ``` From this output the list of sensor modes is generated with information on Size and Format. The FPS, stored for each sensor mode is the maximum value of fps found for each format. ## Determining supported Controls Every USB Camera advertises a list of controls which can be used to adjust focus or image appearance. Supported controls are determined with the v4l2 through (e.g.): ```v4l2-ctl --device=/dev/video12 --list-ctrls``` giving: ``` User Controls brightness 0x00980900 (int) : min=0 max=255 step=1 default=128 value=128 contrast 0x00980901 (int) : min=0 max=255 step=1 default=128 value=128 saturation 0x00980902 (int) : min=0 max=255 step=1 default=128 value=128 white_balance_automatic 0x0098090c (bool) : default=1 value=0 gain 0x00980913 (int) : min=0 max=255 step=1 default=0 value=0 power_line_frequency 0x00980918 (menu) : min=0 max=2 default=2 value=2 (60 Hz) white_balance_temperature 0x0098091a (int) : min=2000 max=7500 step=10 default=4000 value=3000 sharpness 0x0098091b (int) : min=0 max=255 step=1 default=128 value=128 backlight_compensation 0x0098091c (int) : min=0 max=1 step=1 default=1 value=1 Camera Controls auto_exposure 0x009a0901 (menu) : min=0 max=3 default=3 value=3 (Aperture Priority Mode) exposure_time_absolute 0x009a0902 (int) : min=3 max=2047 step=1 default=250 value=312 flags=inactive exposure_dynamic_framerate 0x009a0903 (bool) : default=0 value=0 pan_absolute 0x009a0908 (int) : min=-36000 max=36000 step=3600 default=0 value=0 tilt_absolute 0x009a0909 (int) : min=-36000 max=36000 step=3600 default=0 value=0 focus_absolute 0x009a090a (int) : min=0 max=255 step=5 default=0 value=10 flags=inactive focus_automatic_continuous 0x009a090c (bool) : default=1 value=1 zoom_absolute 0x009a090d (int) : min=100 max=500 step=1 default=100 value=100 ``` **raspiCamSrv** analyzes this list with respect to a limited set of controls and registers - control name - value data type - minimum value - maximum value - default value For the active camera, this information is appended to the Camera Controls data structure (see [Server Configuration Storage](./SettingsConfiguration.md#server-configuration-storage)), which will also be transferred to the Streaming Configuration in case of multiple cameras. In order to establish this data structure, USB cameras need to be activated once as *Active Camera*. The controls information is cused to customize the [Camera Controls](./CameraControls_UsbCams.md) screens when a USB camera is the *Active Camera*. ================================================ FILE: docs/ZoomPan.md ================================================ # raspiCamSrv Zoom & Pan [![Up](img/goup.gif)](./CameraControls.md) ![ZoomAndPan](img/Zoom.jpg) This tab allows zooming and panning the image area within the dimensions supported by the camera pixel array size and Sensor Modes. For more details, see [Image Cropping and Sensor Modes](./ScalerCrop.md) **NOTE**: When zoom or pan/tilt changes the cropping window of the Live View, [Regions of Interest](./TriggerMotion.md#regions-of-interest-and-regions-of-no-interest) which may heve been defined for [Motion Capturing](./TriggerMotion.md) may be adjusted to the new cropping window. ## Current zoom factor in % This value shows the current zoom factor. It cannot be modified manually but only through the **Zoom in**, **Zoom out** or **Full** buttons. The value is given in % of the [ScalerCrop Default](./ScalerCrop.md#cropping) size. ## Zoom & pan step in % This value can be adjusted. It specifies the step size by which every click on **Zoom in** or **Zoom out** will change the *Current zoom factor*. ## Current ScalerCrop (Zoom) This rectangle, given in pixels, specifies the ScalerCrop rectangle which will be requested as part of the [Camera Controls](./CameraControls.md). The rectangle is given as tuple (x_offset, y_offset, width, height). When drawing the zoom window (see [below](#graphically-setting-the-zoom-window)), the rectangle parameters will be updated. After Submitting, the entry should be identical to the *Current ScalerCrop (Live View)*. ## Current ScalerCrop (Live View) This shows the scaler crop rectangle which is currently active for the live view. The *Current ScalerCrop (Live View)* is the base for determining offset and size of the *Autofocus Windows* when drawing rectangles on the canvas (see [Focus](./FocusHandling.md)). The *Current ScalerCrop (Live View)* is also the base for [Regions of Interest or Regions of No Interest](./TriggerMotion.md#regions-of-interest-and-regions-of-no-interest), which can be defined for [Motion Capturing](./TriggerMotion.md). ## Zoom The following buttons allow zooming: - Zoom in zooms into the image (reduces the viewport), keeping the center. - Zoom out zooms out (enlarges the viewport), keeping the center. If the image had been panned before, the center will be shifted, if necessary, to keep the ScalerCrop rectangle within the [ScalerCrop Maximum](./ScalerCrop.md#cropping) area. - Full Zooms to 100% keeping the center, unless a shift is required to keep the ScalerCrop rectangle within the [ScalerCrop Maximum](./ScalerCrop.md#cropping) area. ## Pan Panning can be done with the following buttons: - Pan up / Pan down Move the ScalerCrop rectangle up/down until the upper/lower border of the [ScalerCrop Maximum](./ScalerCrop.md#cropping) rectangle is reached. - Pan left / Pan right Move the ScalerCrop rectangle left/right until the left/right border of the [ScalerCrop Maximum](./ScalerCrop.md#cropping) rectangle is reached. - Center Move the ScalerCrop rectangle to the center of the [ScalerCrop Maximum](./ScalerCrop.md#cropping) rectangle, keeping the zoom factor. - Default Set the ScalerCrop rectangle to the [ScalerCrop Default](./ScalerCrop.md#cropping) rectangle. ## Graphically setting the Zoom Window Pushing the **Draw** button will switch into graphical mode where the zoom window can be drawn on a canvas over the Live Stream area. All other buttons, except **Full** will be disabled in this mode. ![ZoomGraphically](img/Zoom_Graph.jpg) **Attention:** With Safari (e.g. on an iPad), due to the issue with onload events, the canvas will not be directly visible. It needs to trigger window resize by shortly 'pulling' down the window. While drawing a rectangle for the intended image section, the original aspect ratio will be preserved. After drawing is finished, the *Current ScalerCrop (Zoom)* will be updated with offset and dimensions of the zoom window. After pressing **Draw**, the button has changed to **Submit** which must be pressed in order to apply the ScalerCrop setting to the preview and store it for later photo or video taking. Pressing **Submit** terminates the graphic mode. When the **Full** button is pressed in the graphic mode, the dialog returns to normal mode without applying a previously drawn zoom window. ================================================ FILE: docs/api/postman/raspiCamSrv.postman_collection.json ================================================ { "info": { "_postman_id": "5f679e3f-97eb-491c-8265-7ebfe598603c", "name": "raspiCamSrv", "description": "API for Raspberry Pi Camera Server (raspiCamSrv) [https://github.com/signag/raspi-cam-srv](https://github.com/signag/raspi-cam-srv)\n\nSecurity: JSON Web Tokens (JWT)", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "36794475" }, "item": [ { "name": "api login", "event": [ { "listen": "test", "script": { "exec": [ "let response = pm.response.json();\r", "pm.collectionVariables.set(\"access_token\", response.access_token);\r", "pm.collectionVariables.set(\"refresh_token\", response.refresh_token);" ], "type": "text/javascript", "packages": {} } } ], "protocolProfileBehavior": { "disabledSystemHeaders": {} }, "request": { "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"username\": \"{{user}}\",\r\n \"password\": \"{{pwd}}\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/login", "host": [ "{{base_url}}" ], "path": [ "api", "login" ] }, "description": "Client login.\n\nReturns: Access Token and Refresh Token" }, "response": [] }, { "name": "api refresh", "event": [ { "listen": "test", "script": { "exec": [ "let response = pm.response.json();\r", "pm.collectionVariables.set(\"access_token\", response.access_token);\r", "" ], "type": "text/javascript", "packages": {} } } ], "protocolProfileBehavior": { "disabledSystemHeaders": {} }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{refresh_token}}", "type": "string" } ] }, "method": "POST", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/refresh", "host": [ "{{base_url}}" ], "path": [ "api", "refresh" ] }, "description": "Refresh of Access Token\n\nAuthentication: Refresh Token\n\nResponse: Access Token" }, "response": [] }, { "name": "api protected", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/protected", "host": [ "{{base_url}}" ], "path": [ "api", "protected" ] }, "description": "Dummy API for testing purposes" }, "response": [] }, { "name": "api take_photo", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_photo", "host": [ "{{base_url}}" ], "path": [ "api", "take_photo" ] }, "description": "Take photo with active camera" }, "response": [] }, { "name": "api take_photo 2", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_photo2", "host": [ "{{base_url}}" ], "path": [ "api", "take_photo2" ] }, "description": "Take photo with second (non-active) camera" }, "response": [] }, { "name": "api take_photo both", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_photo_both", "host": [ "{{base_url}}" ], "path": [ "api", "take_photo_both" ] }, "description": "Take photos simultaneously with both cameras.\n\nThe photos will have the same file name, but are stored in camera-specific subfolders." }, "response": [] }, { "name": "api take_raw_photo", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_raw_photo", "host": [ "{{base_url}}" ], "path": [ "api", "take_raw_photo" ] }, "description": "Take raw photo with active camera.\n\nIn addition to the raw photo, also a normal photo is stored." }, "response": [] }, { "name": "api take_raw_photo2", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_raw_photo2", "host": [ "{{base_url}}" ], "path": [ "api", "take_raw_photo2" ] }, "description": "Take raw photo with second (non-active) camera.\n\nIn addition to the raw photo, also a normal photo is stored." }, "response": [] }, { "name": "api take_raw_photo both", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/take_raw_photo_both", "host": [ "{{base_url}}" ], "path": [ "api", "take_raw_photo_both" ] }, "description": "Take raw photos simultaneously with both cameras.\n\nThe photos will have the same file name, but are stored in camera-specific subfolders.\n\nIn addition to the raw photo, also a normal photo is stored." }, "response": [] }, { "name": "api record video", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"duration\": {{video_duration}}\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video", "host": [ "{{base_url}}" ], "path": [ "api", "record_video" ] }, "description": "Record video with fixed duration using the active camera.\n\nData: video duration." }, "response": [] }, { "name": "api record video until stop", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video", "host": [ "{{base_url}}" ], "path": [ "api", "record_video" ] }, "description": "Start recording a video with active camera.\n\nRecording must be stopped with **api stop video.**" }, "response": [] }, { "name": "api stop video", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/stop_video", "host": [ "{{base_url}}" ], "path": [ "api", "stop_video" ] }, "description": "Stop video recording with the active camera." }, "response": [] }, { "name": "api record video 2", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"duration\": {{video_duration}}\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video2", "host": [ "{{base_url}}" ], "path": [ "api", "record_video2" ] }, "description": "Record video with fixed duration using the second (non-active) camera.\n\nData: video duration." }, "response": [] }, { "name": "api record video 2 until stop", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video2", "host": [ "{{base_url}}" ], "path": [ "api", "record_video2" ] }, "description": "Start recording a video with the second (non-active) camera.\n\nRecording must be stopped with **api stop video2.**" }, "response": [] }, { "name": "api stop video 2", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/stop_video2", "host": [ "{{base_url}}" ], "path": [ "api", "stop_video2" ] }, "description": "Stop recording a video with the second camera." }, "response": [] }, { "name": "api record video both", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"duration\": {{video_duration}}\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video_both", "host": [ "{{base_url}}" ], "path": [ "api", "record_video_both" ] }, "description": "Simultaneously record fixed-length videos with both cameras.\n\nThe videos will get the same name but will be stored in camera-specific subdirectories.\n\nData: video duration." }, "response": [] }, { "name": "api record video both until stop", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/record_video_both", "host": [ "{{base_url}}" ], "path": [ "api", "record_video_both" ] }, "description": "Start simultaneously recording videos with both cameras.\n\nThe videos will get the same name but will be stored in camera-specific subdirectories." }, "response": [] }, { "name": "api stop video both", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczODk1Nzc1MSwianRpIjoiNDNiMDM0MWEtNjk3Zi00MjFlLTkwNmUtZjgxMTJlY2VlNzI0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNuIiwibmJmIjoxNzM4OTU3NzUxLCJjc3JmIjoiZTlmZDZmY2UtYjA2NS00NzUwLTk4NTQtNDBiM2ZhNjZkNGUzIiwiZXhwIjoxNzM4OTYxMzUxfQ.P3bXCCEoZGIwR9l-00l4xwuQYR05Q9ELqC66DhqCX7M", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/stop_video_both", "host": [ "{{base_url}}" ], "path": [ "api", "stop_video_both" ] }, "description": "Stop recording videos with both cameras" }, "response": [] }, { "name": "api switch cameras", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/switch_cameras", "host": [ "{{base_url}}" ], "path": [ "api", "switch_cameras" ] }, "description": "Switch cameras for systems with 2 cameras." }, "response": [] }, { "name": "api start motion detection", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/start_triggered_capture", "host": [ "{{base_url}}" ], "path": [ "api", "start_triggered_capture" ] }, "description": "Start motion detection" }, "response": [] }, { "name": "api stop motion detection", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/stop_triggered_capture", "host": [ "{{base_url}}" ], "path": [ "api", "stop_triggered_capture" ] }, "description": "Stop motion detection" }, "response": [] }, { "name": "api info", "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "url": { "raw": "{{base_url}}/api/info", "host": [ "{{base_url}}" ], "path": [ "api", "info" ] }, "description": "Get status information from server:\n\n```\n{\n \"message\": {\n \"active_camera\": \"Camera 0 (imx708)\",\n \"cameras\": [\n {\n \"active\": true,\n \"is_usb\": false,\n \"model\": \"imx708\",\n \"num\": 0,\n \"status\": \"open - started - current Sensor Mode: 2\"\n },\n {\n \"active\": false,\n \"is_usb\": false,\n \"model\": \"imx477\",\n \"num\": 1,\n \"status\": \"closed\"\n }\n ],\n \"operation_status\": {\n \"audio_recording\": false,\n \"event_handling\": false,\n \"livestream2_active\": false,\n \"livestream_active\": true,\n \"motion_capturing\": false,\n \"photoseries_recording\": false,\n \"video_recording\": false,\n \"video_recording2\": false\n },\n \"server\": \"raspi06:5000\",\n \"version\": \"raspiCamSrv V3.6.0\"\n }\n}\n\n ```" }, "response": [] }, { "name": "api probe", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{access_token}}", "type": "string" } ] }, "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"properties\": [\r\n {\r\n \"property\": \"Camera().event\"\r\n },\r\n {\r\n \"property\": \"Camera().event2\"\r\n },\r\n {\r\n \"property\": \"Camera().last_access\"\r\n },\r\n {\r\n \"property\": \"Camera().last_access2\"\r\n },\r\n {\r\n \"property\": \"Camera().threadLock.locked()\"\r\n },\r\n {\r\n \"property\": \"Camera().thread2Lock.locked()\"\r\n },\r\n {\r\n \"property\": \"CameraCfg().serverConfig.error\"\r\n },\r\n {\r\n \"property\": \"CameraCfg().serverConfig.error2\"\r\n },\r\n {\r\n \"property\": \"CameraCfg().serverConfig.errorc2\"\r\n },\r\n {\r\n \"property\": \"CameraCfg().serverConfig.errorc22\"\r\n }\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{base_url}}/api/probe", "host": [ "{{base_url}}" ], "path": [ "api", "probe" ] }, "description": "Probe a set of object properties.\n\nYou need to specify an object through one of the singleton base classes (Camera(), CameraCfg(), MotionDetector(), PhotoSeriesCfg() or TriggerHandler()) and then specify valid properties with dot-notation.\n\nNote: Not all properties might be JSON-serializable." }, "response": [] } ], "event": [ { "listen": "prerequest", "script": { "type": "text/javascript", "packages": {}, "exec": [ "" ] } }, { "listen": "test", "script": { "type": "text/javascript", "packages": {}, "exec": [ "" ] } } ], "variable": [ { "key": "base_url", "value": "", "type": "default" }, { "key": "user", "value": "", "type": "default" }, { "key": "pwd", "value": "", "type": "default" }, { "key": "access_token", "value": "", "type": "default" }, { "key": "refresh_token", "value": "", "type": "default" }, { "key": "video_duration", "value": "30", "type": "default" } ] } ================================================ FILE: docs/bp_Hotspot_Bookworm.md ================================================ # Hotspot Configuration for 'Bookworm' OS [![Up](img/goup.gif)](./bp_PiZero_Standalone.md) This section describes how to configure a Raspberry Pi as hotspot if the OS is *Debian Bookworm*. In the following description, you will need to replace - `````` with the intended hotspot SSID, e.g. "RaspiCamSrv01" - `````` with the passphrase to protect hotspot access The connection ID, used in [NetworkManager](https://networkmanager.dev/docs/api/latest/nmcli.html) commands is chosen as "RaspiCamSrv" ## 1. Install required packages ``` sudo apt install dnsmasq iptables ``` ## 2. Configure Hotspot ``` sudo nmcli con add type wifi ifname wlan0 con-name RaspiCamSrv autoconnection yes ssid sudo nmcli con modify RaspiCamSrv 802-11-wireless.mode ap 802-11-wireless.band bg sudo nmcli con modify RaspiCamSrv wifi-sec.key-mgmt wpa-psk sudo nmcli con modify RaspiCamSrv wifi-sec.psk "" ``` ## 3. Assign a Fixed IP Address ``` sudo nmcli con modify RaspiCamSrv ipv4.method manual ipv4.addresses 192.168.1.1/24 sudo nmcli con modify RaspiCamSrv ipv4.gateway 192.168.1.1 sudo nmcli con modify RaspiCamSrv ipv4.dns 192.168.1.1 ``` ## 4. Activate Hotspot ``` sudo nmcli con up RaspiCamSrv ``` If your SSH session uses the Wi-Fi Adapter, connection will now be lost. If you reconnect, the ethernet adapter will be used. At this time, a TCP/IP connection through the hotspot is not yet possible. ## 5. Configure DHCP for hotspot ``` sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.orig sudo nano /etc/dnsmasq.conf ``` Enter the following code: ``` interface=wlan0 no-dhcp-interface=eth0 dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,24h dhcp-option=option:router,192.168.1.1 dhcp-option=option:dns-server,192.168.1.1 ``` ## 6. Check and start DHCP Server and DNS-Cache ``` dnsmasq --test -C /etc/dnsmasq.conf ``` ## 7. Enable dnsmasq for automatic start ``` sudo systemctl restart dnsmasq sudo systemctl status dnsmasq sudo systemctl enable dnsmasq ``` ## 8. Enable IP Forwarding ``` sudo nano /etc/sysctl.conf ``` Find and uncomment the following line: ``` net.ipv4.ip_forward=1 ``` ## 9. Set up NAT ``` sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE sudo iptables -A FORWARD -i eth0 -o wlan0 -m state --state RELATED,ESTABLISHED -j ACCEPT sudo iptables -A FORWARD -i wlan0 -o eth0 -j ACCEPT ``` ## 10. Save Firewall Rules ``` sudo sh -c "iptables-save > /etc/iptables.rules" ``` ## 11. Load firewall rules on boot ``` sudo nano /etc/network/interfaces ``` Add the following line: ``` post-up iptables-restore < /etc/iptables.rules ``` ## 12. Extend hosts ``` sudo nano /etc/hosts ``` Add the following line: ``` 192.168.1.1 ``` where `````` must be replaced by the host name specified during OS setup. ## 13. Reboot the system ``` sudo reboot ``` ## 14. Check processes After reconnecting with SSH ``` nmcli con show --active ip addr show wlan0 ``` ## 19. Test Hotspot access - Unplug the network cable - Switch Off/On the power supply - From a mobile device, wait for the hotspot and try to connect. ================================================ FILE: docs/bp_Hotspot_Bullseye.md ================================================ # Hotspot Configuration for 'Bullseye' OS [![Up](img/goup.gif)](./bp_PiZero_Standalone.md) This section describes how to configure a Raspberry Pi as hotspot if the OS is *Debian Bullseye*. ## 1. Install required packages ``` sudo apt install dnsmasq hostapd iptables ``` ## 2. Configure WLAN ``` sudo nano /etc/dhcpcd.conf ``` Copy/Paste the following code: ``` interface wlan0 static ip_address=192.168.1.1/24 nohook wpa_supplicant ``` This will configure the Wi-Fi adapter with a static IP address 192.168.1.1 ## 3. Restart DHCP ``` sudo systemctl restart dhcpcd ``` If your client is connected through Wi-Fi, it will now lose connection and you need to reconnect, which will now use the ethernet connection. ## 4. Check interfaces ``` ip l ``` Check that both, the ethernet interface (eth0) and Wi-Fi adapter (wlan0) are available. ## 5. Setup DHCP server and DNS-Cache ``` sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf_orig sudo nano /etc/dnsmasq.conf ``` Enter the following code: ``` interface=wlan0 no-dhcp-interface=eth0 dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,24h dhcp-option=option:dns-server,192.168.1.1 ``` ## 6. Test & start DHCP server and DNS Cache ``` dnsmasq --test -C /etc/dnsmasq.conf ``` The response should be ``` dnsmasq: syntax check OK. ``` ## 7. Restart DNSMASQ and enable it for automatic start ``` sudo systemctl restart dnsmasq sudo systemctl status dnsmasq sudo systemctl enable dnsmasq ``` ## 8. Setup WLAN-AP-Host (hostapd) ``` sudo nano /etc/hostapd/hostapd.conf ``` Replace the content with the following code ``` interface=wlan0 ssid= channel=1 hw_mode=g ieee80211n=1 ieee80211d=1 country_code= wmm_enabled=1 auth_algs=1 wpa=2 wpa_key_mgmt=WPA-PSK rsn_pairwise=CCMP wpa_passphrase= ``` where you need to replace - with the intended SSID for the hotspot (e.g.: RaspiCamSrv) - with your [A-2 ISO 3166-1 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) - with the passphrase to secure hotspot access ## 9. Test & start WLAN-AP-Host ``` sudo hostapd -dd /etc/hostapd/hostapd.conf ``` At the end of the output you should find: ``` ... wlan0: interface state COUNTRY_UPDATE->ENABLED ... wlan0: AP-ENABLED ... ``` ## 10. Test Hotspot Access With a mobile device try to access the hotspot. This will generate log output in the SSH session. ## 11. Enable hostapd process for automatic start In the SSH session stop the active process
``` sudo systemctl unmask hostapd sudo systemctl start hostapd sudo systemctl enable hostapd ``` ## 12. Check that hostapd is active ``` sudo systemctl status hostapd ``` ## 13. Activate routing ``` sudo nano /etc/sysctl.conf ``` Find and uncomment the following line: ``` net.ipv4.ip_forward=1 ``` ## 14. Activate NAT ``` sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE sudo sh -c "iptables-save > /etc/iptables.ipv4.nat" ``` ## 15. Assure NAT activation at system start ``` sudo nano /etc/rc.local ``` Before the last line with ```exit 0``` enter ``` iptables-restore < /etc/iptables.ipv4.nat ``` ## 16. Reboot the system ``` sudo reboot ``` ## 18. Check processes After reconnecting with SSH ``` sudo systemctl status hostapd ps ax | grep hostapd sudo systemctl status dnsmasq ps ax | grep dnsmasq ``` ## 19. Test Hotspot access - Unplug the network cable - Switch Off/On the power supply - From a mobile device, wait for the hotspot and try to connect. ================================================ FILE: docs/bp_Hotspot_Trixie.md ================================================ # Hotspot Configuration for *Trixie* OS [![Up](img/goup.gif)](./bp_PiZero_Standalone.md) This section describes how to configure a Raspberry Pi as a **standalone Wi-Fi hotspot** using **NetworkManager**, as provided by default in *Debian Trixie / Raspberry Pi OS (Trixie)*. **Note**: This configuration is **not compatible with older Bookworm-style setups** that relied on `dnsmasq`, `iptables`, or `/etc/network/interfaces`. --- ## Overview NetworkManager provides built-in support for: - Access Point (AP) mode - DHCP server for hotspot clients - NAT (masquerading) - IP forwarding No additional network services or firewall rules are required for a basic hotspot. ## Naming Conventions In the following description, replace: - `` with the intended hotspot SSID e.g. `RaspiCamSrv01` - `` with the WPA2 passphrase The NetworkManager **connection ID** used throughout this document is: RaspiCamSrv ## 1. Create the hotspot connection ``` sudo nmcli con add type wifi ifname wlan0 con-name RaspiCamSrv ssid \ connection.autoconnect yes ``` ## 2. Configure hotspot parameters Enable Access Point mode and WPA2 security: ``` sudo nmcli con modify RaspiCamSrv \ 802-11-wireless.mode ap \ 802-11-wireless.band bg \ wifi-sec.key-mgmt wpa-psk \ wifi-sec.psk "" ``` (Optional) Lock the Wi-Fi channel for improved stability: ``` sudo nmcli con modify RaspiCamSrv 802-11-wireless.channel 6 ``` ## 3. Enable shared IPv4 networking (recommended) ``` sudo nmcli con modify RaspiCamSrv ipv4.method shared ``` This automatically enables: - DHCP for hotspot clients - NAT (masquerading) - IPv4 forwarding ## 4. Activate the hotspot ```bash sudo nmcli con up RaspiCamSrv ``` **NOTE**: If your SSH session uses Wi-Fi, the connection will now be lost. Reconnection will use Ethernet. ## 5. Verify hotspot status Check active connections: ```bash nmcli con show --active ``` Check IP address assigned to the hotspot interface: ```bash ip addr show wlan0 ``` You should see an address in the range assigned by NetworkManager (e.g. 10.42.0.1/24 or similar) ## 6. Setup mDNS To provide host name resolution to clients, [mDNS (Multicast DNS)](https://en.wikipedia.org/wiki/Multicast_DNS) can be used instead of a full-fledged DNS server. Install and enable [Avahi](https://en.wikipedia.org/wiki/Avahi_(software)): ``` sudo apt install avahi-daemon sudo systemctl enable avahi-daemon sudo systemctl start avahi-daemon ``` With Avahi, you will need to connect to the Raspberry Pi with ```.local``` instead of `````` or IP address, where `````` is the name given during [OS Installation](./bp_PiZero_Standalone.md#1-install-os-on-microsd-card) ## 7. Test hotspot access 1. Disconnect the Ethernet cable 2. Power-cycle the Raspberry Pi 3. From a mobile device: -- Search for the Wi-Fi network `````` -- Connect using the configured passphrase -- Verify internet or local access -- With a Ping tool, try to ping ```.local``` ================================================ FILE: docs/bp_PiZero_Standalone.md ================================================ # Setup of Raspberry Pi Zero as Standalone System [![Up](img/goup.gif)](./getting_started_overview.md) This section describes how to set up a Raspberry Pi **Zero W** or **Zero 2 W** as standalone system. This will allow you placing the Raspi camera anywhere, independently from an accessible Wi-Fi. It will act as hotspot to which you can connect from a mobile client to gain access to **raspiCamSrv**. The subsequent descriptions can, in principle, also be applied for other Raspberry Pi models. ## Headless Setup During the setup of such a system, the Raspberry Pi requires a cabled network connection because the Wi-Fi adapter will be configured as hotspot and is, therefore, not available for access by the configuration client. Connections to a display, keyboard and mouse are not required. ![Standalone Setup](./img/bp_PiZero_Connect.jpg) Required [cables and adapters](https://www.raspberrypi.com/products/#power-supplies-and-cables): - USB A to Ethernet adapter - Micro USB/Male to USB A/Female cable - Power supply with Micro USB plug ## 1. Install OS on microSD Card Follow the instructions for [Install using Imager](https://www.raspberrypi.com/documentation/computers/getting-started.html#raspberry-pi-imager). - **Model**
Make sure to select the correct model - **Bullseye, Bookworm or Trixie?**
raspiCamSrv can be used with all of these systems.
Unless other reasons force taking one of the older OS, it is recommended to use the officially recommended, which is currently Trixie. - **Full or lite system?**
It is recommended to install the full system although the desktop environment will not be required. However, raspiCamSrv can also be installed on Lite variants. - **OS Customisation**
Make sure to *Configure wireless LAN*, although the Wi-Fi Adapter will later not be run in client mode,
however this will assure that the *Wireless LAN Country* will be set. ## 2. Power Up 1. If camera applications are intended, connect the camera (the small CSI-2 port of Pi Zero and Pi 5 require a special cable) 2. Insert the microSD card into the card slot 3. If available, encapsulate the Pi into a case 4. Connect the network USB cable through an ethernet adapter to a switch of your local network 5. Connect the power supply ## 3. Connect and upgrade The system may take some time until it is visible within the network. From a client device connect via SSH
``` ssh @ ... sudo apt update sudo apt full-upgrade ``` ## 4. Configure Hotspot The process of configuration is slightly different, depending on the cosen OS: - [Hotspot Configuration for 'Trixie' OS](./bp_Hotspot_Trixie.md) - [Hotspot Configuration for 'Bookworm' OS](./bp_Hotspot_Bookworm.md) - [Hotspot Configuration for 'Bullseye' OS](./bp_Hotspot_Bullseye.md) ## 5. Install raspiCamSrv For installation, you will need to connect through ethernet. Then, follow the [raspiCamSrv Installation Procedure](./installation.md), which will also do the Service configuration. ## 6. Test After rebooting, first test using the client connected with ethernet cable. If this is successfull, you can shutdown and unplug the ethernet cable. After restart, connect from a mobile client to the hotspot and connect to raspiCamSrv from a browser window. **NOTE**, that for the [Trixie setup](./bp_Hotspot_Trixie.md), you need to use ```.local``` instead of ```hostname```. ## Updating a Stanalone RaspiCamSrv System Since the standalone system has no internet connection, you will not be notified on new **raspiCamSrv** versions (see [Update notification through coloured version number](./UserGuide.md#title-bar)). When you are aware of an update, you need to connect the standalone Raspberry Pi to a network with internet access using an ethernet cable. Then, you can use the [Update function](./SettingsUpdate.md) to check for updates and for updating **raspiCamSrv**. ================================================ FILE: docs/features.md ================================================ # Features V4.10.x [![Up](img/goup.gif)](./index.md) For more details, see the [User Guide](./UserGuide.md). ![Live Overview](./img/Live.jpg) ## Feature Overview ### Platform Support - raspiCamSrv can be run on all currenly known Raspberry Pi **hardware platforms** (except microcontroller boards) from Raspberry [Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) to [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) - Supported **Operating Systems** are the **Raspberry Pi** OS versions Bullseye, Bookworm and Trixie. - The recommended variant for all of these is the full 64-Bit variant recommended by [**Raspberry Pi** Imager](https://www.raspberrypi.com/software/) - Setup can be done alternatively using an [automatic installer](./installation.md) or by [deployment as Docker Container](./SetupDocker.md) ### WSGI Server Support - raspiCamSrv is based on [Flask](https://flask.palletsprojects.com/) and, as such, a [WSGI](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) (Web Server Gateway Interface) application. It requires a [WSGI Server](https://www.fullstackpython.com/wsgi-servers.html) as middleware between the WSGI application and a Web Server. - raspiCamSrv can run with the [Werkzeug](https://werkzeug.palletsprojects.com/) WSGI server built-in in Flask. This is usually used for development and testing purposes, but it is also fine for private environments. - For publicly accessible systems, raspiamSrv can be run with [Gunicorn](https://gunicorn.org/) ('Green Unicorn') with [specific settings](./installation_man.md#gunicorn-settings). The [automatic installer](./installation.md) as well as the [Docker Image](https://hub.docker.com/repository/docker/signag/raspi-cam-srv/general) use this as default. ### Camera Support - raspiCamSrv supports the currently available [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/accessories/camera.html). - With the [Raspberry Pi AI Camera](https://www.raspberrypi.com/documentation/accessories/ai-camera.html) (imx500), you can study inference of neural network models for specific tasks (Classification, Object Detection, Pose Estimation, Segmentation) within the Web UI of **raspiCamSrv** (see [AI Camera Support](./AiCameraSupport.md)). - CSI Cameras from other providers can be used as long as they are supported by Picamera2. - USB cameras connected through the Pi's USB ports are seamlessly integrated, however control options are limited, depending on their capabilities. ### Camera Management - raspiCamSrv can detect and use all **CSI** and **USB** cameras connected to a Raspberry Pi, as long as they are identified by Picamera2, which is usually the case. - One of these cameras must be selected as *Active Camera*. [Camera configuration](./Configuration.md) (e.g. stream size colour space or flipping) as well as [controls](./CameraControls.md) (e.g. focus, zoom/pan/tilt, exposure- and image-control) can be actively modified only for the *Active Camera*. - Another camera, if available, can be selected as *Second Camera* by [Multi Camera Control](./CamMulticam.md). - All settings for the *Active Camera* can be preserved before it is replaced by another camera (e.g. by switching cameras). They will be restored/applied when this camera is set as *Active Camera* or as *Second Camera*. - Function [Reload Cameras](./SettingsConfiguration.md) allows hot plug-in/-out of USB cameras without server restart ### Camera Configuration - raspiCamSrv supports all [camera configuration options](./Configuration.md#configuration-tab) which are foreseen by Picamera2. - Individual configuration sets can be specified for 4 different use-cases: Live View, Photo, Raw Photo and Video. - Before the camera is started, raspiCamSrv configures all three camera streams (lowres, main, raw) for the most likely use-cases. This allows to keep the live stream (lowres) active while the camera is being used for phototaking (main), raw photo taking (raw) or video recording (main) - If necessary, specific applications can request the camera for exclusive use. - Support of Camera [Tuning](./Tuning.md) by selection and management of tuning files. ### Camera Control - raspiCamSrv supports all [Camera Control options](./CameraControls.md) foreseen by Picamera2. - [Focus control](./FocusHandling.md) if supported by the camera (e.g. camera module 3 or specific USB cameras). - Graphically drawing *Autofocus Windows* for CSI cameras. - [Pan / Tilt / Zoom](./ZoomPan.md) for CSI as well as for USB cameras. - [Auto Exposure Control](./CameraControls_AutoExposure.md) for CSI cameras. - [Exposure Control](./CameraControls_Exposure.md) for CSI cameras. - [Image Control](./CameraControls_Image.md) for CSI cameras as well as for USB cameras (if supported by the camera). - **NEW**: Automatic White Balance using Nueral Networks can be activated through [Modification of Tuning Files](./tutorials/AWB_with_neural_networks.md) - Panel for [Direct Control](./LiveDirectControl.md) of numeric control parameters. - Configurable [Live View buttons](./CameraControls_Ctrl.md) to be used for physical pan/tilt, light or control of other devices ### Photo Taking / Video Recording - Taking [Photos / Raw Photos](./Phototaking.md). - Recording [Video](./Phototaking.md#video). - Recording [Audio along with the Video](./Settings.md#recording-audio-along-with-video). - Photo/Video [metadata](./Phototaking.md#metadata) display. - Photo [histogram](./Phototaking.md) generation and display. - [Display buffer](./Phototaking.md#photo-display) for comparison of photos and metadata/histogram. - [Photo Viewer](./PhotoViewer.md) - [Photo Download](./PhotoViewer.md) - Photos/videos are enabled for being inspected in a separate [Media Viewer](./UserGuide.md#media-viewer) window. ### Streaming - Endpoint for [streaming](./CamWebcam.md) (MJPEG) the active camera. - Endpoint for [streaming](./CamWebcam.md) the second camera. - Endpoints for photo snapshots of active and second camera with low resolution. - Endpoints for photo snapshots of active and second camera with high resolution. - Option for activating / deactivating authentication for streaming and snapshots. ### Multi-Camera Features - [Selection](./CamMulticam.md) of *Active Camera* and *Second Camera* out of connected CSI and USB cameras. - [Simultaneous streaming](./CamWebcam.md) of both cameras. - [Simultaneous photo taking or video recording](./CamMulticam.md#buttons) for both cameras. - [Camera switch](./CamMulticam.md#switch-cameras). - [Preserving active camera configuration and controls](./CamMulticam.md#configuring-mjpeg-stream-and-jpeg-photo) for later reuse. - [Stereo vision support](./Settings.md#activating-and-deactivating-stereo-vision) for two cameras of same model. - [Synchronization of settings](./CamMulticam.md#synchronize-configurations) for stereo cameras - [Camera calibration](./CamCalibration.md) for stereo cameras. - [Depth Maps](./CamStereo.md#depth-maps) - [3D Video](./CamStereo.md#3d-video) ### Photo Series - [Definition of Photo Series](./PhotoSeries.md) (# shots, interval etc.). - [Control of Photo Series](./PhotoSeries.md) (start, stop, pause, resume). - [Download of Photo Series](./PhotoSeries.md). - [Timelapse Series](./PhotoSeriesTimelapse.md) with optional sunrise/sunset restrictions. - **NEW**: [Sun-Controlled Timelapse Series](./PhotoSeriesTimelapse.md#azimuth-mode) with well-defined sun azimuth. - [Exposure Series](./PhotoSeriesExp.md) with varying exposure time or gain (ISO). - [Exposure Series Result](./PhotoSeriesExp.md#result) showing histograms. - [Focus Stack Series](./PhotoSeriesFocus.md) iterating through a range of focus settings. - Capability for [auto restart](./PhotoSeries.md#series-configuration) of series when Server or Raspi is restarted. ### Motion Detection - [Scheduled Detection of Motion](./TriggerActive.md). - Support for [different algorithms](./TriggerMotion.md) for motion detection. - [Adjustable Sensitivity](./TriggerMotion.md) for motion detection. - Support for [Regions of Interest](./TriggerMotion.md#regions-of-interest-and-regions-of-no-interest). - Support for [Regions of NO Interest](./TriggerMotion.md#regions-of-interest-and-regions-of-no-interest) - [Test Mode for Motion Detection](./TriggerMotion.md#testing-motion-capturing). ### GPIO Device Management - Configuration of [GPIO Devices](./SettingsDevices.md). - [Testing](./SettingsDevices.md#testing-a-device) of GPIO Devices - [Device Calibration](./SettingsDevices.md#calibrating-a-device) for devices which rquire state tracking (e.g. stepper motor) - Device control through [gpiozero](https://gpiozero.readthedocs.io/en/stable/index.html) - All gpiozero device types are supported in raspiCamSrv - Own device types can be added by [configuration](./SettingsDevices.md#device-type-configuration) #### Additional Device Types - [Stepper Motor](./gpioDevices/StepperMotor.md) - [ServoPWM](./gpioDevices/ServoPWM.md) for jitter-free servo control with hardware PWM ### Event Handling - Triggers and Actions - [Configuration of Triggers](./TriggerTriggers.md) - Triggering by [GPIO Input Devices](./SettingsDevices.md) (button, sensors) - Triggering by [Motion Detection](./TriggerMotion.md) - Triggering by Camera events (photo taken, video start, video stop) - [Configuration of Actions](./TriggerActions.md) - [Testing of Actions](./TriggerActions.md#testing-an-action) - Actions by [GPIO Output Devices](./SettingsDevices.md) (LED, buzzer, servo, motor) - All action methods of all [gpiozero Output Devices](https://gpiozero.readthedocs.io/en/stable/api_output.html#regular-classes) are supported - Actions by Camera (take photo, start/stop video) - [Camera Actions](./TriggerCameraActions.md) in case of motion detection (video duration, photo burst) - [Notification](./TriggerNotification.md) actions (mail, mail attachments) - [Action-to-Trigger Association](./TriggerTriggerActions.md) - [Event Viewer](./TriggerEventViewer.md) - [Event Calendar](./TriggerEventViewer.md) - [Detailed Event Information](./TriggerEventViewer.md) - Event Photos / Videos with motion detection frame - Event Photos / Videos with RoI RoNI ### Console Functions - Freely configurable [Array of Versatile Buttons](./ConsoleVButtons.md) - Freely configurable [Array of Action Buttons](./ConsoleActionButtons.md) for execution of configured [Actions](./TriggerActions.md). ### API - Selected functions of raspiCamSrv are accessible through specific [Web Service End Points](./API.md) - API access is secured through JSON Web Tokens (JWT). - A Postman collection is available for testing - A specific API (probe) is available for 'probing' attribute values of raspiCamSrv live objects. ### Privacy Protection - raspiCamSrv access requires registered [users](./Authentication.md) - The Superuser can manage other users: create, remove, reset password - Login requires a password - For streaming, it is possible to disable the necessity of authentication - API access is secured through JSON Web Tokens (JWT). - Secrets (mail account, JWT secret key) are held in a separate secrets store which is not part of the persisted configuration data. ### Configuration Management - Configuration Management refers to the way how raspiCamSrv handles its operational data which may be modified during user sessions. - [On request](./SettingsConfiguration.md), all data of the raspiCamSrv server can be [persisted as JSON files](./SettingsConfiguration.md#server-configuration-storage) - Optionally, the server can start with the stored configuration or with an initialized setup. - An [indicator](./UserGuide.md#elements) shows when configuration data have been modified during a session. - All modifications, which have not yet been saved, are [listed in a dialog](./SettingsConfiguration.md). - You can create backups of entire configuration sets and restore them at another time. ### System Information - Information on the Raspberry Pi System, the raspiCamSrv software stack as well as the active processes is shown on the [Info / System](./Information_Sys.md) screen. - Information on the connected cameras is shown on the [Info / Cameras](./Information_Cam.md) screen. - [Properties of the Active Camera](./Information_CamPrp.md) are also shown. - In addition, the Info Menu provides also details for the individual [Sensor Modes](./Information_Sensor.md) of the *Active Camera*. ### No Camera - raspiCamSrv can operate in a special mode when [no camera is connected](./UserGuide_NoCam.md). - In this case, all camera-related features are invisible. - Functions which do not require a camera, remain available: [GPIO devices](./SettingsDevices.md), [Event Handling](./Trigger.md), [Console](./Console.md). ### Supervision - For error analysis, [Logging](./Troubelshooting.md#logging) can be activated on module level. - In order to inspect the interface of raspiCamSrv with Picamera2, it is possible to activate [Generation of Python Code for the Camera](./Troubelshooting.md#generation-of-python-code-for-camera). This will create an executable Python file including all Picamera2 calls. ## Known Issues - In **Safari** (e.g. on an iPad), there is still an issue with the Live Screen: Due to the specific timing of the onload event, [AF Windows](./FocusHandling.md#autofocus-windows) may not be visible immediately after the page has been loaded. If you just 'pull' the entire window down for a short time (don't touch the AF Windows canvas), they will show up. If the Live stream does not show up (e.g. after visiting another screen), take a photo and then push **Hide**/**Show**. This will show the live stream. - There may be an issue configuring specific sensor modes or stream sizes for the *Live View* in [Config](./Configuration.md). As a result, the live view will not show up and the server log will show an exception. You may need to reset the server (see [Reset Server](./SettingsConfiguration.md))
This is already fixed but may not yet be available in your environment (see [picamera2 Issue #959](https://github.com/raspberrypi/picamera2/issues/959)) ## Limitations The software is still being tested and extended. - Hot plug of CSI cameras is not supported. This will require rebooting the Raspberry Pi (Hot plug of USB cameras is supported). - Hot plug of USB cameras is possible but requires to [Reload Cameras](./SettingsConfiguration.md#reloading-cameras). Hot plug-out of a USB camera should be avoided when the camera is active. This will produce exceptions. - Although the layout is responsive, it may not be "good-looking" with all sizes of browser windows ## Credits - Most technical information on Picamera2 () has been taken from the [Raspberry Pi - The Picamera2 Library](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf) document. - The implementation of live streaming with Flask has been inspired by - The detailed solution for the mjpeg_server is based on the example [mjpeg_server.py](https://github.com/raspberrypi/picamera2/blob/main/examples/mjpeg_server.py) of the [picamera2 repository](https://github.com/raspberrypi/picamera2) - The solution for drawing on the canvas for definition of AF Windows has been inspired by - The [Extended Motion Capturing Algorithms](./TriggerMotion.md) are based on work done by Isaac Berrios, published under [Introduction to Motion Detection: Part 1 - 3](https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2) The algorithm code has been taken from this source as well as its [GitHub Repository](https://github.com/itberrios/CV_projects/tree/main/motion_detection) and integrated into the **raspiCamSrv** environment. - raspiCamSrv uses the [gpiozero](https://gpiozero.readthedocs.io/en/stable/index.html) library for interfacing GPIO-connected devices. ================================================ FILE: docs/getting_started_overview.md ================================================ # Getting started with raspiCamSrv [![Up](img/goup.gif)](./index.md) 1. [Check requirements](requirements.md) 2. [Setup the system](./system_setup.md) for raspiCamSrv 3. [Install raspiCamSrv](./installation.md)
or [run raspiCamSrv as Docker Container](./SetupDocker.md) ## Manual Step-by-Step Installation The following procedures can be applied if an individual installation is intended or if the automatic installation fails: 1. [Manual installation of raspiCamSrv](./installation_man.md) 2. [Manual Service Configuration](./service_configuration.md) ## Alternatives - [Running raspiCamSrv as Docker container](./SetupDocker.md) - [Setup of Raspberry Pi Zero as Standalone System](./bp_PiZero_Standalone.md) ## Troubleshooting - [Trouble Shooting Guide](./Troubelshooting.md) ================================================ FILE: docs/gpioDevices/ServoPWM.md ================================================ # ServoPWM ## Overview ``` class ServoPWM(*args, **kwargs) ``` implements control of servo motors with hardware PWM. Development of this class was motivated by the fact that software-based PWM, as provided by the default RPi.GPIO pin factory in gpiozero, results in significant jitter for servo motors. The alternative pigpio pin factory does support hardware PWM, but it is currently not compatible with the latest Debian release (Trixie) for Raspberry Pi. In this class, hardware PWM support makes use of the [rpi-hardware-pwm](https://github.com/Pioreactor/rpi_hardware_pwm) library which needs to be installed with ```pip install rpi-hardware-pwm``` On Raspberry Pi, hardware PWM is only supported for the GPIO pins 12, 13, 18 and 19. Routing of the PWM signal to one or several of these pins needs to be configured through device tree overlays in ```/boot/firmware/config.txt``` (Trixie or Bookworm) or ```/boot/config.txt``` (Bullseye): for example: ``` [all] dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4 ``` On RPI 4 and earlier only up to 2 pins can be configured for PWM simultaneously. On RPI 5, you can configure up to 4 pins, e.g. ``` [pi5] dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4 dtoverlay=pwm-2chan,pin=18,func=2,pin2=19,func2=2 ``` You need to reboot after dtoverlay changes. Which pins are enabled for PWM can be checked with ```pinctrl get 12,13,18,19``` You will get something like ``` 12: a0 pd | lo // GPIO12 = PWM0_CHAN0 13: a0 pd | lo // GPIO13 = PWM0_CHAN1 18: no pd | -- // GPIO18 = none 19: no pd | -- // GPIO19 = none ``` ServoPWM checks PWM enabling for the specified pin. ServoPWM will translate the GPIO pin number to the correct PWM channel. Setup: 1. Connect the signal cable of the servo to the chosen GPIO pin 2. Connect the power input of the servo to Raspberry Pi 5V and GND pins with the correct polarity. The following code will rotate the servo clockwise (from the perspective of the servo) by 15°: ``` from raspiCamSrv.gpioDevices import ServoPWM servo = ServoPWM(12) servo.rotate(15) servo.stop() ``` The class was tested with a KY66 servo. ## Parameters - **pin** (*int*) - The GPIO pin that the servo signal input is connected to - **min_angle** - (*float*) The minimum angle, the servo can drive to. Defaults to -90.0 - **max_angle** - (*float*) The maximum angle, the servo can drive to. Defaults to 90.0 - **min_pulse_width_us** - (*int*) The minimum PWM pulse width in microseconds. Defaults to 500 - **max_pulse_width_us** - (*int*) The maximum PWM pulse width in microseconds. Defaults to 2500 - **frame_width_us** - (*int*) The frame width of the PWM signal in microseconds. Defaults to 20000
(frequency = 1000000/frame_width_us) - **speed** (*float*) - Speed of the servo: time (in sec) required for a 360° turn. Defaults to 2.8
This parameter is only used in case of *idle_off*=True to calculate the time for a requested rotation before the signal can be set to low. - **idle_off** - (*bool*) If True, the PWM pulse width is set to 0 after a requested rotation has been finished. Defaults to False
If jitter occurs in idle phases, this can be activated to eliminate jitter.
Normally, this is not required for hardware PWM. - **calibration** (*float*) - Calibration angle which will be considered as 0 position of the servo. Defaults to 0.0
Must be between *min_angle* and *max_angle*. ## Properties and Methods ### *property* **current_angle** Returns or sets the current angle relative to *calibration* angle. Any rotations are limited to the range within *min_angle* and *max_angle*. ### *property* **value** Synonym for *current_angle* ### **min**() Rotate to the minimum position of the servo. The minimum position is the position to which the servo drives with the specified *min_pulse_width_us* The absolute angle at this position (without calibration) is assumed to be *min_angle* ### **max**() Rotate to the maximum position of the servo. The maximum position is the position to which the servo drives with the specified *max_pulse_width_us* The absolute angle at this position (without calibration) is assumed to be *max_angle* ### **mid**() Rotate to the mid position of the servo. This is the position halfway between *min_angle* and *max_angle*. Note that for non-zero *calibration*, this position is different from *current_angle*=0.0. ### **rotate_to**(*angle=?*) Rotate to the given angle relative to *calibration*. **Parameters**: - **angle** (*float*) Angle to rotate to ### **rotate_by**(*angle=?*) Rotate by the given angle relative to *current_angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive or negative) ### **rotate_right**(*angle=?*) Rotate right (clockwise from the pespective of the servo) by the given angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive) ### **rotate_left**(*angle=?*) Rotate left (anti-clockwise from the pespective of the servo) by the given angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive) ### **stop()*** Stop PWM. ### **close()*** Stop PWM. ================================================ FILE: docs/gpioDevices/StepperMotor.md ================================================ # StepperMotor ## Overview ``` class StepperMotor(*args, **kwargs) ``` extends ```gpiozero.OutputDevice``` and represents a generic stepper motor connected to a stepper motor driver. An example combination, for which this class has been developped and tested, is the stepper motor **28BYJ-48** with the motor driver **ULN2003A**. 1. Plug in the 5-cable jack of the motor into the socket of the motor driver. 2. Connect the 4 inputs on the motor driver (IN1 ... IN4) to 4 GPIO pins of the Raspberry Pi.
It is important to correctly memorize which pin is connected to which input. 3. Connect the power input of the motor driver to Raspberry Pi 5V and GND pins with the correct polarity. The following code will rotate the motor counter-clockwise (from the perspective of the motor) by 15°: ``` from raspiCamSrv.gpioDevices import StepperMotor stepper = StepperMotor(6, 13, 19, 26) stepper.rotate(-15) stepper.close() ``` ## Parameters - **in1** (*int*) - The GPIO pin that the motor drivers **IN1** pin is connected to - **in2** (*int*) - The GPIO pin that the motor drivers **IN2** pin is connected to - **in3** (*int*) - The GPIO pin that the motor drivers **IN3** pin is connected to - **in4** (*int*) - The GPIO pin that the motor drivers **IN4** pin is connected to - **mode** (*int*) The mode in which the motor is operated.
Can be ```0``` (the default) for selecting half step mode with a resolution of 8 steps per full turn or ```1``` for full step mode with 4 steps per turn. - **speed** (*float*) - The speed with which the motor is operated. The speed is controlled through waiting times between successive steps.
A value of ```0.0``` results in the lowest speed with a waiting time of 4 ms and a value of ```1.0``` (the default) results in the highest speed with a waiting time of 1ms.
Values outside of this interval will be set to the nearest interval border. - **current_angle** - (*float*) The current angle of the motor. Defaults to 0.0 - **swing_from** - (*float*) left boundary angle for swinging. Defaults to -45.0 - **swing_to** - (*float*) right boundary angle for swinging. Defaults to 45.0 - **swing_step** - (*float*) step width for swinging. Defaults to 9.0 - **swing_direction** - (*int*) current swing direction. 1 (default) clockwise, 0 counter-clockwise. - **stride_angle** - (*float*) The angle incremet for a single step after gearing.
The default value of 5.625 is the value for the **28BYJ-48** motor. - **gear_reduction** (*int*) - The inverse of the transmission ratio of the gear box.
The default value of 64 is the value of the 1/64 ratio for the **28BYJ-48** motor. ## Properties and Methods ### *property* **mode** Returns or sets the mode of operation. ### *property* **speed** Returns or sets the speed. Allowed values range from 0 for lowest speed (164.20 seconds for 360°) to 1 for highest speed (4.24 seconds for 360°) ### *property* **stride_angle** Returns the stride angle. ### *property* **gear_reduction** Returns the gear_reduction ### *property* **current_angle** Returns or sets the current angle. When the class is initiated, the angle is set to zero. Every motor movement will update the current angle. For **step**, **step_forward** and **step_backward**, the current angle will stay within (-360 <= *current_angle* <= 360). For the **rotate*** and **swing** methods, *current_angle* is not restricted to these limits, which allows tracking of multiple turns. ### *property* **value** Returns or sets the current angle. ### *property* **swing_from** Returns or sets the left boundary for swinging in degree (-360 - 0). ### *property* **swing_to** Returns or sets the right boundary for swinging in degree (0 - 360). ### *property* **swing_step** Returns or sets the step width for swinging in degree (0 - 360). ### *property* **swing_direction** Returns or sets the current swinging direction. 1=right, -1=left ### **step**(*steps=?*) Steps forward for positive and backward for negative argument by the given number of steps. Thus, the angle is changed by *steps x stride_angle*. When using this method, **Parameters**: - **steps** (*int*) Number of steps to move (positive or negative) ### **step_forward**(*steps=?*) Steps forward (clockwise rotation) by the given number of steps. Thus, the angle is increased by *steps x stride_angle* **Parameters**: - **steps** (*int*) Number of steps to move forward (positive value) ### **step_backward**(*steps=?*) Steps backward (anti-clockwise rotation) by the given number of steps. Thus, the angle is decreased by *steps x stride_angle* **Parameters**: - **steps** (*int*) Number of steps to move backward (positive palue) ### **rotate**(*angle=?*) Rotate clockwise (for positive angle) or counter-clockwise (for negative angle) by the given angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive or negative) ### **rotate_right**(*angle=?*) Rotate right (clockwise from the pespective of the motor) by the given angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive) ### **rotate_left**(*angle=?*) Rotate left (anti-clockwise from the pespective of the motor) by the given angle. **Parameters**: - **angle** (*float*) Angle to rotate (positive) ### **rotate_to**(*target=?*) Rotate to the given angle. **Parameters**: - **target** (*float*) Angle to rotate to ### **swing**() Do one swing step in the current *swing_direction* with the current *swing_step*. If the *current_angle* would exceed *swing_from* or *swing_to*, rotation will reverse its direction at the border. **Parameters**: - **angle** (*float*) Angle to rotate (positive) ### **wipe**(*angle_from=?, angle_to=?, speed=?, count=?*) Wipe back and forth within a given range of angles with the given speed. With the *count* parameter you can specify a certain number of cycles. in case of *count=0*, swiping will continue until **stop** is called. After termination, the motor returns to the starting position. **Parameters**: - **angle_from** (*float*) Start angle (defaults to -45.0) - **angle_to** (*float*) End angle (defaults to 45.0) - **speed** (*float*) Speed to be used for swiping. Can be in the range from 0 (slow) to 1 (fast). Default is 0. - **count** (*int*) Number of cycles. A value of 0 will cause infinite swiping which needs to be stopped with **stop()** ### **stop()*** Terminate swiping after the active cycle is finished. ### **close()** Shut down the device and release all associated resources (such as GPIO pins). ================================================ FILE: docs/index.md ================================================ **raspiCamSrv** is a Web server for Raspberry Pi systems providing an App for control and streaming of CSI and USB cameras as well as for controlling a large variety of connected [GPIO devices](./SettingsDevices.md). While all currently connected cameras are accessible by the system, up to two cameras can be operated simultaneously at a time, supporting multi-camera features like [Stereo Vision](./CamStereo.md). Interoperability between Cameras and GPIO devices is achieved through the freely configurable [event handling infrastructure](./Trigger.md). **raspiCamSrv** supports all Raspberry Pi platforms from Pi Zero to Pi 5, running Bullseye, Bookworm or Trixie OS. Besides the currently available Raspberry Pi cameras, also compatible CSI cameras from other providers can be used. USB web cams are seamlessly integrated. **raspiCamSrv** is built with [Flask 3.x](https://flask.palletsprojects.com/en/stable/) and uses the [Picamera2 library](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf). Due to responsive layout from [W3.CSS](https://www.w3schools.com/w3css/), all modern browsers on PC, Mac or mobile devices can be used as clients. For resources and latest version, refer to the [raspiCamSrv GitHub Repository](https://github.com/signag/raspi-cam-srv) ![Live Overview](./img/Live.jpg) ================================================ FILE: docs/installation.md ================================================ # RaspiCamSrv Installation [![Up](img/goup.gif)](./getting_started_overview.md) ## Installation Steps The following description refers to the initial installation. If you want to update an existing installation to the latest version, see [Update Procedure](./updating_raspiCamSrv.md). 1. Connect to the Pi using SSH:
```ssh @```
with `````` and `````` as specified during setup with Imager. 2. Make sure that the system is up to date
```sudo apt update```
```sudo apt full-upgrade``` 3. Run the automatic installer with: ```bash <(curl -fsSL https://raw.githubusercontent.com/signag/raspi-cam-srv/main/scripts/install_raspiCamSrv.sh)``` Follow instructions given by the installer (see [below](#installer)) 4. When the installer has finished successfully, open a browser and connect to raspiCamSrv using the indicated URL. 5. Before you can login, you first need to [register](./Authentication.md).
The first user will automatically be SuperUser who can later register other users ([User Management](./Authentication.md#user-management)) 6. After successful log-in, the [Live screen](./LiveScreen.md) will be shown, if at least one camera is connected, otherwise the [Info](./Information.md) screen. 7. For usage of **raspiCamSrv**, please refer to the [User Guide](./UserGuide.md) ## Installer **NOTE**: You can run the installer multiple times without any risk, also over an existing installation. So, if you want to switch the [WSGI server](./Information_Sys.md#wsgi-server) or need more [threads for Gunicorn](./installation_man.md#gunicorn-settings), or if you want to extend the softwarestack with a missing package, just rerun the installer. It will not touch your existing data, but just update the installation to the latest version and adjust the service configuration to your requirements. Under normal circumstances, the installer will finish successfully. You will probably see some red **ERROR** messages (See [Trouble Shooting Guide](./Troubelshooting.md#errors-during-installation)) which you can ignore as long as the installer continues and [finalizes](#finalization). ### Starting the Installer #### For Fresh Installation ``` ========================================== === raspiCamSrv Automated Installer === === === === Exit at any step with Ctrl+C === ========================================== RPI Model : Raspberry Pi 4 Model B Rev 1.1 Detected OS codename: trixie full Hostname : raspi03 Running as user : sn Installing at : /home/sn/prg ===================== Installation Defaults ===================== Installation Path : /home/sn/prg/raspi-cam-srv WSGI Server : Gunicorn Gunicorn Threads : 6 Service Port : 5000 (default, will be adjusted if already in use) Audio Recording : Disabled (Installing system service) Advanced Features : Enabled USB Cams, Histograms, Stereo Vision, extended Motion Detection (Requires OpenCV, numpy, matplotlib) AI Camera Support : Disabled Hardware PWM Support: Disabled Hardware PWM is required for jitter-free servo control Do you want to install with these settings? [Y/n]: No more questions! Ready to start installation? [Y/n]: ``` Confirming both times with ```y``` or ```[Enter]``` will run the installer with the default settings. Confirming with ```n``` will allow for individual settings. #### Fresh Installation with existing Backup from previous Installation If saved backups from a previous installation exist at ```~/prg/raspi-cam-srv_backups``` (see [Retaining backups when uninstalling](#retaining-backups)), the installer will automatically restore these for the new installation. If they are not required, you can remove them in dialog [Settings/Configuration](./SettingsConfiguration.md). ``` ========================================== === raspiCamSrv Automated Installer === === === === Exit at any step with Ctrl+C === ========================================== RPI Model : Raspberry Pi Zero 2 W Rev 1.0 Detected OS codename: bookworm lite Hostname : raspi05 Running as user : sn Installing at : /home/sn/prg ===================== Installation Defaults ===================== Installation Path : /home/sn/prg/raspi-cam-srv Backup : Restoring backup from a previous installation WSGI Server : Gunicorn Gunicorn Threads : 6 Service Port : 5000 (default, will be adjusted if already in use) Audio Recording : Disabled (Installing system service) Advanced Features : Enabled USB Cams, Histograms, Stereo Vision, extended Motion Detection (Requires OpenCV, numpy, matplotlib) AI Camera Support : Disabled Hardware PWM Support: Disabled Hardware PWM is required for jitter-free servo control Do you want to install with these settings? [Y/n]: ``` #### Installing over existing Installation If the installation path ```~/prg/raspi-cam-srv``` exists already, it is assumed that a raspiCamSrv installation exists already on the system. ``` ========================================== === raspiCamSrv Automated Installer === === === === Exit at any step with Ctrl+C === ========================================== RPI Model : Raspberry Pi Zero 2 W Rev 1.0 Detected OS codename: bookworm lite Hostname : raspi05 Running as user : sn Installing at : /home/sn/prg ===================== Installation Mode ===================== Installation Path : /home/sn/prg/raspi-cam-srv (exists) Service Status : raspiCamSrv.service (running, will be stopped) A raspiCamSrv installation exists already. Do you want to skip update of raspiCamSrv and software stack and only reconfigure the service[Y/n]: Only installing/replacing service for existing installation ===================== Installation Defaults ===================== WSGI Server : Gunicorn Gunicorn Threads : 6 Service Port : 5000 (default, will be adjusted if already in use) Audio Recording : Disabled (Installing system service) Do you want to install with these settings? [Y/n]: No more questions! Ready to start installation? [Y/n]: ``` Confirming all questions with ```y``` or ```[Enter]``` will - stop a running raspiCamSrv service - skip updating the raspiCamSrv repository - skip installation of software packages - try to initialize the database in case this did not complete in the previous installation run - reconfigure a system service (no audio recording) Alternatively, you can allow updating raspiCamSrv and software stack and/or run a customized installation. ### Custom Installation If a custom installation is required, necessary information is requested step by step: ``` Do you want to install with these settings? [Y/n]: n Available WSGI servers: 1) Gunicorn (recommended for publicly accessible systems) - default 2) Flask built-in server (OK for testing and private networks) Choose WSGI server [1/2]: Using WSGI server: gunicorn How many parallel video streams do you require? [default: 6]: Using 6 threads for Gunicorn worker process Do you need to record audio along with videos? [y/N]: Audio recording enabled: false Do you want to enable advanced features (USB Cams, Histograms, Stereo Vision, extended Motion Detection)? [Y/n]: Advanced features enabled: true Do you intend to use the Raspberry Pi AI Camera (imx500)? [y/N]: y AI Camera support enabled: true Do you intend to use Hardware PWM for jitter-free servo control? [y/N]: y Hardware PWM support enabled: true No more questions! Ready to start installation? [Y/n]: ``` - For WSGI server selection, see the [WSGI Server section](./Information_Sys.md#wsgi-server) of the [Info/System](./Information_Sys.md) screen. - For Gunicorn, especially setting *Number of Threads*, see [Gunicorn Settings](./installation_man.md#gunicorn-settings). - For recording audio, see [Recording Audio along with Video](./Settings.md#recording-audio-along-with-video). - For support of AI features for the imx500 camera, see [AI Camera Support](./AiCameraSupport.md). (This is not available for Bullseye systems) - Hardware PWM support is only required if you intend to use [ServoPWM](./gpioDevices/ServoPWM.md) as [Device](./SettingsDevices.md) for controlling servos through [Actions](./TriggerActions.md). ### Installation Process The installer will automatically execute the procedure described for [manual installation](./installation_man.md) as well as [service configuration](./service_configuration.md). The steps shown in the installer protocol correspond to the steps of [manual installation](./installation_man.md). In case of problems during installation and usage, see [Troubleshooting](./Troubelshooting.md) or try the [manual installation procedure](./installation_man.md). #### Step 12: Initializing database for Raspberry Pi Zero Systems Recent (per ~02/2026) updates of Bookworm and Trixie on Raspberry Pi Zero / Zero 2 devices seem to have an issue with allocation of CMA memory by Picamera2 (see also [Checking Contiguous Memory (CMA)](./SetupDocker.md#checking-contiguous-memory-cma)). The issue may be due to CMA fragmentation or a previous process not releasing its buffers cleanly or fast enough. This issue has not been found for Bullseye systems on RPI Zero and also not for RPI 1, ... 5. When the Flask raspiCamSrv application is created (which is also the case while initializing the database during installation), raspiCamSrv instantiates the active camera through Picamera2. During this process, Picamera2 tries to allocate CMA memory. It has been observed on RPI Zero Bookworm and Trixie systems, that this allocation may fail at one time and be successful later. Therefore, in this specific setup, the raspiCamSrv installer will try database initialization for up to 5 times with a pause of 5 sec in case of a failure. The example, below, shows success in the second attempt: ``` Step 12: Initializing database ... Attempt 1 of 5... Traceback (most recent call last): File "", line 198, in _run_module_as_main File "", line 88, in _run_code File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/__main__.py", line 3, in main() File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/cli.py", line 1131, in main cli.main() File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/click/core.py", line 1406, in main rv = self.invoke(ctx) ^^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/click/core.py", line 1867, in invoke cmd_name, cmd, args = self.resolve_command(ctx, args) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/click/core.py", line 1914, in resolve_command cmd = self.get_command(ctx, cmd_name) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/cli.py", line 631, in get_command app = info.load_app() ^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/cli.py", line 349, in load_app app = locate_app(import_name, name) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/cli.py", line 262, in locate_app return find_best_app(module) ^^^^^^^^^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/.venv/lib/python3.11/site-packages/flask/cli.py", line 72, in find_best_app app = app_factory() ^^^^^^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/raspiCamSrv/__init__.py", line 155, in create_app cam = Camera() ^^^^^^^^ File "/home/sn/prg/raspi-cam-srv/raspiCamSrv/camera_pi.py", line 1713, in __new__ cls.initCamera() File "/home/sn/prg/raspi-cam-srv/raspiCamSrv/camera_pi.py", line 1953, in initCamera cls.loadCameraSpecifics() File "/home/sn/prg/raspi-cam-srv/raspiCamSrv/camera_pi.py", line 2945, in loadCameraSpecifics sensorModes = Camera.cam.sensor_modes ^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3/dist-packages/picamera2/picamera2.py", line 599, in sensor_modes self.configure(temp_config) File "/usr/lib/python3/dist-packages/picamera2/picamera2.py", line 1221, in configure self.configure_("preview" if camera_config is None else camera_config) File "/usr/lib/python3/dist-packages/picamera2/picamera2.py", line 1193, in configure_ self.allocator.allocate(libcamera_config, camera_config.get("use_case")) File "/usr/lib/python3/dist-packages/picamera2/allocators/dmaallocator.py", line 43, in allocate fd = self.dmaHeap.alloc(f"picamera2-{i}", stream_config.frame_size) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3/dist-packages/picamera2/dma_heap.py", line 98, in alloc ret = fcntl.ioctl(self.__dmaHeapHandle.get(), DMA_HEAP_IOCTL_ALLOC, alloc) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ OSError: [Errno 12] Cannot allocate memory Failed. Waiting 5s before retry... Attempt 2 of 5... Initialized the database. ``` A similar behavior may be observed at server start. However, since the raspiCamSrv service is configured to restart automatically, server start will usually be successful after some time. ### Finalization ``` Step 13: Checking Flask service port ... Trying port 5000 ... Using port 5000 Cleaning up existing service before reinstalling ... System service 'raspiCamSrv.service' disabled. System service 'raspiCamSrv.service' configuration removed. Installing 'raspiCamSrv.service' as user unit for WSGI Server werkzeug ... User service installed and started. ========================================== === raspiCamSrv installation completed === === === === Access via http://raspi06:5000 ========================================== ``` #### Check for Hardware PWM Support If you have selected to enable Hardware PWM support, the following information will be displayed: ``` ============================================================================================================= Checking for Hardware PWM support on GPIO pins 12, 13, 18, 19 ... pinctrl get 12,13,18,19 12: a0 pd | lo // GPIO12 = PWM0_CHAN0 13: ip -- | lo // GPIO13 = input 18: ip -- | lo // GPIO18 = input 19: ip -- | lo // GPIO19 = input If you see 'PWM0' or 'PWM1' in the output above, Hardware PWM support is available for the indicated pins. Otherwise, you need to specify device tree overlays in /boot/firmware/config.txt and reboot your Raspberry Pi. Depending on your RPI model and the required pins, add the following lines to /boot/firmware/config.txt: [all] dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4 [pi5] dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4 dtoverlay=pwm-2chan,pin=18,func=2,pin2=19,func2=2 ============================================================================================================= ``` ## Supervision You can check the system logs with ```sudo journalctl -ef``` ### For 'Werkzeug' WSGI server: When the Flask server starts up, it will show a warning that this is a development server. This is, in general, fine for private environments. How to deploy with a production WSGI server, is described in the [Flask documentation](https://flask.palletsprojects.com/en/stable/deploying/) ``` Dec 09 18:49:19 raspi06 python[9642]: * Serving Flask app 'raspiCamSrv' Dec 09 18:49:19 raspi06 python[9642]: * Debug mode: off Dec 09 18:49:19 raspi06 python[9642]: WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. Dec 09 18:49:19 raspi06 python[9642]: * Running on all addresses (0.0.0.0) Dec 09 18:49:19 raspi06 python[9642]: * Running on http://127.0.0.1:5000 Dec 09 18:49:19 raspi06 python[9642]: * Running on http://192.168.178.72:5000 Dec 09 18:49:19 raspi06 python[9642]: Press CTRL+C to quit ``` ### For Gunicorn WSGI server When Gunicorn starts, it will output Info messages like ``` Feb 18 14:09:29 raspi06 systemd[1197]: Started raspiCamSrv.service - raspiCamSrv. Feb 18 14:09:30 raspi06 gunicorn[7906]: [2026-02-18 14:09:30 +0100] [7906] [INFO] Starting gunicorn 25.1.0 Feb 18 14:09:30 raspi06 gunicorn[7906]: [2026-02-18 14:09:30 +0100] [7906] [INFO] Listening at: http://0.0.0.0:5000 (7906) Feb 18 14:09:30 raspi06 gunicorn[7906]: [2026-02-18 14:09:30 +0100] [7906] [INFO] Using worker: gthread Feb 18 14:09:30 raspi06 gunicorn[7906]: [2026-02-18 14:09:30 +0100] [7906] [INFO] Control socket listening at /home/sn/prg/raspi-cam-srv/gunicorn.ctl Feb 18 14:09:30 raspi06 gunicorn[7908]: [2026-02-18 14:09:30 +0100] [7908] [INFO] Booting worker with pid: 7908 ``` The PID of the worker process is also shown in the [Info/System screen](./Information_Sys.md#process-info) **NOTE**: When starting, the Gunicorn master process creates a control socket file ```gunicorn.ctl``` in the working directory, which will be removed when the server stops. ## Manually Starting the Server 1. Stop the service ```sudo systemctl stop raspiCamSrv``` (In case of a system unit) ```systemctl --user stop raspiCamSrv``` (In case of a user unit) 2. Go to the install directory ```cd ~/prg/raspi-cam-srv``` 3. Activate the virtual environment ```.venv/bin/activate``` 4. Start raspiCamSrv either with the Flask built-in development server (werkzeug): ```python -m flask --app raspiCamSrv run --port 5000 --host=0.0.0.0``` or with the Gunicorn production server: ```gunicorn -b 0.0.0.0:5000 -w 1 -k gthread --threads 6 --timeout 0 --log-level info 'raspiCamSrv:create_app()``` ## Uninstalling raspiCamSrv 1. Connect to the Pi using SSH:
```ssh @``` 2. Run the automatic uninstaller with: ```bash <(curl -fsSL https://raw.githubusercontent.com/signag/raspi-cam-srv/main/scripts/uninstall_raspiCamSrv.sh)``` ### Uninstaller The uninstaller will request confirmation: ``` ========================================== === raspiCamSrv Automated Uninstaller === === === === Exit at any step with Ctrl+C === ========================================== RPI Model : Raspberry Pi Zero 2 W Rev 1.0 Detected OS codename: bookworm lite Hostname : raspi05 Running as user : sn Uninstalling from : /home/sn/prg/raspi-cam-srv raspiCamSrv will be completely removed from raspi05. Continue? [yes/NO]: ``` To uninstall, you need to reply with ```yes```. ### Retaining Backups If you had created [backups](./SettingsConfiguration.md#backups), these can be preserved for a possible reuse in a new installation. ``` Backups found in /home/sn/prg/raspi-cam-srv/backups: total 8 drwxr-xr-x 4 sn sn 4096 Feb 28 16:50 2026-02-28-16:49 drwxr-xr-x 4 sn sn 4096 Feb 28 17:05 2026-02-28-17:05 Do you want to keep these backups? [y/N]:y Backups saved at /home/sn/prg/raspi-cam-srv_backups Uninstalling raspiCamSrv service ... ``` ================================================ FILE: docs/installation_man.md ================================================ # Manual raspiCamSrv Installation [![Up](img/goup.gif)](./getting_started_overview.md) The following procedure describes a manual step by step installation. For automatic installation, see [RaspiCamSrv Installation](./installation.md). If you want to update an existing installation to the latest version, see [Update Procedure](./updating_raspiCamSrv.md). In case of problems during installation and usage, see [Troubleshooting](./Troubelshooting.md) **NOTE**: For Debian-**Trixie**, some of the required packages are already preinstalled. To ensure everything is consistently installed in and run from the **raspiCamSrv** virtual environment, the respective ```pip install``` commands, below, have been extended with a ```--ignore-installed``` clause and the Flask server is started with ```python -m flask ...``` ## Step by Step 1. Connect to the Pi using SSH:
```ssh @```
with and as specified during setup with Imager. 2. Update the system
```sudo apt update```
```sudo apt full-upgrade``` 3. If you intend to take videos and have installed a *lite* version of the OS, you may need to install *ffmpeg*:
Check whether ffmpeg is installed with
```which ffmpeg```
If you get an empty response, install with
```sudo apt install ffmpeg``` 4. Create a root directory under which you will install programs (e.g. 'prg')
```mkdir prg```
```cd prg``` 5. Check that git is installed (which is usually the case in current Bullseye, Bookworm or Trixie distributions)
```git --version```
If git is not installed, install it with
```sudo apt install git``` 6. Clone the raspi-cam-srv repository:
```git clone --branch main --single-branch --depth 1 https://github.com/signag/raspi-cam-srv``` 7. Create a virtual environment ('.venv') on the 'raspi-cam-srv' folder:
```cd raspi-cam-srv```
```python -m venv --system-site-packages .venv```
For the reasoning to include system site packages, see the [picamera2-manual.pdf](./picamera2-manual.pdf), chapter 9.5. 8. Activate the virtual environment
```cd ~/prg/raspi-cam-srv```
```source .venv/bin/activate```
The active virtual environment is indicated by ```(.venv)``` preceeding the system prompt.
(If you need to leave the virtual environment at some time, use ```deactivate```) 9. Make sure that picamera2 is available on the system:
```python```
```>>>import picamera2```
```>>>quit()```
If you get a 'ModuleNotFoundError', see the [picamera2 Manual](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), chapter 2.2, how to install picamera2.
For **raspiCamSrv** it would be sufficient to install without GUI dependencies:
For Bullseye and Bookworm:
```sudo apt install -y python3-picamera2 --no-install-recommends```
For Trixie:
```sudo apt install -y python3-libcamera python3-picamera2 --no-install-recommends``` 10. Install Flask 3.x **with the virtual environment activated (Step 8)**.
Raspberry Pi OS distributions come with Flask preinstalled, however we need to run Flask from the virtual environment in order to see other packages which will be located there.
```pip install --ignore-installed "Flask>=3,<4"```

Make sure that Flask is really installed in the virtual environment:
```which flask``` should output
```/home//prg/raspi-cam-srv/.venv/bin/flask``` 11. **Optional** installations:
The following installations are only required if you need to visualize histograms of photos or if you are interested in using [Extended Motion Capturing Algorithms](./TriggerMotion.md) or [Stereo Vision](./CamStereo.md).
For use of USB cameras, OpenCV is required.

All installations must be done with the virtual environment activated (Step 8)

Install [OpenCV](https://de.wikipedia.org/wiki/OpenCV):
```sudo apt-get install python3-opencv```

Install [numpy](https://numpy.org/):
For RPI Zero 2, RPI 1 ... 5:
```pip install --ignore-installed numpy```
(There may be errors, which normally can be ignored)
For RPI Zero W:
```sudo apt-get install -y python3-numpy```

Install [matplotlib](https://de.wikipedia.org/wiki/Matplotlib):
For RPI Zero 2, RPI 1 ... 5:
**Trixie**:```pip install --ignore-installed matplotlib```
(There may be errors, which normally can be ignored)
**Bookworm**: ```pip install --ignore-installed "matplotlib<3.8"```
(The version restriction assures compatibility with numpy 1.x which is [required for Picamera2](https://github.com/raspberrypi/picamera2/issues/1211))
For RPI Zero W:
```sudo apt-get install -y python3-matplotlib```

The following installation is required for enabling the [raspiCamSrv API](./API.md)
Install [flask-jwt-extended](https://flask-jwt-extended.readthedocs.io/en/stable/)
```pip install --ignore-installed flask-jwt-extended```
(There may be errors, which normally can be ignored)

The following installation is only required if you are using a Lite variant of the Debian OS:
For RPI Zero 2, RPI 1 ... 5:
```pip install --ignore-installed psutil```
For RPI Zero W:
```sudo apt-get install -y python3-psutil```

The following installations are only required if you intend to use a Raspberry Pi AI Camera:

Install the imx500-all package:
```sudo apt install imx500-all```

Install [munkres](https://pypi.org/project/munkres/)
```pip install --break-system-packages munkres```
(There may be errors, which normally can be ignored)

If you intend to use the [Gunicorn](https://gunicorn.org/) WSGI server instead of the development WSGI server integrated in Flask ([Werkzeug](https://werkzeug.palletsprojects.com/en/stable/)), you need to install Gunicorn:
```pip install --break-system-packages gunicorn```
(There may be errors, which normally can be ignored)

12. Initialize the database for Flask
(with ```raspi-cam-srv``` as active directory and the virual environment activated - see step 8):
```python -m flask --app raspiCamSrv init-db``` 13. Check that the Flask default port 5000 is available
```sudo netstat -nlp | grep 5000```
If an entry is shown, find another free port (e.g. 5001)
and replace ```port 5000``` by your port in all ```flask``` commands, below and also in the URL in step 12. 14. Start the server
(with ```raspi-cam-srv``` as active directory and the virual environment activated - see step 8):
Either use the Flask built-in development server:
```python -m flask --app raspiCamSrv run --port 5000 --host=0.0.0.0```
or use [Gunicorn](https://gunicorn.org/) as productive server:
```gunicorn -b 0.0.0.0:5000 -w 1 -k gthread --threads 6 --timeout 0 --log-level info 'raspiCamSrv:create_app()'``` 15. Connect to the server from a browser:
```http://:5000```
This will open the [Login](./Authentication.md) screen. 16. Before you can login, you first need to [register](./Authentication.md).
The first user will automatically be SuperUser who can later register other users ([User Management](./Authentication.md#user-management)) 17. After successful log-in, the [Live screen](./LiveScreen.md) will be shown, if at least one camera is connected, otherwise the [Info](./Information.md) screen. 18. Done! 19. For usage of **raspiCamSrv**, please refer to the [User Guide](./UserGuide.md) When the Flask server starts up, it will show a warning that this is a development server. This is, in general, fine for private environments. How to deploy with an alternative production WSGI server, is described in the [Flask documentation](https://flask.palletsprojects.com/en/stable/deploying/) ## Gunicorn Settings When using the [Gunicorn](https://gunicorn.org/) WSGI server, specific settings must be used with the raspiCamSrv Flask application. ### Number of Workers **Command line**: ```-w 1``` Only a single worker must be configured. Each worker will be a separate process. Multiple workers would run multiple raspiCamSrv Flask processes in parallel. This would cause conflicts when accessing Raspberry Pi resources, such as cameras and GPIO devices. ### Port Binding By default, Gunicorn binds to ```127.0.0.1:8080``` It is recommended using the same port as the Flask-internal WSGI server (5000). **Command line**: ```-b 0.0.0.0:5000``` ### Worker Type Gunicorn supports different [Worker Types](https://gunicorn.org/design/?h=design#worker-types) to be used. From these, only ```gthread``` works for raspiCamSrv, because - keep-alive connections are supported, which is essential for MJPEG streaming - it uses real OS threads which is essential for multi-threading in raspiCamSrv, Picamera2 and OpenCV **Command line**: ```-k gthread``` ### Numer of Threads With the ```gthread``` worker type, it is necessary to specify the number of threads which can be simultaneously active, because each request will be handled by an own worker thread. Therefore, the number of threads limits the number of simultaneous MJPEG streams. On the other hand, when a worker is started, the worker process will pre-create the specified number of threads, regardless of how many clients are connected. **Command line**: ```--threads 6``` Every MJPEG stream uses one thread, for example: - [Live screen](./LiveScreen.md): 1 thread - [Web Cam screen](./CamWebcam.md) with 2 cameras: 2 threads - [Stereo Cam screen](./CamStereo.md): 3 threads - Every client streaming from a ```video_feed``` endpoint uses 1 thread So, with ```--threads 4``` and 2 clients showing the [Web Cam screen](./CamWebcam.md), the 4 threads are used up and another client, trying to stream from a ```video_feed``` endpoint, would wait. ### Timeout Gunicorn kills and restarts worker threads which are silent for more than the number of seconds specified in the ```timeout``` option. The default is 30 seconds. For raspiCamSrv, timout should be avoided. **Command line**: ```--timeout 0``` ### Logging **Command line**: ```--log-level info``` 'info' is the default log level. Other valid level names are: - debug - warning - error - critical ### WSGI APP raspiCamSrv uses a factory pattern to create the Flask application. Therefore, the raspiCamSrv WSGI app needs to be ecposed to Gunicorn in the form **Command Line**: ```'raspiCamSrv:create_app()'``` ================================================ FILE: docs/picamera2_manual.md ================================================ [The Picamera2 Library](https://pip-assets.raspberrypi.com/categories/652-raspberry-pi-camera-module-2/documents/RP-008156-DS-2-picamera2-manual.pdf?disposition=inline) ================================================ FILE: docs/requirements.md ================================================ # Requirements [![Up](img/goup.gif)](./getting_started_overview.md) - A **Raspberry Pi** ([Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/), [Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/), [Pi 1](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/), [Pi 3](https://www.raspberrypi.com/products/raspberry-pi-3-model-b-plus/), [Pi 4](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/), [Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/)) - A [Raspberry Pi camera](https://www.raspberrypi.com/documentation/accessories/camera.html) - A suitable **camera cable**
(Pi Zero W, Pi Zero 2 W and Pi 5 have the small CSI-2 camera port, requiring a special cable which is usually not shipped with the camera) - A **microSD card** - A suitable **power supply**
For Pi Zero W or Pi Zero 2 W, a normal mobile phone charger is sufficient as long as it has a Micro-USB connector - Optionally, a **case** for the specific model may ease handling.
For Pi Zero W or Zero 2 W, offerings for the official case (e.g. [here](https://www.reichelt.de/gehaeuse-fuer-raspberry-pi-zero-rot-weiss-rpiz-case-whrd-p223607.html?PROVID=2788&gclid=EAIaIQobChMI2JfM3vjcgwMVSxYGAB1pSQBOEAYYASABEgL_GPD_BwE)) should include a special short (38 mm) camera cable.
The cover for the camera is fine for camera models 1 and 2. For camera model 3, some handwork is necessary to enlarge the hole to a square for the camera body.
Alternatively, there are various 3D printable variants, e.g. [Case for Raspberry Pi Zero Models with Camera](https://makerworld.com/en/models/1804527-case-for-raspberry-pi-zero-models-with-camera#profileId-1924557). - A **Wifi network** with internet access and known access credentials (**SSID**, **password**) - A **PC or Mac** with network access and **(micro)SD** card reader The [setup description](./system_setup.md), assumes a completely autonomous or 'headless' setup, where the Raspberry Pi requires nothing but a power supply cable without any necessity to ever connect it to a display, keyboard or mouse. | | | |--------|---| |![Pi Zero Cover](./img/pi_zero_cover.jpg)|![Pi Zero Cover](./img/pi_zero_cover_3dp.jpg)| |Official Pi Zero Case with Camera módel 2| [3D-printed case](https://makerworld.com/en/models/1804527-case-for-raspberry-pi-zero-models-with-camera#profileId-1924557) suitable for tripod-mounting ================================================ FILE: docs/service_configuration.md ================================================ # Service Configuration [![Up](img/goup.gif)](./getting_started_overview.md) **NOTE**: This installation step is included in the [automatic installation](./installation.md) When the Flask server is started in a SSH session as described in [Installation Step 11](./installation.md), it will terminate with the SSH session. Instead, you may want the server to start up independently from any user sessions, restart after a failure and automatically start up when the device is powered up. In order to achieve this, the Flask server start can be configured as service under control of systemd. ## No Audio Recording required The following procedure is for the case where **audio recording** with video is **not required**. Otherwise, see [next](#service-configuration-for-audio-support) section. 1. Open a SSH session on the Raspberry Pi 2. Copy the service template *raspiCamSrv.service* which is provided with **raspiCamSrv** to your home directory.
When using the Flask built-in development server (werkzeug):
```cp ~/prg/raspi-cam-srv/config/raspiCamSrv.service ~```
When using the Gunicorn production server:
```cp ~/prg/raspi-cam-srv/config/raspiCamSrv_gunicorn.service ~/raspiCamSrv.service``` 3. Adjust the service configuration:
```nano ~/raspiCamSrv.service```
Replace all (4) occurrences of '``````' with the user ID, specified during [System Setup](./system_setup.md)
If you need a port different from 5000 (see [RaspiCamSrv Installation](./installation.md), step 11), replace also ```port 5000``` by your port.
If you are using the Gunicorn WSGI server, you can adjust ```--threads 6``` to a value suitable for your use case (See [Gunicorn Settings / Number of Threads](./installation_man.md#numer-of-threads)) 4. Stage the service configuration file to systemd:
```sudo cp ~/raspiCamSrv.service /etc/systemd/system``` 5. Start the service:
```sudo systemctl start raspiCamSrv.service``` 6. Check that the Flask server has started as service:
```sudo journalctl -ef``` 7. Enable the service so that it automatically starts with system boot:
```sudo systemctl enable raspiCamSrv.service``` 8. Reboot the system to test automatic server start:
```sudo reboot``` ## Service Configuration for Audio Support If it is intended to record audio along with videos, a slightly different setup is required (see [Settings](./Settings.md#recording-audio-along-with-video)): Instead of installing the service as a system unit, it needs to be installed as user unit (see [systemd/User](https://wiki.archlinux.org/title/Systemd/User)) in order to get access to [PulseAudio](https://wiki.archlinux.org/title/PulseAudio). ### Trixie and Bookworm Systems If your system is a trixie or a bookworm system (```lsb_release -a```) follow these steps: 1. Open a SSH session on the Raspberry Pi 2. Copy the service template *raspiCamSrv.service* which is provided with **raspiCamSrv** to your home directory
When using the Flask built-in development server (werkzeug):
```cp ~/prg/raspi-cam-srv/config/raspiCamSrv.service ~```
When using the Gunicorn production server:
```cp ~/prg/raspi-cam-srv/config/raspiCamSrv_gunicorn.service ~/raspiCamSrv.service``` 3. Adjust the service configuration:
```nano ~/raspiCamSrv.service```
Replace all (4) occurrences of '``````' with the user ID, specified during [System Setup](./system_setup.md)
If necessary, raplace also the standard port 5000 with your port.
Remove the entry User=`````` from the [System] section
If you are using the Gunicorn WSGI server, you can adjust ```--threads 6``` to a value suitable for your use case (See [Gunicorn Settings / Number of Threads](./installation_man.md#numer-of-threads))
In section [Install], change ```WantedBy=multi-user.target``` to ```WantedBy=default.target``` 4. Create the directory for systemd user units
```mkdir -p ~/.config/systemd/user``` 5. Stage the service configuration file to systemd for user units:
```cp ~/raspiCamSrv.service ~/.config/systemd/user``` 6. Start the service:
```systemctl --user start raspiCamSrv.service``` 7. Check that the Flask server has started as service:
```journalctl --user -ef```
If you get ```No journal files were found.```, try
```sudo journalctl -ef``` 8. Enable the service so that it automatically starts with a session for the active user:
```systemctl --user enable raspiCamSrv.service``` 9. Enable lingering in order to start the unit right after boot and keep it running independently from a user session
```loginctl enable-linger``` 10. Reboot the system to test automatic server start:
```sudo reboot``` ### Bullseye Systems If your system is a bullseye system (```lsb_release -a```), which is currently still the case for Pi Zero, follow these steps: 1. Open a SSH session on the Raspberry Pi 2. Clone branch 0_3_12_next of Picamera2 repository
```cd ~/prg```
```git clone -b 0_3_12_next https://github.com/raspberrypi/picamera2``` 3. Copy the service template *raspiCamSrv.service* which is provided with **raspiCamSrv** to your home directory
```cp ~/prg/raspi-cam-srv/config/raspiCamSrv.service ~``` 4. Adjust the service configuration:
```nano ~/raspiCamSrv.service```
- Replace '``````' with the user ID, specified during [System Setup](./system_setup.md)
- If necessary, raplace also the standard port 5000 with your port.
- Add another Environment entry: ```Environment="PYTHONPATH=/home//prg/picamera2"```
- Remove the entry User=`````` from the [System] section
- In section [Install], change ```WantedBy=multi-user.target``` to ```WantedBy=default.target```
For an example of the final .service file, see below 5. Create the directory for systemd user units
```mkdir -p ~/.config/systemd/user``` 6. Stage the service configuration file to systemd for user units:
```cp ~/raspiCamSrv.service ~/.config/systemd/user``` 7. Start the service:
```systemctl --user start raspiCamSrv.service``` 8. Check that the Flask server has started as service:
```journalctl --user -e``` 9. Enable the service so that it automatically starts with a session for the active user:
```systemctl --user enable raspiCamSrv.service``` 10. Enable lingering in order to start the unit right after boot and keep it running independently from a user session
```loginctl enable-linger``` 11. Reboot the system to test automatic server start:
```sudo reboot``` ### Example Service Configuration #### For user "sn" with ```werkzeug``` WSGI server: ``` [Unit] Description=raspiCamSrv After=network.target [Service] ExecStart=/home/sn/prg/raspi-cam-srv/.venv/bin/flask --app raspiCamSrv run --port 5000 --host=0.0.0.0 Environment="PATH=/home/sn/prg/raspi-cam-srv/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" WorkingDirectory=/home/sn/prg/raspi-cam-srv StandardOutput=inherit StandardError=inherit Restart=always [Install] WantedBy=default.target ``` #### For user "sn" wth ```gunicorn``` WSGI server: ``` [Unit] Description=raspiCamSrv After=network.target [Service] ExecStart=/home/sn/prg/raspi-cam-srv/.venv/bin/gunicorn -b 0.0.0.0:5000 -w 1 -k gthread --threads 6 --timeout 0 --log-level info 'raspiCamSrv:create_app()' Environment="PATH=/home/sn/prg/raspi-cam-srv/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" WorkingDirectory=/home/sn/prg/raspi-cam-srv StandardOutput=inherit StandardError=inherit Restart=always [Install] WantedBy=default.target ``` ================================================ FILE: docs/system_setup.md ================================================ # System Setup for raspiCamSrv [![Up](img/goup.gif)](./getting_started_overview.md) Follow the instructions of the [Raspberry Pi Getting Started Documentation](https://www.raspberrypi.com/documentation/computers/os.html#get-raspberry-pi-os) for OS installation using [Imager](https://www.raspberrypi.com/documentation/computers/getting-started.html#raspberry-pi-imager). **NOTE**: It is recommended to install the Debian distribution proposed by [Imager](https://www.raspberrypi.com/documentation/computers/getting-started.html#raspberry-pi-imager) for your Raspberry Pi device. This is usually the full 64-bit version including desktop. The latest distribution is currently Trixie. RaspiCamSrv can also be installed on Bullseye or Bookworm systems, updated to the latest version. Lite variants of these systems, without desktop, can be used for RPI Zero models which are usually only operated in headless mode. For these, the [Automated Installer](./installation.md) will install all required features which are not included in this distribution. Make sure that SSH is enabled on the Services tab. Once the SD card is written, insert it into the Raspberry Pi and power it up. Initially, it will take several minutes until it is visible in the network. ================================================ FILE: docs/tutorials/AWB_with_neural_networks.md ================================================ # Tutorial: Automatic White Balance with Neural Networks [![Up](../img/goup.gif)](./Tutorials_Overview.md) This tutorial describes how to activate neural network-based AWB (AWB-NN). This is a relatively new feature provided by the Raspberry Pi team which is currently only documented in the Forum article [AWB with Neural Networks](https://forums.raspberrypi.com/viewtopic.php?p=2365911). AWB-NN can currently only be activated through a modification of the tuning file for the camera. If activated, it overrules *AWB* settings in the [Camera Controls](../CameraControls_Image.md). ## Precondition AWB-NN is only available in Trixie with libcamera version 0.7.0+rpt20260205 or later. To check, open the [Info / System](../Information_Sys.md) dialog and check - Hardware and OS / Debial Version - Software Stack / libcamera ## Procedure 1. Open the [Config / Tuning](../Tuning.md) dialog
The dialog will show the tuning file for the active camera located in the standard folder ```/usr/share/libcamera/ipa/rpi/pisp```. 2. Push button **Custom Folder**
which will create a custom folder for tuning files if it does not yet exist (```/home//prg/raspi-cam-srv/raspiCamSrv/static/tuning```) and copy the tuning file to this folder:
![Image1](./img/AWB-NN_01.jpg)
The reason for this is that standard tuning files in the standard folders must not be edited. 3. Push button **Download Tuning File**
This will download the tuning file to your Downloads folder 4. It is recommended to rename the tuning file to indicate that it is used for AWB-NN:
![Image2](./img/AWB-NN_02.jpg) 5. Open the tuning file in your preferred text editor 6. Search for node ```rpi.awb``` and change the element ```"enabled"``` to ```false```:
![Image3](./img/AWB-NN_03.jpg) 7. Now, search for node ```rpi.nn.awb``` and change the element ```"enabled"``` to ```true```:
![Image4](./img/AWB-NN_04.jpg) 8. Save the file 9. In dialog **Tuning**, press button **Select Tuning File for Upload**,
navigate to your Downloads folder and select the modified tuning file.
The button will now show the name of the selected tuning file (if just one file has been selected). 10. Now, push button **Upload selected File**
![Image6](./img/AWB-NN_06.jpg)
This will upload the file to the custom folder for tuning files 11. Change the seleted tuning file to the modified file. Make sure that it is the file for the active camera:
![Image7](./img/AWB-NN_07.jpg) 12. Check *Load Tuning File* and push button **Submit and Apply**
![Image8](./img/AWB-NN_08.jpg) ## Result If the modified tuning file is selected, Picamera2 will load this file when the camera is started. Cited from [AWB with Neural Networks](https://forums.raspberrypi.com/viewtopic.php?p=2365911#p2365911): "The AWB NN algorithm definitely produces different results to the default Bayesian algorithm, the nature of which depends on the training data. Generally, we find it produces significantly better results, though your mileage may vary. Obviously this is quite early days still, so we're interested in people's experiences." ## Deactivate AWB-NN To return to the standard AWB, deactivate *Load Tuning File* and push button **Submit and Apply** ================================================ FILE: docs/tutorials/Tutorials_Overview.md ================================================ # RaspiCamSrv Tutorials [![Up](../img/goup.gif)](../index.md) - [Automatic White Balance with Neural Networks](./AWB_with_neural_networks.md) ================================================ FILE: docs/updating_raspiCamSrv.md ================================================ # Updating raspiCamSrv [![Up](img/goup.gif)](./index.md) Before updating, make sure that - [video recording](./Phototaking.md#video) is stopped - there are no active [photoseries](./PhotoSeries.md) - [triggered capture](./Trigger.md) (motion tracking) is stopped - server will not [start with stored configuration](./SettingsConfiguration.md) The [Settings/Update](./SettingsUpdate.md) dialog is the easiest way for updating. Alternatively, you can configure [Versatile Buttons](./ConsoleVButtons.md) with similar commands as described in the following, so that update and server restart can be initiated directly from the Web UI. (Note that commands issued through Versatile Buttons execute from the root directory in the virtual environment) For update, proceed as follows: (If running a Docker container see [Update Procedure for Docker Container](./SetupDocker.md#update-procedure)) 1. Within a SSH session go to the **raspiCamSrv** root directory ```cd ~/prg/raspi-cam-srv``` 2. If you have made local changes (e.g. logging), you may need to reset the workspace with ```git reset --hard``` 3. If you have created unversioned files, you may need to clean the workspace with ```git clean -fd``` 4. Use [git fetch](https://git-scm.com/docs/git-fetch) to update to the latest version (normally you need to fetch only the ```main``` branch) ```git fetch origin main --depth=1``` As a result, you will see a summary of changes with respect to the previously installed version. 5. Use [git reset](https://git-scm.com/docs/git-reset) to reset the current branch head to origin/main ```git reset --hard origin/main``` As a result, you will see the new HEAD version. 6. Restart the service, depending on [how the service was installed](./service_configuration.md) ```sudo systemctl restart raspiCamSrv.service``` or ```systemctl --user restart raspiCamSrv.service``` 7. Check that the service started correctly ```sudo journalctl -e``` or ```journalctl --user -e``` 8. If you used [start with stored configuration](./SettingsConfiguration.md) before updating, you may now try to activate this again.
In cases where configuration parameters were not modified with the update, this will usually work.
If not, you will need to prepare and store your preferred configuration again. In case that the server did not start correctly or if you see an unexpected behavior in the UI, you may have forgotten to deactivate [start with stored configuration](./SettingsConfiguration.md)
In this case, you can do the following: - ```cd ~/prg/raspi-cam-srv/raspiCamSrv/static/config``` - Check whether a file ```_loadConfigOnStart.txt``` exists in this folder. - If it exists, remove it:
```rm _loadConfigOnStart.txt``` - Then repeat step 4, above ================================================ FILE: mkdocs.yml ================================================ site_name: raspiCamSrv V4.10.x Documentation plugins: - search - mike theme: name: material favicon: img/favicon.ico features: - content.code.copy not_in_nav: | Z_Legacy*.md nav: - Home: index.md - Getting Started: - Overview: getting_started_overview.md - Requirements: requirements.md - System Setup: system_setup.md - Installation: installation.md - Alternatives: - Manual Step by Step Installation: installation_man.md - Service Configuration: service_configuration.md - Running raspiCamSrv as Docker container: SetupDocker.md - Setup of Raspberry Pi Zero as Standalone System: bp_PiZero_Standalone.md - Configuring as Hotspot: - Hotspot Setup for Trixie: bp_Hotspot_Trixie.md - Hotspot Setup for Boookworm: bp_Hotspot_Bookworm.md - Hotspot Setup for Bullseye: bp_Hotspot_Bullseye.md - Trouble Shooting Guide: Troubelshooting.md - Features: features.md - References: - Picamera2 Manual: picamera2_manual.md - Releases: - Release Notes: ReleaseNotes.md - Updating a raspiCamSrv Installation: updating_raspiCamSrv.md - User Guide: - Overview: UserGuide.md - Authentication: Authentication.md - Live Menu: - Live Screen: LiveScreen.md - Camera Controls: - Overview: CameraControls.md - Focus Handling: FocusHandling.md - Zoom/Pan/Tilt: ZoomPan.md - Auto-Exposure: CameraControls_AutoExposure.md - Exposure: CameraControls_Exposure.md - Image: CameraControls_Image.md - Ctrl: CameraControls_Ctrl.md - Camera Controls for USB Cameras: CameraControls_UsbCams.md - Cropping: ScalerCrop.md - Direct Control Panel: LiveDirectControl.md - Photo / Video Taking: Phototaking.md - Config Menu: - raspiCamSrv Camera Configuration: Configuration.md - raspiCamSrv Camera AI Configuration: Configuration_AI.md - Tuning: Tuning.md - Info Menu: - Overview: Information.md - System Information: Information_Sys.md - Installed Cameras: Information_Cam.md - Active Camera Properties: Information_CamPrp.md - Active Camera Sensor Modes: Information_Sensor.md - Photos Menu: - Photo Viewer: PhotoViewer.md - Photo Series Menu: - Photo Series: PhotoSeries.md - Timelapse Series: PhotoSeriesTimelapse.md - Exposure Series: PhotoSeriesExp.md - Focus Stack Series: PhotoSeriesFocus.md - Trigger Menu: - Overview: TriggerOverview.md - Introduction: Trigger.md - Event Handling Control: TriggerControl.md - Trigger Configuration: TriggerTriggers.md - Action Configuration: TriggerActions.md - Trigger/Action Configuration: TriggerTriggerActions.md - Motion Detection Configuration: TriggerMotion.md - Camera Action Configuration: TriggerCameraActions.md - Notification Configuration: TriggerNotification.md - Active Motion Capturing: TriggerActive.md - Event Viewer: TriggerEventViewer.md - Event Calendar: TriggerCalendar.md - Cam Menu: - Overview: Cam.md - Web Cam Dialog: CamWebcam.md - Multi-Cam Dialog: CamMulticam.md - Camera Calibration Dialog: CamCalibration.md - Stereo Camera Dialog: CamStereo.md - Console Menu: - Overview: Console.md - Versatile Buttons: ConsoleVButtons.md - Action Buttons: ConsoleActionButtons.md - Settings Menu: - Settings Parameters: Settings.md - Configuration Management: SettingsConfiguration.md - User Management: SettingsUsers.md - API Settings: SettingsAPI.md - Settings for Versatile Buttons: SettingsVButtons.md - Settings for Action Buttons: SettingsAButtons.md - Settings for Live Buttons: SettingsLButtons.md - GPIO Device Configuration: SettingsDevices.md - GPIO Devices: - Stepper Motor: gpioDevices/StepperMotor.md - Servo Motor: gpioDevices/ServoPWM.md - Update: SettingsUpdate.md - AI Camera Support: AiCameraSupport.md - Background Processes: Background Processes.md - No Camera Mode: - User Guide: UserGuide_NoCam.md - System Information: Information_Sys.md - Settings: Settings_NoCam.md - Configuration Management: SettingsConfiguration_NoCam.md - API: API.md - Tutorials: - Overview: tutorials/Tutorials_Overview.md - Automatic White Balance with Neural Networks: tutorials/AWB_with_neural_networks.md ================================================ FILE: raspiCamSrv/__init__.py ================================================ import os from pathlib import Path from flask import Flask import logging from flask.logging import default_handler from picamera2 import Picamera2 from raspiCamSrv.camera_pi import Camera from raspiCamSrv.motionDetector import MotionDetector from raspiCamSrv.triggerHandler import TriggerHandler import json import datetime import time from werkzeug.serving import is_running_from_reloader def create_app(test_config=None): # create and configure the app app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( SECRET_KEY="dev", DATABASE=os.path.join(app.instance_path, "raspiCamSrv.sqlite"), ) # ensure the instance folder exists try: os.makedirs(app.instance_path) except OSError: pass # Configure loggers logsPath = os.path.dirname(app.instance_path) + "/logs" os.makedirs(logsPath, exist_ok=True) logFile = logsPath + "/raspiCamSrv.log" Path(logFile).touch(exist_ok=True) filehandler = logging.FileHandler(logFile) filehandler.setFormatter(app.logger.handlers[0].formatter) for logger in ( app.logger, logging.getLogger("werkzeug"), logging.getLogger("raspiCamSrv.db"), logging.getLogger("raspiCamSrv.auth"), logging.getLogger("raspiCamSrv.auth_su"), logging.getLogger("raspiCamSrv.camCfg"), logging.getLogger("raspiCamSrv.camera_pi"), logging.getLogger("raspiCamSrv.config"), logging.getLogger("raspiCamSrv.home"), logging.getLogger("raspiCamSrv.images"), logging.getLogger("raspiCamSrv.info"), logging.getLogger("raspiCamSrv.settings"), logging.getLogger("raspiCamSrv.photoseries"), logging.getLogger("raspiCamSrv.photoseriesCfg"), logging.getLogger("raspiCamSrv.trigger"), logging.getLogger("raspiCamSrv.motionDetector"), logging.getLogger("raspiCamSrv.motionAlgoIB"), logging.getLogger("raspiCamSrv.triggerHandler"), logging.getLogger("raspiCamSrv.gpioDevices"), logging.getLogger("raspiCamSrv.webcam"), logging.getLogger("raspiCamSrv.console"), logging.getLogger("raspiCamSrv.sun"), logging.getLogger("raspiCamSrv.api"), logging.getLogger("raspiCamSrv.stereoCam"), ): logger.setLevel(logging.ERROR) # >>>>> Uncomment the following line in order to log to the log file # app.logger.addHandler(filehandler) # >>>>> Explicitely set specific log levels. Leave "werkzeug" at INFO logging.getLogger("werkzeug").setLevel(logging.INFO) # logging.getLogger("raspiCamSrv.auth").setLevel(logging.ERROR) # logging.getLogger("raspiCamSrv.camCfg").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.camera_pi").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.home").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.images").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.webcam").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.trigger").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.photoseriesCfg").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.photoseries").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.sun").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.motionDetector").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.motionAlgoIB").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.triggerHandler").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.settings").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.console").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.api").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.stereoCam").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.info").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.config").setLevel(logging.DEBUG) # logging.getLogger("raspiCamSrv.gpioDevices").setLevel(logging.DEBUG) # >>>>> Set log level for picamera2 (DEBUG, INFO, WARNING, ERROR) Picamera2.set_logging(logging.ERROR) # >>>>> Uncomment the following line to let Picamera2 log to the log file # logging.getLogger("picamera2").addHandler(filehandler) # >>>>> Set log level for libcamera (0:DEBUG, 1:INFO, 2:WARNING, 3:ERROR, 4:FATAL) os.environ["LIBCAMERA_LOG_LEVELS"] = "*:3" # Configure the logger for generation of program code # This logger generates an executable Picamera2 Python application program # including the entire interaction with Picamera2 during a server run prgOutPath = os.path.dirname(app.instance_path) + "/output" os.makedirs(prgOutPath, exist_ok=True) prgLogger = logging.getLogger("pc2_prg") prgLogPath = os.path.dirname(app.instance_path) + "/logs" prgLogTime = datetime.datetime.now() prgLogFilename = "prgLog_" + prgLogTime.strftime("%Y%m%d_%H%M%S") + ".log" prgLogFile = prgLogPath+ "/" + prgLogFilename # >>>>> Uncomment the following 5 lines when code generation is activated (see below) # Path(prgLogFile).touch(exist_ok=True) # prgFilehandler = logging.FileHandler(prgLogFile) # prgFormatter = logging.Formatter('%(message)s') # prgFilehandler.setFormatter(prgFormatter) # prgLogger.addHandler(prgFilehandler) # >>>>> To activate Python code generation, set level to DEBUG # prgLogger.setLevel(logging.DEBUG) if test_config is None: # load the instance config, if it exists, when not testing app.config.from_pyfile("config.py", silent=True) else: # load the test config if passed in app.config.from_mapping(test_config) # Make database available in the application context from . import db db.init_app(app) # Configure Config from . import camCfg from . import settings cfg = camCfg.CameraCfg() sc = cfg.serverConfig sc.photoRoot = app.static_folder sc.prgOutputPath = prgOutPath sc.checkEnvironment() # Wait for system time syncronization sc.wait_for_time_sync() serverStartTime = sc.serverStartTime if sc.supportsExtMotionDetection == False: cfg.triggerConfig.motionDetectAlgos = ["Mean Square Diff",] cfgPath = app.static_folder + "/config" sc.cfgPath = cfgPath if settings.getLoadConfigOnStart(cfgPath): cfg.loadConfig(cfgPath) cfg = camCfg.CameraCfg() sc = cfg.serverConfig sc.serverStartTime = serverStartTime sc.cfgPath = cfgPath sc.cfgBackupPath = os.path.dirname(app.instance_path) + "/backups" sc.checkEnvironment() sc.database = os.path.join(app.instance_path, "raspiCamSrv.sqlite") stc = cfg.stereoCfg stc.calibPhotosPath = app.static_folder + "/calib_photos/" stc.calibPhotosSubPath = "calib_photos/" stc.calibDataSubPath = "calib_data/" stc.calibDataFile = "calib_params.xml" cam = Camera() cfg.setSupportedCameras() cfg.setPiCameras() # For testiing multi-camera features: # sc.piCameras.pop(1) # Check display photo and buffer sc.displayBufferCheck() # Check Latest version sc.getLatestVersion(now=True) # Configure Triggered Capture tcActionPath = app.static_folder + "/events" os.makedirs(tcActionPath, exist_ok=True) tc = cfg.triggerConfig tc.actionPath = tcActionPath Path(tc.logFilePath).touch(exist_ok=True) # Configure Photoseries from . import photoseriesCfg tlRootPath = app.static_folder + "/photoseries" os.makedirs(tlRootPath, exist_ok=True) tlCfg = photoseriesCfg.PhotoSeriesCfg() tlCfg.rootPath = tlRootPath tlCfg.initFromTlFolder() tlCfg = photoseriesCfg.PhotoSeriesCfg() # Restart an active series if requested if tlCfg.hasCurSeries: sr = tlCfg.curSeries if sr.status == "ACTIVE": if sr.isExposureSeries == False \ and sr.isFocusStackingSeries == False: if sr.continueOnServerStart == True: sr.nextStatus("pause") # Start live stream in order to load lowres config for later live stream compatibility Camera().startLiveStream() time.sleep(2) Camera().startPhotoSeries(sr) time.sleep(2) if sc.error is None and sr.error is None: sr.nextStatus("start") else: sr.nextStatus("pause") else: sr.nextStatus("pause") # Autostart triggered capture, if configured if tc.operationAutoStart == True: if tc.triggeredByMotion == True: MotionDetector().startMotionDetection() sc.isTriggerRecording = True if tc.triggeredByEvents == True: TriggerHandler().start() sc.isEventhandling = True # Register required blueprints from . import auth app.register_blueprint(auth.bp) from . import home app.register_blueprint(home.bp) app.add_url_rule("/", endpoint="index") from . import config app.register_blueprint(config.bp) from . import images app.register_blueprint(images.bp) from . import info app.register_blueprint(info.bp) from . import settings app.register_blueprint(settings.bp) from . import photoseries app.register_blueprint(photoseries.bp) from . import trigger app.register_blueprint(trigger.bp) from . import webcam app.register_blueprint(webcam.bp) from . import console app.register_blueprint(console.bp) if sc.useAPI == True: from . import api app.register_blueprint(api.bp) from flask_jwt_extended import JWTManager if sc.jwtAuthenticationActive == False: sc.API_active = False else: sc.API_active = True app.config["JWT_SECRET_KEY"] = cfg.secrets.jwtSecretKey if sc.jwtAccessTokenExpirationMin > 0: app.config["JWT_ACCESS_TOKEN_EXPIRES"] = datetime.timedelta(minutes=sc.jwtAccessTokenExpirationMin) if sc.jwtRefreshTokenExpirationDays > 0: app.config["JWT_REFRESH_TOKEN_EXPIRES"] = datetime.timedelta(days=sc.jwtRefreshTokenExpirationDays) jwt = JWTManager(app) return app ================================================ FILE: raspiCamSrv/api.py ================================================ from flask import Blueprint, request, jsonify from werkzeug.security import check_password_hash from werkzeug.exceptions import abort from raspiCamSrv.db import get_db from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg, TuningConfig from raspiCamSrv.photoseriesCfg import PhotoSeriesCfg from _thread import get_ident import datetime import time from raspiCamSrv.motionDetector import MotionDetector from raspiCamSrv.triggerHandler import TriggerHandler from raspiCamSrv.version import version from raspiCamSrv.home import generateHistogram from raspiCamSrv.auth import login_required import logging # Try to import flask_jwt_extended to avoid errors when upgrading to V2.11 from earlier versions try: from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity except ImportError: pass bp = Blueprint("api", __name__) logger = logging.getLogger(__name__) @bp.route('/api/login', methods=['POST']) def login(): data = request.get_json() username = data.get("username") password = data.get("password") db = get_db() error = None user = db.execute( "SELECT * FROM user WHERE username = ?", (username,) ).fetchone() if user is None: error = "Invalid username or password" elif not check_password_hash(user["password"], password): error = "Invalid username or password" if error is None: if len(user) == 5: if user["isinitial"] == 1: error = "Password change required. Please log in through UI!" if error is None: cfg = CameraCfg() sc = cfg.serverConfig access_token = create_access_token(identity=username) if sc.jwtAccessTokenExpirationMin == 0: return jsonify(access_token=access_token) else: refresh_token = create_refresh_token(identity=username) return jsonify(access_token=access_token, refresh_token=refresh_token) return jsonify({"error": error}), 401 @bp.route('/api/refresh', methods=['POST']) @jwt_required(refresh=True) def refresh(): current_user = get_jwt_identity() new_access_token = create_access_token(identity=current_user) return jsonify(access_token=new_access_token) @bp.route('/api/protected', methods=['GET']) @jwt_required() def protected(): current_user = get_jwt_identity() return jsonify(message=f"Hello, {current_user}! You accessed a protected route.") @bp.route("/api/take_photo", methods=["GET"]) @jwt_required() def take_photo(): logger.debug("Thread %s: In /api/take_photo", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Saving image %s", filename) fp = Camera().takeImage(filename) if not sc.error: logger.debug("take_photo - success") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) msg=f"Photo taken: {fp}" return jsonify(message=msg) else: msg = "Error in " + sc.errorSource + ": " + sc.error return jsonify(message=msg), 500 @bp.route("/api/take_photo2", methods=["GET"]) @jwt_required() def take_photo2(): logger.debug("Thread %s: In /api/take_photo2", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Saving image %s", filename) fp = Camera().takeImage2(filename) if not sc.errorc2: logger.debug("take_photo2 - success") msg=f"Photo taken: {fp}" return jsonify(message=msg) else: msg = "Error in " + sc.errorc2Source + ": " + sc.errorc2 return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("take_photo2 - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/take_photo_both", methods=["GET"]) @jwt_required() def take_photo_both(): logger.debug("Thread %s: In /api/take_photo_both", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Saving image2 %s", filename) fp1 = Camera().takeImage(filename) fp2 = Camera().takeImage2(filename) msg = {} err = False if not sc.error: logger.debug("takeImage - success") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) msg["Photo1"] = fp1 else: msg["Photo1"] = "Error in " + sc.errorcSource + ": " + sc.errorc err = True if not sc.errorc2: logger.debug("takeImage2 - success") msg["Photo2"] = fp2 else: msg["Photo2"] = "Error in " + sc.errorc2Source + ": " + sc.errorc2 err = True if not err: return jsonify(message=msg) else: return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("take_photo_both - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/take_raw_photo", methods=["GET"]) @jwt_required() def take_raw_photo(): logger.debug("Thread %s: In /api/take_raw_photo", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType filenameRaw = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.rawPhotoType logger.debug("Saving raw image %s", filenameRaw) fp = Camera().takeRawImage(filenameRaw, filename) if not sc.error: logger.debug("take_raw_photo - success") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) msg = f"Raw photo taken: {fp}" return jsonify(message=msg) else: msg = "Error in " + sc.errorSource + ": " + sc.error return jsonify(message=msg), 500 @bp.route("/api/take_raw_photo2", methods=["GET"]) @jwt_required() def take_raw_photo2(): logger.debug("Thread %s: In /api/take_raw_photo2", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType filenameRaw = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.rawPhotoType logger.debug("Saving raw image %s", filenameRaw) fp = Camera().takeRawImage2(filenameRaw, filename) if not sc.error: logger.debug("take_raw_photo2 - success") msg=f"Raw photo taken: {fp}" return jsonify(message=msg) else: msg = "Error in " + sc.errorc2Source + ": " + sc.errorc2 return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("take_raw_photo2 - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/take_raw_photo_both", methods=["GET"]) @jwt_required() def take_raw_photo_both(): logger.debug("Thread %s: In /api/take_raw_photo_both", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType filenameRaw = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.rawPhotoType logger.debug("Saving raw images %s", filenameRaw) fp1 = Camera().takeRawImage(filenameRaw, filename) fp2 = Camera().takeRawImage2(filenameRaw, filename) msg = {} err = False if not sc.error: logger.debug("takeRawImage - success") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) msg["Photo1"] = fp1 else: msg["Photo1"] = "Error in " + sc.errorcSource + ": " + sc.errorc err = True if not sc.errorc2: logger.debug("takeRawImage2 - success") msg["Photo2"] = fp2 else: msg["Photo2"] = "Error in " + sc.errorc2Source + ": " + sc.errorc2 err = True if not err: return jsonify(message=msg) else: return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("take_raw_photo_both - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/start_triggered_capture", methods=["GET"]) @jwt_required() def start_triggered_capture(): logger.debug("In /api/start_triggered_capture") cfg = CameraCfg() sc = cfg.serverConfig tc = cfg._triggerConfig if tc.triggeredByMotion \ or tc.triggeredByEvents: if tc.triggeredByMotion: MotionDetector().setAlgorithm() MotionDetector().startMotionDetection() if tc.triggeredByEvents: TriggerHandler().start() if sc.error: msg = "Error in " + sc.errorSource + ": " + sc.error return jsonify(message=msg), 500 elif tc.error: msg = "Error in " + tc.errorSource + ": " + tc.error return jsonify(message=msg), 500 else: if tc.triggeredByMotion: sc.isTriggerRecording = True msg = "Motion detection started" if tc.triggeredByEvents: sc.isEventhandling = True msg = "Event handling started" if tc.triggeredByMotion \ and tc.triggeredByEvents: msg = "Motion detection and event handlinfg started" return jsonify(message=msg) else: msg = "There is no trigger activated" return jsonify(message=msg), 500 @bp.route("/api/stop_triggered_capture", methods=["GET"]) @jwt_required() def stop_triggered_capture(): logger.debug("In /api/stop_triggered_capture") cfg = CameraCfg() sc = cfg.serverConfig tc = cfg._triggerConfig if tc.triggeredByMotion \ or tc.triggeredByEvents: if sc.isTriggerRecording: MotionDetector().stopMotionDetection() sc.isTriggerRecording = False msg = "Motion detection stopped" if sc.isEventhandling: TriggerHandler().stop() sc.isEventhandling = False msg = "Event handling stopped" if sc.isTriggerRecording \ and sc.isEventhandling: msg = "Motion detection and event handling stopped" return jsonify(message=msg) else: msg = "There is no trigger activated" return jsonify(message=msg), 500 @bp.route("/api/info", methods=["GET"]) @jwt_required() def info(): logger.debug("In /api/info") cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tc = cfg.triggerConfig info = {} info["version"] = "raspiCamSrv " + version info["server"] = request.host info["active_camera"] = sc.activeCameraInfo infoCams = [] cams = cfg.cameras logger.debug("/api/info - cams: %s", cams) for cam in cams: infoCam = {} infoCam["num"] = cam.num infoCam["model"] = cam.model infoCam["is_usb"] = cam.isUsb if cam.num == sc.activeCamera: infoCam["active"] = True else: infoCam["active"] = False infoCam["status"] = Camera.cameraStatus(cam.num) infoCams.append(infoCam) info["cameras"] = infoCams infoStatus = {} infoStatus["livestream_active"] = sc.isLiveStream if len(cams) > 1: infoStatus["livestream2_active"] = sc.isLiveStream2 infoStatus["photoseries_recording"] = sc.isPhotoSeriesRecording infoStatus["motion_capturing"] = sc.isTriggerRecording == True and tc.triggeredByMotion == True infoStatus["event_handling"] = sc.isEventhandling == True and sc.isEventsWaiting == False infoStatus["video_recording"] = sc.isVideoRecording infoStatus["audio_recording"] = sc.isAudioRecording if len(cams) > 1: infoStatus["video_recording2"] = sc.isVideoRecording2 info["operation_status"] = infoStatus return jsonify(message=info) @bp.route("/api/switch_cameras", methods=["GET"]) @jwt_required() def switch_cameras(): logger.debug("In /api/switch_cameras") cfg = CameraCfg() sc = cfg.serverConfig str2 = None if sc.isLiveStream2: str2 = cfg.streamingCfg[str(Camera().camNum2)] msg = None cs = cfg.cameras activeCam = sc.activeCamera newCam = activeCam for cm in cs: if cm.isUsb == False: if activeCam != cm.num: newCam = cm.num newCamInfo = "Camera " + str(cm.num) + " (" + cm.model + ")" newCamModel = cm.model break if newCam != sc.activeCamera: if sc.isTriggerRecording: msg = "Cameras cannot be switched because triggered capturing is active" if sc.isVideoRecording == True: msg = "Cameras cannot be switched because trigvideorecording is active" if sc.isPhotoSeriesRecording: msg = "Cameras cannot be switched because photo series recording is active" if not msg: sc.activeCameraInfo = newCamInfo sc.activeCameraModel = newCamModel cfg.liveViewConfig.stream_size = None cfg.photoConfig.stream_size = None cfg.rawConfig.stream_size = None cfg.videoConfig.stream_size = None sc.activeCamera = newCam strCfg = cfg.streamingCfg newCamStr = str(newCam) if newCamStr in strCfg: ncfg = strCfg[newCamStr] if "tuningconfig" in ncfg: cfg.tuningConfig = ncfg["tuningconfig"] else: cfg.tuningConfig = TuningConfig() else: cfg.tuningConfig = TuningConfig() Camera.switchCamera() if sc.isLiveStream2: str2 = cfg.streamingCfg[str(Camera().camNum2)] logger.debug("/api/switch_cameras - active camera set to %s", sc.activeCamera) else: msg = "No other camera available" if msg: return jsonify(message=msg), 500 else: msg = "Camera switch successful" return jsonify(message=msg) @bp.route("/api/record_video", methods=["GET"]) @jwt_required() def record_video(): logger.debug("Thread %s: In /api/record_video", get_ident()) data = request.get_json() duration = 0 if "duration" in data: duration = data.get("duration") logger.debug("Thread %s: /api/record_video - requested duration: %s", get_ident(), duration) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties timeImg = datetime.datetime.now() filenameVid = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.videoType filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Recording a video %s", filenameVid) fp = Camera().recordVideo(filenameVid, filename, duration) time.sleep(4) if not sc.error: # Check whether video is being recorded if Camera.isVideoRecording(): logger.debug("Video recording started") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) sc.isVideoRecording = True if sc.recordAudio: sc.isAudioRecording = True msg="Video recorded to " + fp return jsonify(message=msg) else: logger.debug("Video recording did not start") sc.isVideoRecording = False sc.isAudioRecording = False msg="Video recording failed. Requested resolution too high" return jsonify(message=msg), 500 else: msg = "Error in " + sc.errorSource + ": " + sc.error return jsonify(message=msg), 500 @bp.route("/api/stop_video", methods=["GET"]) @jwt_required() def stop_video(): logger.debug("Thread %s: In /api/stop_video", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig if sc.isVideoRecording == True: fp = Camera().videoOutput Camera().stopVideoRecording() time.sleep(1) msg = {"Video": fp, "Status": "Stopped"} sc.isVideoRecording = False return jsonify(message=msg) else: msg = "No video recording in progress" return jsonify(message=msg), 500 @bp.route("/api/record_video2", methods=["GET"]) @jwt_required() def record_video2(): logger.debug("Thread %s: In /api/record_video2", get_ident()) if Camera().isCamera2Available(): data = request.get_json() duration = 0 if "duration" in data: duration = data.get("duration") logger.debug("Thread %s: /api/record_video2 - requested duration: %s", get_ident(), duration) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties timeImg = datetime.datetime.now() filenameVid = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.videoType filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Recording a video %s", filenameVid) fp = Camera().recordVideo2(filenameVid, filename, duration) time.sleep(4) if not sc.errorc2: # Check whether video is being recorded if Camera.isVideoRecording2(): logger.debug("Video recording 2 started") sc.isVideoRecording2 = True msg = "Video recorded to " + fp return jsonify(message=msg) else: logger.debug("Video recording 2 did not start") sc.isVideoRecording2 = False msg = "Video recording failed. Requested resolution too high" return jsonify(message=msg), 500 else: msg = "Error in " + sc.errorc2Source + ": " + sc.errorc2 return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("record_video2 - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/stop_video2", methods=["GET"]) @jwt_required() def stop_video2(): logger.debug("Thread %s: In /api/stop_video2", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig if sc.isVideoRecording2 == True: fp = Camera().videoOutput2 Camera().stopVideoRecording2() time.sleep(1) msg = {"Video": fp, "Status": "Stopped"} sc.isVideoRecording2 = False return jsonify(message=msg) else: msg = "No video recording in progress" return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("stop_video2 - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/record_video_both", methods=["GET"]) @jwt_required() def record_video_both(): logger.debug("Thread %s: In /api/record_video_both", get_ident()) if Camera().isCamera2Available(): data = request.get_json() duration = 0 if "duration" in data: duration = data.get("duration") logger.debug("Thread %s: /api/record_video_both - requested duration: %s", get_ident(), duration) cfg = CameraCfg() sc = cfg.serverConfig timeImg = datetime.datetime.now() filenameVid = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.videoType filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Recording 2 videos %s", filenameVid) fp1 = Camera().recordVideo(filenameVid, filename, duration) fp2 = Camera().recordVideo2(filenameVid, filename, duration) time.sleep(4) msg = {} err = False if not sc.error: # Check whether video is being recorded if Camera.isVideoRecording(): logger.debug("Video recording 1 started") if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) sc.isVideoRecording = True msg["Video 1"] = fp1 else: logger.debug("Video recording 1 did not start") sc.isVideoRecording = False msg["Video 1"] = "Video recording failed" err = True else: err = True msg["Video 1"] = "Error in " + sc.errorSource + ": " + sc.error if not sc.errorc2: # Check whether video is being recorded if Camera.isVideoRecording2(): logger.debug("Video recording 2 started") sc.isVideoRecording2 = True msg["Video 2"] = fp2 else: logger.debug("Video recording 2 did not start") sc.isVideoRecording2 = False msg["Video 2"] = "Video recording failed" err = True else: msg["Video 2"] = "Error in " + sc.errorc2Source + ": " + sc.errorc2 err = True if err == False: return jsonify(message=msg) else: return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("record_video_both - %s", msg) return jsonify(message=msg), 500 @bp.route("/api/stop_video_both", methods=["GET"]) @jwt_required() def stop_video_both(): logger.debug("Thread %s: In /api/stop_video_both", get_ident()) if Camera().isCamera2Available(): cfg = CameraCfg() sc = cfg.serverConfig msg = {} err = False if sc.isVideoRecording == False: msg["Video 1"] = "No video recording in progress for camera 1" err = True else: fp1 = Camera().videoOutput Camera().stopVideoRecording() sc.isVideoRecording = False msg["Video 1"] = {"Video": fp1, "Status": "Stopped"} if sc.isVideoRecording2 == False: msg["Video 2"] = "No video recording in progress for camera 2" err = True else: fp2 = Camera().videoOutput2 Camera().stopVideoRecording2() sc.isVideoRecording2 = False msg["Video 2"] = {"Video": fp2, "Status": "Stopped"} time.sleep(1) if err == False: return jsonify(message=msg) else: return jsonify(message=msg), 500 else: msg = "Second camera is not available" logger.error("stop_video_both - %s", msg) return jsonify(message=msg), 500 def propGen(property): """Generator to yield properties of a property separated by dot.""" while len(property) > 0: p = property.find(".") if p >= 0: if p == 0: method = "" else: method = property[:p] property = property[p + 1 :] else: method = property property = "" params = [] ps = method.find("(") if ps >= 0: pe = method.find(")", ps) if pe < 0: raise ValueError("Missing closing parenthesis in method: " + method) else: pars = method[ps + 1 : pe] if len(pars) > 0: params = [p.strip() for p in pars.split(",")] method = method[:ps] yield (method, params, len(property) == 0) def probeTerm(property): """Evaluate a property.""" logger.debug("Thread %s: In probeTerm - property=%s", get_ident(), property) res = None obj = None for prop, params, last in propGen(property): logger.debug("Thread %s: In probeTerm - prop=%s, params=%s, last=%s", get_ident(), prop, params, last) if obj is None: if len(params) > 0: obj = globals()[prop](**params) else: obj = globals()[prop]() if last == True: res = obj logger.debug("Thread %s: In probeTerm - Instantiated %s(%s)", get_ident(), prop, params) else: if hasattr(obj, prop): method = getattr(obj, prop) if callable(method): logger.debug("Thread %s: In probeTerm - Calling method %s with params %s", get_ident(), prop, params) if last == True: if len(params) > 0: res = method(*params) else: res = method() else: if len(params) > 0: obj = method(*params) else: obj = method() else: logger.debug("Thread %s: In probeTerm - Accessing property %s", get_ident(), prop) if last == True: res = method else: obj = method if obj is None: logger.debug("Thread %s: In probeTerm - obj is None after accessing %s", get_ident(), prop) break else: raise AttributeError(f"Object {obj} has no attribute {prop}") try: result = jsonify(res) except TypeError as e: if hasattr(res, "toDict"): res = res.toDict() elif hasattr(res, "__dict__"): res = res.__dict__ else: logger.error("Error in probeTerm - jsonify(res), error: %s", str(e)) res = "Error : " + str(e) except Exception as e: logger.error("Error in probeTerm - jsonify(res), error: %s", str(e)) res = "Error : " + str(e) return res @bp.route("/api/probe", methods=["GET"]) @jwt_required() def probe(): logger.debug("Thread %s: In /api/probe", get_ident()) if CameraCfg().serverConfig.useStereo == True: from raspiCamSrv.stereoCam import StereoCam result = {} data = request.get_json() if "properties" in data: properties = data.get("properties") else: result["error"] = "No properties provided" return jsonify(result), 400 if len(properties) == 0: result["error"] = "properties must not be empty" return jsonify(result), 400 results = [] result["results"] = results for t in properties: property = t["property"] logger.debug("Thread %s: In api/probe - property:%2s", get_ident(), property) res = {} try: res[property] = probeTerm(property) except Exception as e: logger.error("Error in api/probe - property: %s, error: %s", property, str(e)) res["property"] = "ERROR:" + str(e) results.append(res) return jsonify(result) ================================================ FILE: raspiCamSrv/auth.py ================================================ import functools from raspiCamSrv.version import version from flask import ( Blueprint, flash, g, redirect, render_template, request, session, url_for, ) from werkzeug.security import check_password_hash, generate_password_hash from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.db import get_db from raspiCamSrv.auth_su import superuser_required import logging bp = Blueprint("auth", __name__, url_prefix="/auth") logger = logging.getLogger(__name__) @bp.route("/register", methods=("GET", "POST")) @superuser_required def register(): logger.debug("In register") g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties sc.curMenu = "register" if request.method == "POST": username = request.form["username"] password = request.form["password"] db = get_db() # Get number of registered users nrUsers = 0 try: nrUsers = db.execute("SELECT COUNT(*) from user").fetchone()[0] except db.Error as e: logger.error("Database error: %s", e) nrUsers = 0 logger.debug("Found %s users", nrUsers) error = None if not username: error = "Username is required." elif not password: error = "Password is required." if error is None: if nrUsers == 0: isSuperUser = 1 isInitial = 0 else: isSuperUser = 0 isInitial = 1 schemaOK = True try: db.execute( "INSERT INTO user (username, password, issuperuser, isinitial) VALUES (?, ?, ?, ?)", (username, generate_password_hash(password), isSuperUser, isInitial), ) db.commit() logger.debug("Insert with new schema OK") except db.IntegrityError: error = f"User {username} is already registered." except db.OperationalError: logger.debug("Got OperationalError") schemaOK = False if not schemaOK: # Try with old db schema logger.debug("Traying with old schema") try: db.execute( "INSERT INTO user (username, password) VALUES (?, ?)", (username, generate_password_hash(password)), ) db.commit() logger.debug("Insert with old schema OK") except db.IntegrityError: error = f"User {username} is already registered." if error is None: logger.debug("g.user: %s", g.user) if g.user: return redirect(url_for("settings.main")) else: return redirect(url_for("auth.login")) flash(error) return render_template("auth/register.html", sc=sc, cp=cp) @bp.route("/login", methods=("GET", "POST")) def login(): g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties sc.curMenu = "login" if request.method == "POST": username = request.form["username"] password = request.form["password"] db = get_db() error = None user = db.execute( "SELECT * FROM user WHERE username = ?", (username,) ).fetchone() if user is None: error = "Incorrect username." elif not check_password_hash(user["password"], password): error = "Incorrect password." if error is None: if len(user) == 5: if user["isinitial"] == 1: return redirect(url_for("auth.password")) else: session.clear() session["user_id"] = user["id"] return redirect(url_for("index")) else: session.clear() session["user_id"] = user["id"] return redirect(url_for("index")) flash(error) return render_template("auth/login.html", sc=sc, cp=cp) @bp.route("/password", methods=("GET", "POST")) def password(): g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties sc.curMenu = "password" if request.method == "POST": username = request.form["username"] oldpassword = request.form["oldpassword"] newpassword = request.form["newpassword"] newpassword2 = request.form["newpassword2"] db = get_db() error = None user = db.execute( "SELECT * FROM user WHERE username = ?", (username,) ).fetchone() if user is None: error = "Incorrect username." elif not check_password_hash(user["password"], oldpassword): error = "Old password is not correct." if error is None: if len(newpassword) <= 1: error = "New password too short. Must be at least 2 characters." elif newpassword2 != newpassword: error = "New password repetition incorrect." if error is None: schemaOK = True isInitial = 0 try: db.execute( "UPDATE user SET password = ?, isinitial = ? WHERE id = ?", (generate_password_hash(newpassword), isInitial, user["id"]), ) db.commit() logger.debug("Update with new schema OK") except db.IntegrityError as e: error = f"Error {e} during update." except db.OperationalError: logger.debug("Got OperationalError") schemaOK = False if not schemaOK: # Try with old db schema logger.debug("Traying with old schema") try: db.execute( "UPDATE user SET password = ? WHERE id = ?", (generate_password_hash(newpassword), user["id"]), ) db.commit() logger.debug("Update with old schema OK") except db.IntegrityError as e: error = f"Error {e} during update." if error is None: return redirect(url_for("auth.login")) else: flash(error) return render_template("auth/password.html", sc=sc, cp=cp) @bp.before_app_request def load_logged_in_user(): logger.debug("In load_logged_in_user") user_id = session.get("user_id") logger.debug("user_id (session): %s ", user_id) if user_id is None: g.user = None else: userdb = ( get_db().execute("SELECT * FROM user WHERE id = ?", (user_id,)).fetchone() ) logger.debug("userdb: %s", userdb) if userdb == None: g.user = None session.clear() else: user = {} user["id"] = userdb["id"] user["username"] = userdb["username"] if len(userdb) == 5: user["issuperuser"] = userdb["issuperuser"] user["isinitial"] = userdb["isinitial"] else: user["issuperuser"] = 1 user["isinitial"] = 0 g.user = user logger.debug("Current user: %s", g.user) g.nrUsers = get_db().execute("SELECT count(*) FROM user").fetchone()[0] logger.debug("Found %s users", g.nrUsers) usersdb = get_db().execute("SELECT * FROM user").fetchall() users = [] for userdb in usersdb: user = {} user["id"] = userdb["id"] user["username"] = userdb["username"] if len(userdb) == 5: user["issuperuser"] = userdb["issuperuser"] user["isinitial"] = userdb["isinitial"] users.append(user) g.users = users logger.debug("g.users: %s", g.users) @bp.route("/logout") def logout(): session.clear() return redirect(url_for("index")) def login_required(view): @functools.wraps(view) def wrapped_view(**kwargs): logging.getLogger("werkzeug").setLevel(logging.ERROR) if g.user is None: db = get_db() nrUsers = 0 try: nrUsers = db.execute("SELECT COUNT(*) from user").fetchone()[0] except db.Error as e: logger.error("Database error: %s", e) nrUsers = 0 if nrUsers == 0: return redirect(url_for("auth.register")) else: return redirect(url_for("auth.login")) return view(**kwargs) return wrapped_view def login_for_streaming(view): @functools.wraps(view) def wrapped_view(**kwargs): logging.getLogger("werkzeug").setLevel(logging.ERROR) sc = CameraCfg().serverConfig if sc.requireAuthForStreaming == True: if g.user is None: db = get_db() nrUsers = 0 try: nrUsers = db.execute("SELECT COUNT(*) from user").fetchone()[0] except db.Error as e: logger.error("Database error: %s", e) nrUsers = 0 if nrUsers == 0: return redirect(url_for("auth.register")) else: return redirect(url_for("auth.login")) return view(**kwargs) return wrapped_view ================================================ FILE: raspiCamSrv/auth_su.py ================================================ import functools from flask import ( g, redirect, url_for, ) from werkzeug.security import check_password_hash, generate_password_hash from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.db import get_db import logging logger = logging.getLogger(__name__) def superuser_required(view): @functools.wraps(view) def wrapped_view(**kwargs): logger.debug("superuser_required. g.user: %s", g.user) if g.user is None: db = get_db() nrUsers = 0 try: nrUsers = db.execute("SELECT COUNT(*) from user").fetchone()[0] except db.Error as e: logger.error("Database error: %s", e) nrUsers = 0 if nrUsers > 0: logger.debug("found %s users. Redirecting to login", nrUsers) return redirect(url_for("auth.login")) else: if g.user["issuperuser"] == 0: logger.debug("Logged-In user is not SuperUser. Redirecting to index") return redirect(url_for("index")) logger.debug("Allowing access") return view(**kwargs) return wrapped_view ================================================ FILE: raspiCamSrv/camCfg.py ================================================ import subprocess import importlib from subprocess import CalledProcessError import json import logging import os import re from ast import literal_eval from pathlib import Path from datetime import datetime from datetime import date from datetime import time from datetime import timedelta import raspiCamSrv.dbx as dbx from raspiCamSrv.gpioDeviceTypes import gpioDeviceTypes from raspiCamSrv import versionDoc from raspiCamSrv.version import version as currentVersion import smtplib import zoneinfo from secrets import token_urlsafe import threading from time import sleep import importlib import requests import psutil logger = logging.getLogger(__name__) class GPIODevice(): def __init__(self): self._id = "" self._usage = "" self._type = "" self._params = {} self._usedPins = "" self._isOk = False self._docUrl = "" self._needsCalibration = False self._isCalibrating = False self._deviceStatePath = "" cfg = CameraCfg() sc = cfg.serverConfig self._deviceStatePath = sc.cfgPath + "/device_state" os.makedirs(self._deviceStatePath, exist_ok=True) self._deviceStateFile = "" @property def id(self) -> str: return self._id @id.setter def id(self, value: str): self._id = value self._deviceStateFile = self._deviceStatePath + "/" + self._id + ".json" @property def usage(self) -> str: return self._usage @usage.setter def usage(self, value: str): self._usage = value @property def type(self) -> str: return self._type @type.setter def type(self, value: str): self._type = value @property def params(self) -> dict: return self._params @params.setter def params(self, value: dict): self._params = value @property def usedPins(self) -> str: return self._usedPins @usedPins.setter def usedPins(self, value: str): self._usedPins = value @property def isOk(self) -> bool: return self._isOk @isOk.setter def isOk(self, value: bool): self._isOk = value @property def docUrl(self) -> str: url = self._docUrl if url.find("/latest/") >= 0: url = url.replace("/latest/", f"/{versionDoc.docversion}/") return url @docUrl.setter def docUrl(self, value: str): self._docUrl = value @property def isCalibrating(self) -> bool: return self._isCalibrating @isCalibrating.setter def isCalibrating(self, value: bool): self._isCalibrating = value @property def needsCalibration(self) -> bool: return self._needsCalibration @needsCalibration.setter def needsCalibration(self, value: bool): self._needsCalibration = value def trackState(self, devObject:object) ->bool: """ Track the state of a GPIO device for which calibration is required The device object is expected to have the following attributes: - value The state is persisted in file Args: devObject (object): device object to track Returns: bool: True if the state is tracked successfully, False otherwise """ logger.debug("GPIODevice.trackState - entry") res = False state = {} if self._needsCalibration: if hasattr(devObject, "value"): try: value = getattr(devObject, "value") state["value"] = value logger.debug("GPIODevice.trackState - tracking value %s in file %s", value, self._deviceStateFile) with open(self._deviceStateFile, "w") as f: json.dump(state, f) res = True except Exception as e: logger.error("GPIODevice.trackState: Error %s tracking device state: %s", type(e), e) return res def setState(self, devObject:object) ->bool: """ Set the state of a GPIO device for which calibration is required The device object is expected to have the following attributes: - value The state is read from file Args: devObject (object): device object to track Returns: bool: True if the state is trasetcked successfully, False otherwise """ logger.debug("GPIODevice.setState - entry") res = False state = {} if self._needsCalibration: try: with open(self._deviceStateFile, "r") as f: state = json.load(f) logger.debug("GPIODevice.trackState - read from file %s : %s",self._deviceStateFile, state) if "value" in state: setattr(devObject, "value", state["value"]) res = True except FileNotFoundError: # If state has not yet been persisted, keep default state pass except Exception as e: logger.error("GPIODevice.setState: Error %s setting device state: %s", type(e), e) return res def getState(self) -> dict: """ Get the state of a GPIO device for which calibration is required The device object is expected to have the following attributes: - value The state is read from file Returns: dict: The state of the device """ logger.debug("GPIODevice.getState - entry") state = {} if self._needsCalibration: try: with open(self._deviceStateFile, "r") as f: state = json.load(f) logger.debug("GPIODevice.getState - read from file %s : %s",self._deviceStateFile, state) except FileNotFoundError: # If state has not yet been persisted, keep default state pass except Exception as e: logger.error("GPIODevice.getState: Error %s getting device state: %s", type(e), e) return state def getUncalibratedState(self) -> dict: """ Get the uncalibrated state of a GPIO device for which calibration is required The device object is expected to have the following attributes: - value The calibrated state is read from file This is then adjusted depending on the calibration type: - For devices having anternal state (e.g. a servo), the calibration velue of the device is added - For devices without internal state (e.g. a stepper motor), the current state is returned Returns: dict: The uncalibrated state of the device """ logger.debug("GPIODevice.getUncalibratedState - entry") state = {} if self._needsCalibration: try: with open(self._deviceStateFile, "r") as f: state = json.load(f) logger.debug("GPIODevice.getUncalibratedState - read from file %s : %s",self._deviceStateFile, state) except FileNotFoundError: # If state has not yet been persisted, keep default state pass except Exception as e: logger.error("GPIODevice.getUncalibratedState: Error %s getting device state: %s", type(e), e) if "calibration" in self.params: calibration = self.params["calibration"] logger.debug("GPIODevice.getUncalibratedState - calibration: %s", calibration) if "value" in state: state["value"] += calibration logger.debug("GPIODevice.getUncalibratedState - uncalibrated state: %s", state) return state @classmethod def initFromDict(cls, dict:dict): dev = GPIODevice() for key, value in dict.items(): if value is None: setattr(dev, key, value) else: if key == "_params": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval setattr(dev, key, value) return dev class Trigger(): def __init__(self): self._id = "" self._source = "" self._device = "" self._event = "" self._params = {} self._control = {} self._isActive = False self._actions = {} @property def id(self) -> str: return self._id @id.setter def id(self, value: str): self._id = value @property def source(self) -> str: return self._source @source.setter def source(self, value: str): self._source = value @property def device(self) -> str: return self._device @device.setter def device(self, value: str): self._device = value @property def event(self) -> str: return self._event @event.setter def event(self, value: str): self._event = value @property def params(self) -> dict: return self._params @params.setter def params(self, value: dict): self._params = value @property def control(self) -> dict: return self._control @control.setter def control(self, value: dict): self._control = value @property def isActive(self) -> bool: return self._isActive @isActive.setter def isActive(self, value: bool): self._isActive = value @property def actions(self) -> dict: return self._actions @actions.setter def actions(self, value: dict): self._actions = value @classmethod def initFromDict(cls, dict:dict): trg = Trigger() for key, value in dict.items(): if value is None: setattr(trg, key, value) else: if key == "_device": val = value if value == "Active Camera": val = "CAM-1" if value == "Second Camera": val = "CAM-2" value = val if key == "_params": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval if key == "_control": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval elif key == "_actions": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval setattr(trg, key, value) return trg class Action(): def __init__(self): self._id = "" self._isActive = False self._source = "" self._device = "" self._method = "" self._params = {} self._control = {} @property def id(self) -> str: return self._id @id.setter def id(self, value: str): self._id = value @property def isActive(self) -> bool: return self._isActive @isActive.setter def isActive(self, value: bool): self._isActive = value @property def source(self) -> str: return self._source @source.setter def source(self, value: str): self._source = value @property def device(self) -> str: return self._device @device.setter def device(self, value: str): self._device = value @property def method(self) -> str: return self._method @method.setter def method(self, value: str): self._method = value @property def params(self) -> dict: return self._params @params.setter def params(self, value: dict): self._params = value @property def control(self) -> dict: return self._control @control.setter def control(self, value: dict): self._control = value @classmethod def initFromDict(cls, dict:dict): act = Action() for key, value in dict.items(): if value is None: setattr(act, key, value) else: if key == "_params": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval elif key == "_control": newval = {} for pkey, pvalue in value.items(): if type(pvalue) is list: newval[pkey] = tuple(pvalue) else: newval[pkey] = pvalue value = newval setattr(act, key, value) return act class TriggerConfig(): motionDetectAlgos = ["Mean Square Diff", "Frame Differencing", "Optical Flow", "Background Subtraction"] videoRecorders = ["Normal", "Circular"] backgroundSubtractionModels = ["MOG2", "KNN"] def __init__(self): self._triggeredByMotion = True self._triggeredBySound = False self._triggeredByEvents = False self._actionVideo = True self._actionPhoto = True self._actionNotify = False self._operationStartMinute: int = 0 self._operationEndMinute: int = 1439 self._operationWeekdays = {"1":True, "2":True, "3":True, "4":True, "5":True, "6":True, "7":True} self._operationAutoStart = False self._detectionDelaySec = 0 self._detectionPauseSec = 20 self._motionDetectAlgo = 1 self._motionRefTit = "" self._motionRefURL = "" self._msdThreshold = 10 self._bboxThreshold = 400 self._nmsThreshold = 0.001 self._motionThreshold = 1 self._useRoI = False self._regionOfNoInterest = () self._regionOfInterest = () self._backSubModel = "MOG2" self._videoBboxes = True self._photoRois = False self._motionTestFrame1Title = "" self._motionTestFrame2Title = "" self._motionTestFrame3Title = "" self._motionTestFrame4Title = "" self._motionTestFramerate = 0 self._actionVR = 1 self._actionCircSize = 5 self._actionPath = "" self._actionVideoDuration = 10 self._actionPhotoBurst = 1 self._actionPhotoBurstDelaySec = 2 self._notifyHost = "" self._notifyPort = 0 self._notifyUseSSL = False self._notifyAuthenticate = True self._notifyConOK = False self._notifyPause = 0 self._notifyIncludeVideo = False self._notifyIncludePhoto = False self._notifySavePwd = False self._notifyPwdPath = "" self._notifyFrom = "" self._notifyTo = "" self._notifySubject = "" self._retentionPeriod = 3 self._evStart = None self._evIncludePhoto = False self._evIncludeVideo = True self._evAutoRefresh = False self._calStart = None self._error = None self._error2 = None self._errorSource = None self._triggers = [] self._actions = [] self._noCamera = False @property def logFileName(self) -> str: return "_events.log" @property def logFilePath(self) -> str: return self._actionPath + "/" + self.logFileName @property def operationStartMinute(self) -> int: return self._operationStartMinute @operationStartMinute.setter def operationStartMinute(self, value: int): self._operationStartMinute = value @property def operationStartStr(self) -> str: h = self._operationStartMinute // 60 m = self._operationStartMinute % 60 return str(h).zfill(2) + ":" + str(m).zfill(2) @operationStartStr.setter def operationStartStr(self, value: str): h = 0 m = 0 if value: if len(value) == 5: h = int(value[:2]) m = int(value[3:]) self._operationStartMinute = 60 * h + m @property def operationEndMinute(self) -> int: return self._operationEndMinute @operationEndMinute.setter def operationEndMinute(self, value: int): self._operationEndMinute = value @property def operationEndStr(self) -> str: h = self._operationEndMinute // 60 m = self._operationEndMinute % 60 return str(h).zfill(2) + ":" + str(m).zfill(2) @operationEndStr.setter def operationEndStr(self, value: str): h = 0 m = 0 if value: if len(value) == 5: h = int(value[:2]) m = int(value[3:]) self._operationEndMinute = 60 * h + m @property def operationWeekdays(self) -> dict: return self._operationWeekdays @operationWeekdays.setter def operationWeekdays(self, value: dict): self._operationWeekdays = value @property def operationAutoStart(self) -> bool: return self._operationAutoStart @operationAutoStart.setter def operationAutoStart(self, value: bool): self._operationAutoStart = value @property def detectionDelaySec(self) -> int: return self._detectionDelaySec @detectionDelaySec.setter def detectionDelaySec(self, value: int): self._detectionDelaySec = value @property def detectionPauseSec(self) -> int: return self._detectionPauseSec @detectionPauseSec.setter def detectionPauseSec(self, value: int): self._detectionPauseSec = value @property def triggeredByMotion(self) -> bool: return self._triggeredByMotion @triggeredByMotion.setter def triggeredByMotion(self, value: bool): self._triggeredByMotion = value @property def triggeredBySound(self) -> bool: return self._triggeredBySound @triggeredBySound.setter def triggeredBySound(self, value: bool): self._triggeredBySound = value @property def triggeredByEvents(self) -> bool: return self._triggeredByEvents @triggeredByEvents.setter def triggeredByEvents(self, value: bool): self._triggeredByEvents = value @property def motionDetectAlgo(self) -> int: return self._motionDetectAlgo @motionDetectAlgo.setter def motionDetectAlgo(self, value: int): self._motionDetectAlgo = value @property def motionRefTit(self) -> str: return self._motionRefTit @motionRefTit.setter def motionRefTit(self, value: str): self._motionRefTit = value @property def motionRefURL(self) -> str: return self._motionRefURL @motionRefURL.setter def motionRefURL(self, value: str): self._motionRefURL = value @property def actionVideo(self) -> bool: return self._actionVideo @actionVideo.setter def actionVideo(self, value: bool): self._actionVideo = value @property def actionPhoto(self) -> bool: return self._actionPhoto @actionPhoto.setter def actionPhoto(self, value: bool): self._actionPhoto = value @property def actionNotify(self) -> bool: return self._actionNotify @actionNotify.setter def actionNotify(self, value: bool): self._actionNotify = value @property def msdThreshold(self) -> int: return self._msdThreshold @msdThreshold.setter def msdThreshold(self, value: int): self._msdThreshold = value @property def bboxThreshold(self) -> int: return self._bboxThreshold @bboxThreshold.setter def bboxThreshold(self, value: int): self._bboxThreshold = value # TODO: int->float @property def nmsThreshold(self) -> int: return self._nmsThreshold @nmsThreshold.setter def nmsThreshold(self, value: int): self._nmsThreshold = value @property def motionThreshold(self) -> int: return self._motionThreshold @motionThreshold.setter def motionThreshold(self, value: int): self._motionThreshold = value @property def useRoI(self) -> bool: return self._useRoI @useRoI.setter def useRoI(self, value: bool): self._useRoI = value @property def regionOfNoInterest(self) -> tuple: return self._regionOfNoInterest @regionOfNoInterest.setter def regionOfNoInterest(self, value: tuple): self._regionOfNoInterest = value @property def regionOfNoInterestStr(self) -> str: res = "(" for win in self.regionOfNoInterest: if len(res) > 1: res = res + "," res = res + "(" + str(win[0]) + "," + str(win[1]) + "," + str(win[2]) + "," + str(win[3]) + ")" res = res + ")" return res @regionOfNoInterestStr.setter def regionOfNoInterestStr(self, value: str): """Parse the string representation for regionOfNoInterest """ self._regionOfNoInterest = () # Get the list of windows winlist = TriggerConfig._parseWindows(value) for win in winlist: awin = TriggerConfig._parseRectTuple(win) # Add window from list to _regionOfNoInterest tuple awin = (awin,) self._regionOfNoInterest += awin @property def regionOfInterest(self) -> tuple: return self._regionOfInterest @regionOfInterest.setter def regionOfInterest(self, value: tuple): self._regionOfInterest = value @property def regionOfInterestStr(self) -> str: res = "(" for win in self.regionOfInterest: if len(res) > 1: res = res + "," res = res + "(" + str(win[0]) + "," + str(win[1]) + "," + str(win[2]) + "," + str(win[3]) + ")" res = res + ")" return res @regionOfInterestStr.setter def regionOfInterestStr(self, value: str): """Parse the string representation for regionOfInterest """ self._regionOfInterest = () # Get the list of windows winlist = TriggerConfig._parseWindows(value) for win in winlist: awin = TriggerConfig._parseRectTuple(win) # Add window from list to _regionOfInterest tuple awin = (awin,) self._regionOfInterest += awin def _getRectangleFromCrop(self, crop: tuple) -> tuple: """ Get rectangle (x1, y1, x2, y2) from crop (x, y, w, h) Args: crop (tuple): Cropping rectangle defined as (x, y, w, h) Returns: tuple: (x1, y1, x2, y2) """ x1 = crop[0] y1 = crop[1] x2 = x1 + crop[2] y2 = y1 + crop[3] if x2 < x1: xx = x1 x1 = x2 x2 = xx if y2 < y1: yy = y1 y1 = y2 y2 = yy return (x1, y1, x2, y2) def checkRoisAgainstScalerCropLiveView(self, scalerCrop:tuple) -> bool: """ Check that the RoIs are within the scaler crop used for live view Args: scalerCrop (tuple): (x, y, w, h) of scaler crop Returns: bool: True if RiIs/RoNIs are unchanged, False if they have been adjusted """ unchanged = True (scX1, scY1, scX2, scY2) = self._getRectangleFromCrop(scalerCrop) newRois = () for roi in self.regionOfInterest: (rX1, rY1, rX2, rY2) = self._getRectangleFromCrop(roi) if rX1 < scX1: rX1 = scX1 if rY1 < scY1: rY1 = scY1 if rX2 > scX2: rX2 = scX2 if rY2 > scY2: rY2 = scY2 if rX2 > rX1 and rY2 > rY1: newRois += ((rX1, rY1, rX2 - rX1, rY2 - rY1),) if self._regionOfInterest != newRois: unchanged = False self._regionOfInterest = newRois newRonis = () for roni in self.regionOfNoInterest: (rX1, rY1, rX2, rY2) = self._getRectangleFromCrop(roni) if rX1 < scX1: rX1 = scX1 if rY1 < scY1: rY1 = scY1 if rX2 > scX2: rX2 = scX2 if rY2 > scY2: rY2 = scY2 if rX2 > rX1 and rY2 > rY1: newRonis += ((rX1, rY1, rX2 - rX1, rY2 - rY1),) if self._regionOfNoInterest != newRonis: unchanged = False self._regionOfNoInterest = newRonis return unchanged @property def backSubModel(self) -> str: return self._backSubModel @backSubModel.setter def backSubModel(self, value: str): self._backSubModel = value @property def videoBboxes(self) -> bool: return self._videoBboxes @videoBboxes.setter def videoBboxes(self, value: bool): self._videoBboxes = value @property def photoRois(self) -> bool: return self._photoRois @photoRois.setter def photoRois(self, value: bool): self._photoRois = value @property def motionTestFrame1Title(self) -> str: return self._motionTestFrame1Title @motionTestFrame1Title.setter def motionTestFrame1Title(self, value: str): self._motionTestFrame1Title = value @property def motionTestFrame2Title(self) -> str: return self._motionTestFrame2Title @motionTestFrame2Title.setter def motionTestFrame2Title(self, value: str): self._motionTestFrame2Title = value @property def motionTestFrame3Title(self) -> str: return self._motionTestFrame3Title @motionTestFrame3Title.setter def motionTestFrame3Title(self, value: str): self._motionTestFrame3Title = value @property def motionTestFrame4Title(self) -> str: return self._motionTestFrame4Title @motionTestFrame4Title.setter def motionTestFrame4Title(self, value: str): self._motionTestFrame4Title = value @property def motionTestFramerate(self) -> float: return self._motionTestFramerate @motionTestFramerate.setter def motionTestFramerate(self, value: str): self._motionTestFramerate = value @property def actionVR(self) -> int: return self._actionVR @actionVR.setter def actionVR(self, value: int): self._actionVR = value @property def actionCircSize(self) -> int: return self._actionCircSize @actionCircSize.setter def actionCircSize(self, value: int): self._actionCircSize = value @property def actionPath(self) -> str: return self._actionPath @actionPath.setter def actionPath(self, value: str): self._actionPath = value @property def actionVideoDuration(self) -> int: return self._actionVideoDuration @actionVideoDuration.setter def actionVideoDuration(self, value: int): self._actionVideoDuration = value @property def actionPhotoBurst(self) -> int: return self._actionPhotoBurst @actionPhotoBurst.setter def actionPhotoBurst(self, value: int): self._actionPhotoBurst = value @property def actionPhotoBurstDelaySec(self) -> int: return self._actionPhotoBurstDelaySec @actionPhotoBurstDelaySec.setter def actionPhotoBurstDelaySec(self, value: int): self._actionPhotoBurstDelaySec = value @property def notifyHost(self) -> str: return self._notifyHost @notifyHost.setter def notifyHost(self, value: str): self._notifyHost = value @property def notifyPort(self) -> int: return self._notifyPort @notifyPort.setter def notifyPort(self, value: int): self._notifyPort = value @property def notifyUseSSL(self) -> bool: return self._notifyUseSSL @notifyUseSSL.setter def notifyUseSSL(self, value: bool): self._notifyUseSSL = value @property def notifyAuthenticate(self) -> bool: return self._notifyAuthenticate @notifyAuthenticate.setter def notifyAuthenticate(self, value: bool): self._notifyAuthenticate = value @property def notifyConOK(self) -> bool: return self._notifyConOK @notifyConOK.setter def notifyConOK(self, value: bool): self._notifyConOK = value @property def notifyPause(self) -> int: return self._notifyPause @notifyPause.setter def notifyPause(self, value: int): self._notifyPause = value @property def notifyIncludeVideo(self) -> bool: return self._notifyIncludeVideo @notifyIncludeVideo.setter def notifyIncludeVideo(self, value: bool): self._notifyIncludeVideo = value @property def notifyIncludePhoto(self) -> bool: return self._notifyIncludePhoto @notifyIncludePhoto.setter def notifyIncludePhoto(self, value: bool): self._notifyIncludePhoto = value @property def notifySavePwd(self) -> bool: return self._notifySavePwd @notifySavePwd.setter def notifySavePwd(self, value: bool): self._notifySavePwd = value @property def notifyPwdPath(self) -> str: return self._notifyPwdPath @notifyPwdPath.setter def notifyPwdPath(self, value: str): self._notifyPwdPath = value @property def notifyFrom(self) -> str: return self._notifyFrom @notifyFrom.setter def notifyFrom(self, value: str): self._notifyFrom = value @property def notifyTo(self) -> str: return self._notifyTo @notifyTo.setter def notifyTo(self, value: str): self._notifyTo = value @property def notifySubject(self) -> str: return self._notifySubject @notifySubject.setter def notifySubject(self, value: str): self._notifySubject = value @property def retentionPeriod(self) -> int: return self._retentionPeriod @retentionPeriod.setter def retentionPeriod(self, value: int): self._retentionPeriod = value @property def retentionPeriodStr(self) -> str: return str(self._retentionPeriod) @property def evStart(self) -> datetime: return self._evStart @evStart.setter def evStart(self, value: datetime): if value is None: val = None else: val = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._evStart = val @property def evStartDateStr(self) -> str: return self._evStart.isoformat()[:10] @evStartDateStr.setter def evStartDateStr(self, value: str): try: d = date.fromisoformat(value) except ValueError: d = datetime.now() v = datetime(year=d.year, month=d.month, day=d.day, hour=self._evStart.hour, minute=self._evStart.minute) self._evStart = v @property def evStartTimeStr(self) -> str: return self._evStart.isoformat()[11:16] @evStartTimeStr.setter def evStartTimeStr(self, value: str): try: d = time.fromisoformat(value) except ValueError: d = datetime.now() v = datetime(year=self._evStart.year, month=self._evStart.month, day=self._evStart.day, hour=d.hour, minute=d.minute) self._evStart = v @property def evStartIso(self) -> str: return self._evStart.isoformat() def evStartMidnight(self): self._evStart = datetime(year=self._evStart.year, month=self._evStart.month, day=self._evStart.day, hour=0, minute=0) @property def evIncludePhoto(self) -> bool: return self._evIncludePhoto @evIncludePhoto.setter def evIncludePhoto(self, value: bool): self._evIncludePhoto = value @property def evIncludeVideo(self) -> bool: return self._evIncludeVideo @evIncludeVideo.setter def evIncludeVideo(self, value: bool): self._evIncludeVideo = value @property def evAutoRefresh(self) -> bool: return self._evAutoRefresh @evAutoRefresh.setter def evAutoRefresh(self, value: bool): self._evAutoRefresh = value @property def calStart(self) -> datetime: return self._calStart @calStart.setter def calStart(self, value: datetime): if value == None: val = None else: val = datetime(year=value.year, month=value.month, day=1, hour=0, minute=0, second=0) self._calStart = val @property def calStartDateStr(self) -> str: return self._calStart.isoformat()[:10] @calStartDateStr.setter def calStartDateStr(self, value: str): try: d = date.fromisoformat(value) except ValueError: d = datetime.now() v = datetime(year=d.year, month=d.month, day=1, hour=0, minute=0) self._evStart = v @property def error(self) -> str: return self._error @error.setter def error(self, value: str): self._error = value if value is None: self._errorSource = None self._error2 = None @property def error2(self) -> str: return self._error2 @error2.setter def error2(self, value: str): self._error2 = value @property def errorSource(self) -> str: return self._errorSource @errorSource.setter def errorSource(self, value: str): self._errorSource = value @property def triggers(self) -> list[Trigger]: return self._triggers @triggers.setter def triggers(self, value: list[Trigger]): self._triggers = value @property def actions(self) -> list: return self._actions @actions.setter def actions(self, value: list): self._actions = value @property def eventList(self) -> list: return self.getEventList() def getEventList(self) -> list: db = dbx.get_dbx() events = [] seldate = self.evStartDateStr seltime = self.evStartTimeStr eventsdb = db.execute("SELECT * FROM events WHERE date = ? AND minute >= ?", (seldate, seltime) ).fetchall() for eventdb in eventsdb: eventContainer = {} event = {} event["timestamp"] = eventdb["timestamp"] event["date"] = eventdb["date"] event["time"] = eventdb["time"] event["type"] = eventdb["type"] event["trigger"] = eventdb["trigger"] event["triggertype"] = eventdb["triggertype"] tps = eventdb["triggerparam"] # Handle DB entries from previous releases where params were just strings and no dict handleAsStr = True tpd = {} try: tpdt = literal_eval(tps) if isinstance(tpdt, dict): tpd = tpdt handleAsStr = False except Exception: pass if handleAsStr == True: if tps[:5] == "msd: ": tpd["msd"] = tps[5:] else: tpd["par"] = tps event["triggerparam"] = tpd eventContainer["event"] = event events.append(eventContainer) if self.evIncludeVideo: for ev in events: event = ev["event"] ts = event["timestamp"] eventactions = db.execute("SELECT * FROM eventactions WHERE event = ? AND actiontype = ?", (ts, "Video") ).fetchone() if eventactions is None: eventVideo = {} else: eventVideo = {} eventVideo["timestamp"] = eventactions["timestamp"] eventVideo["date"] = eventactions["date"] eventVideo["time"] = eventactions["time"] eventVideo["duration"] = round(eventactions["actionduration"], 0) eventVideo["filename"] = eventactions["filename"] videophoto = db.execute("SELECT * FROM eventactions WHERE event = ? AND actiontype = ? AND timestamp = ?", (ts, "Photo", eventactions["timestamp"]) ).fetchone() if videophoto is None: eventVideo["photo"] = None else: eventVideo["photo"] = videophoto["filename"] ev["video"] = eventVideo if self.evIncludePhoto: for ev in events: event = ev["event"] ts = event["timestamp"] eventactions = None #if self.evIncludeVideo: # if len(ev["video"]) > 0: # eventVideo = ev["video"] # if not eventVideo["photo"] is None: # videoTs = eventVideo["timestamp"] # eventactions = db.execute("SELECT * FROM eventactions WHERE event = ? AND actiontype = ? AND timestamp != ? ORDER BY timestamp ASC", # (ts, "Photo", videoTs) # ).fetchall() if eventactions is None: eventactions = db.execute("SELECT * FROM eventactions WHERE event = ? AND actiontype = ? ORDER BY timestamp ASC", (ts, "Photo") ).fetchall() if eventactions is None: eventPhotos = [] else: eventPhotos = [] for eventactiondb in eventactions: eventPhoto = {} eventPhoto["timestamp"] = eventactiondb["timestamp"] eventPhoto["date"] = eventactiondb["date"] eventPhoto["time"] = eventactiondb["time"] eventPhoto["duration"] = round(eventactiondb["actionduration"], 0) eventPhoto["filename"] = eventactiondb["filename"] eventPhotos.append(eventPhoto) ev["photos"] = eventPhotos return events @property def calendar(self) -> list: return self.getCalendar() @property def calendarMonthStr(self) -> str: return self.calStart.strftime("%B") + " " + str(self.calStart.year) def getCalendar(self)-> list: """ Setup calendar for the selected month with information on events """ db = dbx.get_dbx() wd = self.calStart.isocalendar().weekday month = self.calStart.month wnrStart = self.calStart.isocalendar().week dayStart = self.calStart - timedelta(hours = (wd - 1) * 24) calendar = [] dayIter = dayStart for week in range(wnrStart, wnrStart + 6): calWeek = {} calWeek["week"] = week weekdays = [] for weekday in range(1, 8): day = {} day["day"] = dayIter.day day["weekday"] = dayIter.isocalendar().weekday day["week"] = dayIter.isocalendar().week dayIso = dayIter.isoformat()[:10] day["date"] = dayIso data = {} nrEvents = db.execute("SELECT count(*) FROM events WHERE date = ?", (dayIso,) ).fetchone()[0] data["nrevents"] = nrEvents day["data"] = data weekdays.append(day) dayIter = dayIter + timedelta(hours=24) calWeek["weekdays"] = weekdays calendar.append(calWeek) if dayIter.month > month: break return calendar def cleanupEvents(self): """ Remove all events older than retention period """ logger.debug("TriggerConfig.cleanupEvents") db = dbx.get_dbx() dr = datetime.now() - timedelta(days=self.retentionPeriod) #dr = dr - timedelta(hours=23) dateRem = str(dr.isoformat()[:10]) logger.debug("TriggerConfig.cleanupEvents - Removing %s and earlier", dateRem) # Cleanup events log fpLog = self.logFilePath fpLogOld = os.path.dirname(fpLog) + "/_backup.log" if os.path.exists(fpLog): if os.path.exists(fpLogOld): os.remove(fpLogOld) os.rename(fpLog, fpLogOld) with open(fpLogOld, "r") as src: oldLines = src.readlines() with open(fpLog, "w") as tgt: for line in oldLines: if line[:10] > dateRem: tgt.write(line) os.remove(fpLogOld) logger.debug("Cleaned up %s", fpLog) # Remove files cnt = 0 evadb = db.execute("SELECT * FROM eventactions WHERE date <= ?", (dateRem,)).fetchall() if not evadb is None: for eva in evadb: fp = eva["fullpath"] if os.path.exists(fp): os.remove(fp) cnt += 1 logger.debug("Removed %s files", cnt) # Delete eventactions db.execute("DELETE FROM eventactions WHERE date <= ?", (dateRem,)).fetchall() db.commit() logger.debug("Removed old eventaction") # Delete events db.execute("DELETE FROM events WHERE date <= ?", (dateRem,)).fetchall() db.commit() logger.debug("Removed old events") @staticmethod def _parseWindows(wins: str) -> list: """ Parses the tuple-string of one or multiple rectangles "((x,x,x,x),(x,x,x,x))" and returns an array of rectangles as strings """ resa = [] if wins.startswith("("): wns = wins[1:] if wns.endswith(")"): wns = wns[0: len(wns) - 1] while len(wns) > 0: i = wns.find(")") if i > 0: wn = wns[0: i + 1] resa.append(wn) if i < len(wns): wns = wns[i + 2:].strip() else: wns = "" else: wns = "" return resa @staticmethod def _parseRectTuple(stuple: str) -> tuple: """ Parse a Python tuple string for a rectangle "(xOffset, yOffset, width, height)" """ rest = (0, 0, 0, 0) if stuple.startswith("("): tpl = stuple[1:] if tpl.endswith(")"): tpl = tpl[0: len(tpl) - 1] res = tpl.rsplit(",") if len(res) == 4: rest = (int(res[0]), int(res[1]), int(res[2]), int(res[3])) return rest def checkNotificationRecipient(self, user=None, pwd=None) -> tuple: """ Check login to mail server using available credentials Return (user, password, error message) """ logger.debug("TriggerConfig.checkNotificationRecipient") logger.debug("user: %s, password: %s", user, pwd) err = "" secHost = "" secPort = -1 secUseSSL = None secAuthenticate = None secUser = "" secPwd = "" secretsOK = False # Try to get credentials from the file if os.path.exists(self.notifyPwdPath): with open(self.notifyPwdPath) as f: try: secrets = json.load(f) notifySecrets = secrets["eventnotification"] secHost = notifySecrets["host"] secPort = notifySecrets["port"] secUseSSL = notifySecrets["useSSL"] if "authentication" in notifySecrets: secAuthenticate = notifySecrets["authentication"] secUser = notifySecrets["user"] secPwd = notifySecrets["password"] secretsOK = True logger.debug("TriggerConfig.checkNotificationRecipient - read credentials from file") except Exception as e: pass if secHost == "": secHost = self.notifyHost else: if secHost != self.notifyHost: secHost = self.notifyHost secretsOK = False if secPort == -1: secPort = self.notifyPort else: if secPort != self.notifyPort: secPort = self.notifyPort secretsOK = False if secUseSSL is None: secUseSSL = self.notifyUseSSL else: if secUseSSL != self.notifyUseSSL: secUseSSL = self.notifyUseSSL secretsOK = False if secAuthenticate is None: secAuthenticate = self.notifyAuthenticate else: if secAuthenticate != self.notifyAuthenticate: secAuthenticate = self.notifyAuthenticate secretsOK = False if secUser == "": if not user is None: secUser = user else: if not user is None: if user != "": secUser = user secretsOK = False if secPwd == "": if not pwd is None: secPwd = pwd else: if not pwd is None: if pwd != "": secPwd = pwd secretsOK = False # Test SSL # TODO: Investigate why this test no longer works #try: # with smtplib.SMTP_SSL(host=secHost, port=secPort) as smtp_ssl: # smtp_ssl.ehlo() # if secUseSSL == False: # err = "Server requires SSL" #except (smtplib.SMTPConnectError, ConnectionRefusedError): # if secUseSSL == True: # err = "Server does not require SSL" # Test connection if err == "": try: if secUseSSL == True: server = smtplib.SMTP_SSL(host=secHost, port=secPort) else: server = smtplib.SMTP(host=secHost, port=secPort) server.connect(secHost) server.ehlo() if secAuthenticate == True: logger.debug("Authentication with user/pwd") server.login(secUser, secPwd) else: if "auth" in server.esmtp_features: err = "The server requires authentication. Please provide 'User' and 'Password'" logger.debug("Authentication skipped") server.quit() logger.debug("TriggerConfig.checkNotificationRecipient - connection test successful") except Exception as e: logger.debug("TriggerConfig.checkNotificationRecipient - connection test failed") err = "Connection error: " + str(e) if err == "": self.notifyConOK = True if secretsOK == False: if self.notifySavePwd == True: # Store credentials if self.notifyPwdPath == "": err = "Please enter the file path for storage of credentials!" else: if not os.path.exists(self.notifyPwdPath): fp = Path(self.notifyPwdPath) dir = fp.parent.absolute() fn = fp.name if not os.path.exists(dir): os.makedirs(dir, exist_ok=True) self.notifyPwdPath = str(dir) + "/" + fn Path(self.notifyPwdPath).touch(exist_ok=True) else: if os.path.isdir(self.notifyPwdPath): err = "The 'Password File Path' must be a file and not a directory!" secrets = {} if err == "": if os.stat(self.notifyPwdPath).st_size > 0: with open(self.notifyPwdPath, "r") as f: try: secrets = json.load(f) except Exception as e: err = "The file specified as 'Password File Path' has content which is not in JSON format" if err == "": if "eventnotification" in secrets: notifySecrets = secrets["eventnotification"] else: notifySecrets = {} secrets["eventnotification"] = notifySecrets notifySecrets["host"] = self.notifyHost notifySecrets["port"] = self.notifyPort notifySecrets["useSSL"] = self.notifyUseSSL notifySecrets["authentication"] = self.notifyAuthenticate notifySecrets["user"] = secUser notifySecrets["password"] = secPwd with open(self.notifyPwdPath, "w") as f: try: json.dump(secrets,fp=f, indent=4) logger.debug("TriggerConfig.checkNotificationRecipient - saved credentials to file %s", self.notifyPwdPath) except Exception as e: logger.err("TriggerConfig.checkNotificationRecipient - error while saving credentials to file %s: %s", self.notifyPwdPath, e) err = "Error writing to " + self.notifyPwdPath + ": " + str(e) else: self.notifyConOK = False return (secUser, secPwd, err) def triggerSources(self) -> list[str]: """ Return a list of trigger sources Trigger sources are: - 'Camera' : Camera-based triggers - 'GPIO' : for GPIO input devices - 'MotionDetector' : for motion detection using a camera Returns: list[str] : List of trigger sources """ if self._noCamera == False: triggerSources = ["Camera", "GPIO", "MotionDetector"] else: triggerSources = ["GPIO",] return triggerSources def actionSources(self) -> list[str]: """ Return a list of action sources Action sources are: - 'Camera' : for photo taking and video recording - 'GPIO' : for GPIO output devices Returns: list[str]: List of action sources """ if self._noCamera == False: actionSources = ["Camera", "GPIO", "SMTP"] else: actionSources = ["GPIO", "SMTP"] return actionSources def triggerDevices(self, source:str) -> list[str]: """ Return a list of trigger devices for the given source for source 'Camera': - "CAM-1" - "CAM-2" for source 'GPIO': - list of IDs of Input devices for source 'MotionDetector': - "CAM-1" Args: source (str): trigger source ('Camera' or 'GPIO') Returns: list[str]: list of devices """ logger.debug("TriggerConfig.triggerDevices") deviceList = [] if source == "Camera": deviceList = ["CAM-1",] if len(CameraCfg().cameras) > 1: deviceList.append("CAM-2") elif source == "MotionDetector": deviceList = ["CAM-1",] elif source == "GPIO": devices = CameraCfg().serverConfig.gpioDevices for device in devices: if device.usage == "Input" \ and device.isOk == True: id = device.id deviceList.append(id) return deviceList def actionDevices(self, source:str) -> list[str]: """ Return a list of action devices for the given source for source 'Camera': - "CAM-1" for source 'GPIO': - list of IDs of Output devices for source 'SMTP': - The configured SMTP srver, if any Args: source (str): trigger source ('Camera' or 'GPIO') Returns: list[str]: list of devices """ deviceList = [] if source == "Camera": deviceList = ["CAM-1",] if source == "SMTP": deviceList = [self._notifyHost,] elif source == "GPIO": devices = CameraCfg().serverConfig.gpioDevices for device in devices: if device.usage == "Output": id = device.id deviceList.append(id) return deviceList def triggerEvents(self, source:str, device:str) -> tuple[list[str], dict, dict]: """ Return lists of events and event settings for the given device The returned events are methods which allow assignment of callback routines Args: source (str): Source ('Camera' or 'GPIO') device (str): Device Returns: tuple[list[str], dict]: - list of events - dict of event settings - dict of control data """ events = [] eventSettings = {} control = {} if source == "Camera": if device == "CAM-1": events = [ "when_photo_taken", "when_series_photo_taken", "when_recording_starts", "when_recording_stops", "when_streaming_1_starts", "when_streaming_1_stops", ] control = { "event_log": False } elif device == "CAM-2": events = [ "when_streaming_2_starts", "when_streaming_2_stops", ] control = { "event_log": False } elif source == "MotionDetector": if device == "CAM-1": events = [ "when_motion_detected", ] control = {} elif source == "GPIO": gpioDev = CameraCfg().serverConfig.getDevice(device) if gpioDev is not None: devType = gpioDev.type for typ in gpioDeviceTypes: if typ["type"] == devType: if "events" in typ: events = typ["events"] if "eventSettings" in typ: eventSettings = typ["eventSettings"] if "control" in typ: control = typ["control"] break return events, eventSettings, control def actionTargets(self, source:str, device:str) -> list[str]: """ Return lists of action targets for the given device The returned actions are methods or properties for the device type Args: source (str): Source ('Camera' or 'GPIO') device (str): Device Returns: list[str]: list of action targets """ actionTargets = [] if source == "Camera": actionTargets = [] actionTargets = [ { "method": "take_photo", "params": { "type": "jpg" }, "control": { "burst_count": 1, "burst_intvl": 1.0 } }, { "method": "record_video", "params": { "type": "mp4" }, "control": { "duration": 1 } }, { "method": "start_video", "params": { "type": "mp4" }, "control": { } }, { "method": "stop_video", "params": {}, "control": {} } ] elif source == "SMTP": if self._noCamera == False: actionTargets = [ { "method": "send_mail", "params": {}, "control": { "attach_photo": False, "attach_video": False } } ] else: actionTargets = [ { "method": "send_mail", "params": {}, "control": { } } ] elif source == "GPIO": gpioDev = CameraCfg().serverConfig.getDevice(device) if gpioDev is not None: devType = gpioDev.type for typ in gpioDeviceTypes: if typ["type"] == devType: if "actionTargets" in typ: actionTargets = typ["actionTargets"] break return actionTargets def getTrigger(self, id:str) -> Trigger: """ Return a trigger with a specific ID Args: id (str): ID of trigger to be returned Returns: Trigger: Trigger with the given ID or None """ trigger = None for trg in self.triggers: if trg.id == id: trigger = trg break return trigger def getAction(self, id:str) -> Action: """ Return an action with a specific ID Args: id (str): ID of action to be returned Returns: Action: Action with the given ID or None """ action = None for act in self.actions: if act.id == id: action = act break return action @property def cameraSettings(self) -> dict: cs = {} cs["actionPhoto"] = self._actionPhoto cs["actionVideo"] = self._actionVideo cs["motionDetectAlgo"] = self._motionDetectAlgo cs["msdThreshold"] = self._msdThreshold cs["bboxThreshold"] = self._bboxThreshold cs["nmsThreshold"] = self._nmsThreshold cs["motionThreshold"] = self._motionThreshold cs["useRoI"] = self._useRoI cs["regionOfNoInterest"] = self._regionOfNoInterest cs["regionOfInterest"] = self._regionOfInterest cs["backSubModel"] = self._backSubModel cs["videoBboxes"] = self._videoBboxes cs["photoRois"] = self._photoRois cs["actionVR"] = self._actionVR cs["actionCircSize"] = self._actionCircSize cs["actionVideoDuration"] = self._actionVideoDuration cs["actionPhotoBurst"] = self._actionPhotoBurst cs["actionPhotoBurstDelaySec"] = self._actionPhotoBurstDelaySec return cs @cameraSettings.setter def cameraSettings(self, value: dict): if "actionPhoto" in value: self._actionPhoto = value["actionPhoto"] if "actionVideo" in value: self._actionVideo = value["actionVideo"] if "motionDetectAlgo" in value: self._motionDetectAlgo = value["motionDetectAlgo"] if "msdThreshold" in value: self._msdThreshold = value["msdThreshold"] if "bboxThreshold" in value: self._bboxThreshold = value["bboxThreshold"] if "nmsThreshold" in value: self._nmsThreshold = value["nmsThreshold"] if "motionThreshold" in value: self._motionThreshold = value["motionThreshold"] if "useRoI" in value: self._useRoI = value["useRoI"] if "regionOfNoInterest" in value: self._regionOfNoInterest = value["regionOfNoInterest"] if "regionOfInterest" in value: self._regionOfInterest = value["regionOfInterest"] if "backSubModel" in value: self._backSubModel = value["backSubModel"] if "videoBboxes" in value: self._videoBboxes = value["videoBboxes"] if "photoRois" in value: self._photoRois = value["photoRois"] if "actionVR" in value: self._actionVR = value["actionVR"] if "actionCircSize" in value: self._actionCircSize = value["actionCircSize"] if "actionVideoDuration" in value: self._actionVideoDuration = value["actionVideoDuration"] if "actionPhotoBurst" in value: self._actionPhotoBurst = value["actionPhotoBurst"] if "actionPhotoBurstDelaySec" in value: self._actionPhotoBurstDelaySec = value["actionPhotoBurstDelaySec"] def setCameraSettingsToDefault(self): self._actionPhoto = True self._actionVideo = True self._motionDetectAlgo = 1 self._msdThreshold = 10 self._bboxThreshold = 400 self._nmsThreshold = 0.001 self._motionThreshold = 1 self._useRoI = False self._regionOfNoInterest = () self._regionOfInterest = () self._backSubModel = "MOG2" self._videoBboxes = True self._photoRois = True self._actionVR = 1 self._actionCircSize = 5 self._actionVideoDuration = 10 self._actionPhotoBurst = 1 self._actionPhotoBurstDelaySec = 2 @property def cameraDefaultSettings(self) -> dict: cs = {} cs["actionPhoto"] = True cs["actionVideo"] = True cs["motionDetectAlgo"] = 1 cs["msdThreshold"] = 10 cs["bboxThreshold"] = 400 cs["nmsThreshold"] = 0.001 cs["motionThreshold"] = 1 cs["useRoI"] = False cs["regionOfNoInterest"] = () cs["regionOfInterest"] = () cs["backSubModel"] = "MOG2" cs["videoBboxes"] = True cs["actionVR"] = 1 cs["actionCircSize"] = 5 cs["actionVideoDuration"] = 10 cs["actionPhotoBurst"] = 1 cs["actionPhotoBurstDelaySec"] = 2 return cs @classmethod def initFromDict(cls, dict:dict): cc = TriggerConfig() for key, value in dict.items(): if value is None: setattr(cc, key, value) elif key == "_triggers": if value is None: setattr(cc, key, value) else: triggers = [] for trg in value: trigger = Trigger.initFromDict(trg) triggers.append(trigger) setattr(cc, key, triggers) elif key == "_actions": if value is None: setattr(cc, key, value) else: actions = [] for act in value: action = Action.initFromDict(act) actions.append(action) setattr(cc, key, actions) else: setattr(cc, key, value) #Reset some default values for which imported values shall be ignored cc.evStart = None cc.calStart = None cc.notifyConOK = False #Reset error cc._error = None cc._error2 = None cc._errorSource = None return cc class CameraInfo(): def __init__(self): self._model = "" self._isUsb = False self._usbDev = "" self._hasAi = False self._location = 0 self._rotation = 0 self._id = "" self._num = 0 self._status = "" @property def model(self) -> str: return self._model @model.setter def model(self, value: str): self._model = value @property def isUsb(self) -> bool: return self._isUsb @isUsb.setter def isUsb(self, value: bool): self._isUsb = value @property def hasAi(self) -> bool: return self._hasAi @hasAi.setter def hasAi(self, value: bool): self._hasAi = value @property def usbDev(self) -> str: return self._usbDev @usbDev.setter def usbDev(self, value: str): self._usbDev = value @property def location(self) -> int: return self._location @location.setter def location(self, value: int): self._location = value @property def rotation(self) -> int: return self._rotation @rotation.setter def rotation(self, value: int): self._rotation = value @property def id(self) -> str: return self._id @id.setter def id(self, value: str): self._id = value @property def num(self) -> int: return self._num @num.setter def num(self, value: int): self._num = value @property def status(self) -> str: return self._status @status.setter def status(self, value: str): self._status = value def setUsbDev(self): """Determine and set the device for a USB camera, based on camera model and USB port The USB port is determined from the camera ID. The ID is assumed to have the following structure (example): /base/axi/pcie@1000120000/rp1/usb@200000-2:1.0-046d:085c |_______________________||_____________| | |_| |__| |__| USB root port path USB root hub | | | | | | | └ Product ID | | └ Vendor ID | └ Interface └ USB port Information from the ID is matched to information on video devices from 'v4l2-ctl --list-devices' """ logger.debug("CameraInfo.setUsbDev for num=%s model=%s", self.num, self.model) usbDev = "" if self._isUsb: usbDev = "UNKNOWN" logger.debug("CameraInfo.setUsbDev - ID=%s", self.id) idParts = self._id.split("-") if len(idParts) >= 2: usbPart = idParts[1] productPart = idParts[2] logger.debug("CameraInfo.setUsbDev - usbPart=%s", usbPart) logger.debug("CameraInfo.setUsbDev - productPart=%s", productPart) usbPort = usbPart.split(":")[0] vidPid = productPart model = self.model logger.debug("CameraInfo.setUsbDev - usbPort=%s vidPid=%s model=%s", usbPort, vidPid, model) # Find which /dev/video node has the same USB port and same model name or VID:PID try: result = subprocess.run( ["v4l2-ctl", "--list-devices"], capture_output=True, text=True ).stdout # For each camera block in v4l2 output for block in result.strip().split("\n\n"): if vidPid in block or model in block: logger.debug("CameraInfo.setUsbDev - Found matching block in v4l2-ctl output") lines = [l.strip() for l in block.splitlines() if "/dev/video" in l] device = lines[0] logger.debug("CameraInfo.setUsbDev - Found device: %s", device) usbDev = device break if usbDev == "UNKNOWN": logger.debug("CameraInfo.setUsbDev - No matching device found in v4l2-ctl output") except CalledProcessError as e: logger.error("CameraInfo.setUsbDev - CalledProcessError: %s", e) # In case v4l2-ctl cannot be run, ignore the exception pass except Exception as e: logger.error("CameraInfo.setUsbDev - Exception: %s", e) pass self._usbDev = usbDev logger.debug("CameraInfo.setUsbDev - Set usbDev=%s", self._usbDev) class CameraControls(): def __init__(self): self._aeConstraintMode = 0 self.include_aeConstraintMode = False self._aeEnable = True self.include_aeEnable = False self._aeExposureMode = 0 self.include_aeExposureMode = False self._aeFlickerMode = 0 self.include_aeFlickerMode = False self._aeFlickerPeriod = 10000 self.include_aeFlickerPeriod = False self._aeMeteringMode = 0 self.include_aeMeteringMode = False self._afMode = 0 self.include_afMode = False self._lensPosition = 1.0 self.include_lensPosition = False self._afMetering = 0 self.include_afMetering = False self._afPause = 0 self.include_afPause = False self._afRange = 0 self.include_afRange = False self._afSpeed = 0 self.include_afSpeed = False self._afTrigger = 0 self.include_afTrigger = False self._afWindows = () self.include_afWindows = False self._analogueGain = 1.0 self.include_analogueGain = False self._awbEnable = True self.include_awbEnable = False self._awbMode = 0 self.include_awbMode = False self._brightness = 0.0 self.include_brightness = False self._colourGains = (0, 0) self.include_colourGains = False self._contrast = 1.0 self.include_contrast = False self._exposureTime = 0 self.include_exposureTime = False self._exposureValue = 0.0 self.include_exposureValue = False self._frameDurationLimits = (0, 0) self.include_frameDurationLimits = False self._hdrMode = 0 self.include_hdrMode = False self._noiseReductionMode = 0 self.include_noiseReductionMode = False self._saturation = 1.0 self.include_saturation = False self._scalerCrop = (0, 0, 4608, 2592) self.include_scalerCrop = False self._sharpness = 1.0 self.include_sharpness = False self.usbCamControls = {} def dict(self) -> dict: dict={} dict["AeConstraintMode"] = [self.include_aeConstraintMode, self._aeConstraintMode] dict["AeEnable"] = [self.include_aeEnable, self._aeEnable ] dict["AeExposureMode"] = [self.include_aeExposureMode, self._aeExposureMode] dict["AeFlickerMode"] = [self.include_aeFlickerMode, self._aeFlickerMode] dict["AeFlickerPeriod"] = [self.include_aeFlickerPeriod, self._aeFlickerPeriod] dict["AeMeteringMode"] = [self.include_aeMeteringMode, self._aeMeteringMode] dict["AfMode"] = [self.include_afMode, self._afMode] dict["LensPosition"] = [self.include_lensPosition, self._lensPosition] dict["AfMetering"] = [self.include_afMetering, self._afMetering] dict["AfPause"] = [self.include_afPause, self._afPause] dict["AfRange"] = [self.include_afRange, self._afRange] dict["AfSpeed"] = [self.include_afSpeed, self._afSpeed] dict["AfTrigger"] = [self.include_afTrigger, self._afTrigger] dict["AfWindows"] = [self.include_afWindows, self._afWindows] dict["AnalogueGain"] = [self.include_analogueGain, self._analogueGain] dict["AwbEnable"] = [self.include_awbEnable, self._awbEnable] dict["AwbMode"] = [self.include_awbMode, self._awbMode] dict["Brightness"] = [self.include_brightness, self._brightness] dict["ColourGains"] = [self.include_colourGains, self._colourGains] dict["Contrast"] = [self.include_contrast, self._contrast] dict["ExposureTime"] = [self.include_exposureTime, self._exposureTime] dict["ExposureValue"] = [self.include_exposureValue, self._exposureValue] dict["FrameDurationLimits"] = [self.include_frameDurationLimits, self._frameDurationLimits] dict["HdrMode"] = [self.include_hdrMode, self._hdrMode] dict["NoiseReductionMode"] = [self.include_noiseReductionMode, self._noiseReductionMode] dict["Saturation"] = [self.include_saturation, self._saturation] dict["ScalerCrop"] = [self.include_scalerCrop, self._scalerCrop] dict["Sharpness"] = [self.include_sharpness, self._sharpness] return dict @property def aeConstraintMode(self) -> int: return self._aeConstraintMode @aeConstraintMode.setter def aeConstraintMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2 \ or value == 3: self._aeConstraintMode = value else: raise ValueError("Invalid value for aeConstraintMode") @aeConstraintMode.deleter def aeConstraintMode(self): del self._aeConstraintMode @property def aeEnable(self) -> bool: return self._aeEnable @aeEnable.setter def aeEnable(self, value: bool): self._aeEnable = value @aeEnable.deleter def aeEnable(self): del self._aeEnable @property def aeExposureMode(self) -> int: return self._aeExposureMode @aeExposureMode.setter def aeExposureMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2 \ or value == 3: self._aeExposureMode = value else: raise ValueError("Invalid value for aeExposureMode") @aeExposureMode.deleter def aeExposureMode(self): del self._aeExposureMode @property def aeFlickerMode(self) -> int: return self._aeFlickerMode @aeFlickerMode.setter def aeFlickerMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2: self._aeFlickerMode = value else: raise ValueError("Invalid value for aeFlickerMode") @aeFlickerMode.deleter def aeFlickerMode(self): del self._aeFlickerMode @property def aeFlickerPeriod(self) -> int: return self._aeFlickerPeriod @aeFlickerPeriod.setter def aeFlickerPeriod(self, value: int): if value > 0: self._aeFlickerPeriod = value else: raise ValueError("Invalid value for aeFlickerPeriod") @aeFlickerPeriod.deleter def aeFlickerPeriod(self): del self._aeFlickerPeriod @property def aeMeteringMode(self) -> int: return self._aeMeteringMode @aeMeteringMode.setter def aeMeteringMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2 \ or value == 3: self._aeMeteringMode = value else: raise ValueError("Invalid value for aeMeteringMode") @aeMeteringMode.deleter def aeMeteringMode(self): del self._aeMeteringMode @property def afMode(self) -> int: return self._afMode @afMode.setter def afMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2: self._afMode = value else: raise ValueError("Invalid value for afMode") @afMode.deleter def afMode(self): del self._afMode @property def lensPosition(self) -> float: return self._lensPosition @lensPosition.setter def lensPosition(self, value: float): self._lensPosition = value @lensPosition.deleter def lensPosition(self): del self._lensPosition @property def focalDistance(self) -> float: if self._lensPosition == 0: return 9999.9 else: fd = 1.0 / self._lensPosition fd = int(1000 * fd)/1000 return fd @focalDistance.setter def focalDistance(self, value: float): if value > 0: if value > 9999.9: self._lensPosition = 0 else: self._lensPosition = 1.0 / value else: self._lensPosition = 9999.9 @property def afMetering(self) -> int: return self._afMetering @afMetering.setter def afMetering(self, value: int): if value == 0 \ or value == 1: self._afMetering = value else: raise ValueError("Invalid value for afMetering") @afMetering.deleter def afMetering(self): del self._afMetering @property def afPause(self) -> int: return self._afPause @afPause.setter def afPause(self, value: int): if value == 0 \ or value == 1 \ or value == 2: self._afPause = value else: raise ValueError("Invalid value for afPause") @afPause.deleter def afPause(self): del self._afPause @property def afRange(self) -> int: return self._afRange @afRange.setter def afRange(self, value: int): if value == 0 \ or value == 1 \ or value == 2: self._afRange = value else: raise ValueError("Invalid value for afRange") @afRange.deleter def afRange(self): del self._afRange @property def afSpeed(self) -> int: return self._afSpeed @afSpeed.setter def afSpeed(self, value: int): if value == 0 \ or value == 1: self._afSpeed = value else: raise ValueError("Invalid value for afSpeed") @afSpeed.deleter def afSpeed(self): del self._afSpeed @property def scalerCrop(self) -> tuple: return self._scalerCrop @scalerCrop.setter def scalerCrop(self, value: tuple): self._scalerCrop = value @scalerCrop.deleter def scalerCrop(self): del self._scalerCrop @property def scalerCropStr(self) -> str: return "(" + str(self._scalerCrop[0]) + "," + str(self._scalerCrop[1]) + "," + str(self._scalerCrop[2]) + "," + str(self._scalerCrop[3]) + ")" @scalerCropStr.setter def scalerCropStr(self, value: str): self._scalerCrop = CameraControls._parseRectTuple(value) @property def afTrigger(self) -> int: return self._afTrigger @afTrigger.setter def afTrigger(self, value: int): if value == 0 \ or value == 1: self._afTrigger = value else: raise ValueError("Invalid value for afTrigger") @afTrigger.deleter def afTrigger(self): del self._afTrigger @property def afWindows(self) -> tuple: return self._afWindows @afWindows.setter def afWindows(self, value: tuple): self._afWindows = value @afWindows.deleter def afWindows(self): del self._afWindows @property def afWindowsStr(self) -> str: res = "(" for win in self.afWindows: if len(res) > 1: res = res + "," res = res + "(" + str(win[0]) + "," + str(win[1]) + "," + str(win[2]) + "," + str(win[3]) + ")" res = res + ")" return res @afWindowsStr.setter def afWindowsStr(self, value: str): """Parse the string representation for afWindows """ self._afWindows = () # Get the list of windows winlist = CameraControls._parseWindows(value) for win in winlist: awin = CameraControls._parseRectTuple(win) # Add window from list to _afWindows tuple awin = (awin,) self._afWindows += awin @property def analogueGain(self) -> float: return self._analogueGain @analogueGain.setter def analogueGain(self, value: float): if value >= 1: self._analogueGain = value else: raise ValueError("Invalid value for _analogueGain. Must be >= 1.") @analogueGain.deleter def analogueGain(self): del self._analogueGain @property def awbEnable(self) -> bool: return self._awbEnable @awbEnable.setter def awbEnable(self, value: bool): self._awbEnable = value @awbEnable.deleter def awbEnable(self): del self._awbEnable @property def awbMode(self) -> int: return self._awbMode @awbMode.setter def awbMode(self, value: int): if value == 0 \ or value == 2 \ or value == 3 \ or value == 4 \ or value == 5 \ or value == 6 \ or value == 7: self._awbMode = value else: raise ValueError("Invalid value for awbMode") @awbMode.deleter def awbMode(self): del self._awbMode @property def brightness(self) -> float: return self._brightness @brightness.setter def brightness(self, value: float): self._brightness = value @brightness.deleter def brightness(self): del self._brightness @property def colourGains(self) -> tuple: return self._colourGains @colourGains.setter def colourGains(self, value: tuple): if len(value) == 2: if value[0] >= 0.0 \ and value[1] >= 0.0 \ and value[0] <= 32.0 \ and value[1] <= 32.0: self._colourGains = value else: raise ValueError("Invalid value for colourGains. Values must be in range [0.0;32.0]") else: raise ValueError("Invalid value for colourGains. Must be tuple of 2") @colourGains.deleter def colourGains(self): del self._colourGains @property def colourGainRed(self) -> float: return self._colourGains[0] @property def colourGainBlue(self) -> float: return self._colourGains[1] @property def contrast(self) -> float: return self._contrast @contrast.setter def contrast(self, value: float): self._contrast = value @contrast.deleter def contrast(self): del self._contrast @property def exposureTime(self) -> int: return self._exposureTime @exposureTime.setter def exposureTime(self, value: int): if value >= 0: self._exposureTime = value else: raise ValueError("Invalid value for exposureTime. Must be > 0") @exposureTime.deleter def exposureTime(self): del self._exposureTime @property def exposureTimeSec(self) -> float: return float(self._exposureTime / 1000000) @exposureTimeSec.setter def exposureTimeSec(self, value: float): if value >= 0: self._exposureTime = int(value * 1000000) else: raise ValueError("Invalid value for exposureTime. Must be > 0") @property def exposureValue(self) -> float: return self._exposureValue @exposureValue.setter def exposureValue(self, value: float): if value >= -8.0 \ and value <= 8.0: self._exposureValue = value else: raise ValueError("Invalid value for exposureValue. Must be in range [-8.0;8.0]") @exposureValue.deleter def exposureValue(self): del self._exposureValue @property def frameDurationLimits(self) -> tuple: return self._frameDurationLimits @frameDurationLimits.setter def frameDurationLimits(self, value: tuple): if value[0] >= 0 \ and value[1] >= 0: self._frameDurationLimits = value else: raise ValueError("Invalid value for frameDurationLimits") @frameDurationLimits.deleter def frameDurationLimits(self): del self._frameDurationLimits @property def frameDurationLimitMax(self) -> int: return self._frameDurationLimits[0] @property def frameDurationLimitMin(self) -> int: return self._frameDurationLimits[1] @property def hdrMode(self) -> int: return self._hdrMode @hdrMode.setter def hdrMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2 \ or value == 3 \ or value == 4: self._hdrMode = value else: raise ValueError("Invalid value for hdrMode") @hdrMode.deleter def hdrMode(self): del self._hdrMode @property def noiseReductionMode(self) -> int: return self._noiseReductionMode @noiseReductionMode.setter def noiseReductionMode(self, value: int): if value == 0 \ or value == 1 \ or value == 2: self._noiseReductionMode = value else: raise ValueError("Invalid value for noiseReductionMode") @noiseReductionMode.deleter def noiseReductionMode(self): del self._noiseReductionMode @property def saturation(self) -> float: return self._saturation @saturation.setter def saturation(self, value: float): self._saturation = value @saturation.deleter def saturation(self): del self._saturation @property def sharpness(self) -> float: return self._sharpness @sharpness.setter def sharpness(self, value: float): self._sharpness = value @sharpness.deleter def sharpness(self): del self._sharpness @property def usbCamControls(self) -> dict: return self._usbCamControls @usbCamControls.setter def usbCamControls(self, value: dict): self._usbCamControls = value @usbCamControls.deleter def usbCamControls(self): del self._usbCamControls @staticmethod def _parseWindows(wins: str) -> list: """ Parses the tuple-string of one or multiple rectangles "((x,x,x,x),(x,x,x,x))" and returns an array of rectangles as strings """ resa = [] if wins.startswith("("): wns = wins[1:] if wns.endswith(")"): wns = wns[0: len(wns) - 1] while len(wns) > 0: i = wns.find(")") if i > 0: wn = wns[0: i + 1] resa.append(wn) if i < len(wns): wns = wns[i + 2:].strip() else: wns = "" else: wns = "" return resa @staticmethod def _parseRectTuple(stuple: str) -> tuple: """ Parse a Python tuple string for libcamera.Rectangle "(xOffset, yOffset, width, height)" """ rest = (0, 0, 0, 0) if stuple.startswith("("): tpl = stuple[1:] if tpl.endswith(")"): tpl = tpl[0: len(tpl) - 1] res = tpl.rsplit(",") if len(res) == 4: rest = (int(res[0]), int(res[1]), int(res[2]), int(res[3])) return rest @classmethod def initFromDict(cls, dict:dict): cc = CameraControls() for key, value in dict.items(): if value is None: setattr(cc, key, value) else: if key == "_scalerCrop": setattr(cc, key, tuple(value)) elif key == "_frameDurationLimits": setattr(cc, key, tuple(value)) elif key == "_colourGains": setattr(cc, key, tuple(value)) elif key == "_afWindows": afws = () for el in value: afw = (tuple(el),) afws += afw setattr(cc, key, afws) elif key == "_usbCamControls": setattr(cc, key, value) else: setattr(cc, key, value) return cc class SensorMode(): """ The class represents a specific sensor mode of the camera """ def __init__(self): self._id = None self._format = None self._unpacked = None self._bit_depth = None self._size = None self._fps = None self._crop_limits = None self._exposure_limits = None @property def id(self) -> int: return self._id @id.setter def id(self, value: int): self._id = value @property def format(self) -> str: return self._format @format.setter def format(self, value: str): self._format = value @property def unpacked(self) -> str: return self._unpacked @unpacked.setter def unpacked(self, value: str): self._unpacked = value @property def bit_depth(self) -> int: return self._bit_depth @bit_depth.setter def bit_depth(self, value: int): self._bit_depth = value @property def size(self) -> tuple[int, int]: return self._size @size.setter def size(self, value: tuple[int, int]): self._size = value @property def fps(self) -> float: return self._fps @fps.setter def fps(self, value: float): self._fps = value @property def crop_limits(self) -> tuple: return self._crop_limits @crop_limits.setter def crop_limits(self, value: tuple): self._crop_limits = value @property def exposure_limits(self) -> tuple: return self._exposure_limits @exposure_limits.setter def exposure_limits(self, value: tuple): self._exposure_limits = value @property def tabId(self) -> str: return "sensormode" + str(self.id) @property def tabButtonId(self) -> str: return "sensormodetab" + str(self.id) @property def tabTitle(self) -> str: return "Sensor Mode " + str(self.id) class TuningConfig(): def __init__(self): self._loadTuningFile = False self._tuningFolderDef = None self._tuningFolder = None self._tuningFile = "" @property def loadTuningFile(self) -> bool: return self._loadTuningFile @loadTuningFile.setter def loadTuningFile(self, value: bool): self._loadTuningFile = value @property def tuningFolderDef(self) -> str: return self._tuningFolderDef @property def tuningFolder(self) -> str: return self._tuningFolder @tuningFolder.setter def tuningFolder(self, value: str): self._tuningFolder = value @property def tuningFile(self) -> str: return self._tuningFile @tuningFile.setter def tuningFile(self, value: str): self._tuningFile = value @property def tuningFilePath(self) -> str: if self.tuningFolder is None: return self._tuningFile else: return self.tuningFolder + "/" + self._tuningFile @property def isDefaultFolder(self) -> bool: return self.tuningFolder == self.tuningFolderDef @classmethod def initFromDict(cls, dict:dict): cc = TuningConfig() for key, value in dict.items(): if value is None: setattr(cc, key, value) else: setattr(cc, key, value) return cc class AiConfig(): def __init__(self): self._enable = False self._modelFolderDef = "/usr/share/imx500-models" self.tasks = ["Classification", "Object Detection", "Pose Estimation", "Segmentation"] self._task = None self._modelFolder = "/usr/share/imx500-models" self._modelFiles = [] self._modelFile = "" self._modelIntrinsics = None self._drawOnLores = True self._drawOnMain = False self._topK = 3 self._detectionThreshold = 0.6 self._iouThreshold = 0.6 self._maxDetections = 10 @property def enable(self) -> bool: return self._enable @enable.setter def enable(self, value: bool): self._enable = value @property def task(self) -> str: return self._task @task.setter def task(self, value: str): self._task = value @property def modelFolder(self) -> str: return self._modelFolder @modelFolder.setter def modelFolder(self, value: str): self._modelFolder = value @property def modelFiles(self) -> list[str]: return self._modelFiles @modelFiles.setter def modelFiles(self, value: list[str]): self._modelFiles = value @property def modelFile(self) -> str: return self._modelFile @modelFile.setter def modelFile(self, value: str): self._modelFile = value @property def modelIntrinsics(self) -> dict: return self._modelIntrinsics @modelIntrinsics.setter def modelIntrinsics(self, value: dict): self._modelIntrinsics = value @property def drawOnLores(self) -> bool: return self._drawOnLores @drawOnLores.setter def drawOnLores(self, value: bool): self._drawOnLores = value @property def drawOnMain(self) -> bool: return self._drawOnMain @drawOnMain.setter def drawOnMain(self, value: bool): self._drawOnMain = value @property def topK(self) -> int: return self._topK @topK.setter def topK(self, value: int): self._topK = value @property def detectionThreshold(self) -> float: return self._detectionThreshold @detectionThreshold.setter def detectionThreshold(self, value: float): self._detectionThreshold = value @property def iouThreshold(self) -> float: return self._iouThreshold @iouThreshold.setter def iouThreshold(self, value: float): self._iouThreshold = value @property def maxDetections(self) -> int: return self._maxDetections @maxDetections.setter def maxDetections(self, value: int): self._maxDetections = value @classmethod def initFromDict(cls, dict:dict): cc = AiConfig() for key, value in dict.items(): if value is None: setattr(cc, key, value) else: setattr(cc, key, value) return cc class CameraConfig(): def __init__(self): self._id = "" self._use_case = "" self._transform_hflip = False self._transform_vflip = False self._colour_space = "sYCC" self._buffer_count = 1 self._queue = False self._display = None self._encode = None self._sensor_mode = "0" self._stream = "main" self._stream_size = None self._stream_size_align = False self._format = "RGB888" self._controls = {} @property def id(self) -> str: return self._id @id.setter def id(self, value: str): self._id = value @property def use_case(self) -> str: return self._use_case @use_case.setter def use_case(self, value: str): self._use_case = value @property def transform_hflip(self) -> bool: return self._transform_hflip @transform_hflip.setter def transform_hflip(self, value: bool): self._transform_hflip = value @property def transform_vflip(self) -> bool: return self._transform_vflip @transform_vflip.setter def transform_vflip(self, value: bool): self._transform_vflip = value @property def colour_space(self) -> str: return self._colour_space @colour_space.setter def colour_space(self, value: str): self._colour_space = value @property def buffer_count(self) -> int: return self._buffer_count @buffer_count.setter def buffer_count(self, value: int): self._buffer_count = value @property def queue(self) -> bool: return self._queue @queue.setter def queue(self, value: bool): self._queue = value @property def display(self) -> str: return self._display @display.setter def display(self, value: str): self._display = value @property def encode(self) -> str: return self._encode @encode.setter def encode(self, value: str): if value is None: self._encode = value else: if value == "main" \ or value == "lores" \ or value == "raw": self._encode = value else: raise ValueError("Invalid value for encode: %s", value) @property def sensor_mode(self) -> str: return self._sensor_mode @sensor_mode.setter def sensor_mode(self, value: str): self._sensor_mode = value @property def stream(self) -> str: return self._stream @stream.setter def stream(self, value: str): if value == "main" \ or value == "lores" \ or value == "raw": self._stream = value else: raise ValueError("Invalid value for stream: %s. Must be 'main', 'lores' or 'raw'", value) @property def stream_size(self) -> tuple[int, int]: return self._stream_size @stream_size.setter def stream_size(self, value: tuple[int, int]): self._stream_size = value @property def stream_size_align(self) -> bool: return self._stream_size_align @stream_size_align.setter def stream_size_align(self, value: bool): self._stream_size_align = value @property def format(self) -> str: return self._format @format.setter def format(self, value: str): self._format = value @property def controls(self) -> dict: return self._controls @controls.setter def controls(self, value: dict): self._controls = value @property def tabId(self) -> str: return "cfg" + self.id @property def tabButtonId(self) -> str: return "cfg" + self.id + "btn" @property def tabTitle(self) -> str: return "Config " + self.id @classmethod def initFromDict(cls, dict:dict): cc = CameraConfig() for key, value in dict.items(): if value is None: setattr(cc, key, value) else: if key == "_stream_size": setattr(cc, key, tuple(value)) elif key == "_controls": ctrlt = {} for ckey, cvalue in value.items(): vt = cvalue if ckey == "ScalerCrop": vt = tuple(cvalue) elif ckey == "FrameDurationLimits": vt = tuple(cvalue) elif ckey == "ColourGains": vt = tuple(cvalue) elif ckey == "AfWindows": afws = () for el in cvalue: afw = (tuple(el),) afws += afw vt = afws else: vt = cvalue ctrlt[ckey] = vt setattr(cc, key, ctrlt) else: setattr(cc, key, value) return cc class CameraProperties(): def __init__(self): self._hasFocus = True self._hasFlicker = True self._hasHdr = True self._model = None self._unitCellSize = None self._location = None self._rotation = None self._pixelArraySize = None self._pixelArrayActiveAreas = None self._colorFilterArrangement = None self._scalerCropMaximum = None self._systemDevices = None self._sensorSensitivity = None self._colorSpace = None @property def hasFocus(self) -> bool: return self._hasFocus @hasFocus.setter def hasFocus(self, value: bool): self._hasFocus = value @hasFocus.deleter def hasFocus(self): del self._hasFocus @property def hasFlicker(self) -> bool: return self._hasFlicker @hasFlicker.setter def hasFlicker(self, value: bool): self._hasFlicker = value @hasFlicker.deleter def hasFlicker(self): del self._hasFlicker @property def hasHdr(self) -> bool: return self._hasHdr @hasHdr.setter def hasHdr(self, value: bool): self._hasHdr = value @hasHdr.deleter def hasHdr(self): del self._hasHdr @property def model(self): return self._model @model.setter def model(self, value: str): self._model = value @model.deleter def model(self): del self._model @property def unitCellSize(self): return self._unitCellSize @unitCellSize.setter def unitCellSize(self, value: str): self._unitCellSize = value @unitCellSize.deleter def unitCellSize(self): del self._unitCellSize @property def location(self): return self._location @location.setter def location(self, value: str): self._location = value @location.deleter def location(self): del self._location @property def rotation(self): return self._rotation @rotation.setter def rotation(self, value: str): self._rotation = value @rotation.deleter def rotation(self): del self._rotation @property def pixelArraySize(self): return self._pixelArraySize @pixelArraySize.setter def pixelArraySize(self, value: str): self._pixelArraySize = value @pixelArraySize.deleter def pixelArraySize(self): del self._pixelArraySize @property def pixelArrayActiveAreas(self): return self._pixelArrayActiveAreas @pixelArrayActiveAreas.setter def pixelArrayActiveAreas(self, value: str): self._pixelArrayActiveAreas = value @pixelArrayActiveAreas.deleter def pixelArrayActiveAreas(self): del self._pixelArrayActiveAreas @property def colorFilterArrangement(self): return self._colorFilterArrangement @colorFilterArrangement.setter def colorFilterArrangement(self, value: str): self._colorFilterArrangement = value @colorFilterArrangement.deleter def colorFilterArrangement(self): del self._colorFilterArrangement @property def scalerCropMaximum(self): return self._scalerCropMaximum @scalerCropMaximum.setter def scalerCropMaximum(self, value: str): self._scalerCropMaximum = value @scalerCropMaximum.deleter def scalerCropMaximum(self): del self._scalerCropMaximum @property def systemDevices(self): return self._systemDevices @systemDevices.setter def systemDevices(self, value: str): self._systemDevices = value @systemDevices.deleter def systemDevices(self): del self._systemDevices @property def sensorSensitivity(self) -> float: return self._sensorSensitivity @sensorSensitivity.setter def sensorSensitivity(self, value: float): self._sensorSensitivity = value @property def colorSpace(self): return self._colorSpace @colorSpace.setter def colorSpace(self, value: str): self._colorSpace = value @colorSpace.deleter def colorSpace(self): del self._colorSpace @classmethod def initFromDict(cls, dict:dict): cp = CameraProperties() for key, value in dict.items(): if value is None: setattr(cp, key, value) else: if key == "_pixelArraySize": setattr(cp, key, tuple(value)) elif key == "_scalerCropMaximum": setattr(cp, key, tuple(value)) elif key == "_pixelArrayActiveAreas": paas = () for el in value: paa = (tuple(el),) paas += paa setattr(cp, key, paas) else: setattr(cp, key, value) return cp class vButton(): """ Versatile button """ def __init__(self) -> None: self._row = 0 self._col = 0 self._isVisible = False self._needsConfirm = False self._buttonColor = None self._buttonShape = None self._buttonText = "" self._buttonExec = "" @property def row(self) -> int: return self._row @row.setter def row(self, value: int): self._row = value @property def col(self) -> int: return self._col @col.setter def col(self, value: int): self._col = value @property def isVisible(self) -> bool: return self._isVisible @isVisible.setter def isVisible(self, value: bool): self._isVisible = value @property def needsConfirm(self) -> bool: return self._needsConfirm @needsConfirm.setter def needsConfirm(self, value: bool): self._needsConfirm = value @property def buttonColor(self) -> str: return self._buttonColor @buttonColor.setter def buttonColor(self, value: str): self._buttonColor = value @property def buttonShape(self) -> str: return self._buttonShape @buttonShape.setter def buttonShape(self, value: str): self._buttonShape = value @property def buttonText(self) -> str: return self._buttonText @buttonText.setter def buttonText(self, value: str): self._buttonText = value @property def buttonExec(self) -> str: return self._buttonExec @buttonExec.setter def buttonExec(self, value: str): self._buttonExec = value @classmethod def initFromDict(cls, dict:dict): vb = vButton() for key, value in dict.items(): if value is None: setattr(vb, key, value) else: setattr(vb, key, value) return vb class ActionButton(): """ Action button """ def __init__(self) -> None: self._row = 0 self._col = 0 self._isVisible = False self._needsConfirm = False self._buttonColor = None self._buttonShape = None self._buttonText = "" self._buttonAction = "" @property def row(self) -> int: return self._row @row.setter def row(self, value: int): self._row = value @property def col(self) -> int: return self._col @col.setter def col(self, value: int): self._col = value @property def isVisible(self) -> bool: return self._isVisible @isVisible.setter def isVisible(self, value: bool): self._isVisible = value @property def needsConfirm(self) -> bool: return self._needsConfirm @needsConfirm.setter def needsConfirm(self, value: bool): self._needsConfirm = value @property def buttonColor(self) -> str: return self._buttonColor @buttonColor.setter def buttonColor(self, value: str): self._buttonColor = value @property def buttonShape(self) -> str: return self._buttonShape @buttonShape.setter def buttonShape(self, value: str): self._buttonShape = value @property def buttonText(self) -> str: return self._buttonText @buttonText.setter def buttonText(self, value: str): self._buttonText = value @property def buttonAction(self) -> str: return self._buttonAction @buttonAction.setter def buttonAction(self, value: str): self._buttonAction = value @classmethod def initFromDict(cls, dict:dict): ab = ActionButton() for key, value in dict.items(): if value is None: setattr(ab, key, value) else: setattr(ab, key, value) return ab class LiveButton(): """ Live button """ def __init__(self) -> None: self._row = 0 self._col = 0 self._isVisible = False self._needsConfirm = False self._buttonColor = None self._buttonShape = None self._buttonText = "" self._isAction = False self._buttonAction = "" self._buttonExec = "" @property def row(self) -> int: return self._row @row.setter def row(self, value: int): self._row = value @property def col(self) -> int: return self._col @col.setter def col(self, value: int): self._col = value @property def isVisible(self) -> bool: return self._isVisible @isVisible.setter def isVisible(self, value: bool): self._isVisible = value @property def needsConfirm(self) -> bool: return self._needsConfirm @needsConfirm.setter def needsConfirm(self, value: bool): self._needsConfirm = value @property def buttonColor(self) -> str: return self._buttonColor @buttonColor.setter def buttonColor(self, value: str): self._buttonColor = value @property def buttonShape(self) -> str: return self._buttonShape @buttonShape.setter def buttonShape(self, value: str): self._buttonShape = value @property def buttonText(self) -> str: return self._buttonText @buttonText.setter def buttonText(self, value: str): self._buttonText = value @property def isAction(self) -> bool: return self._isAction @isAction.setter def isAction(self, value: bool): self._isAction = value @property def buttonAction(self) -> str: return self._buttonAction @buttonAction.setter def buttonAction(self, value: str): self._buttonAction = value @property def buttonExec(self) -> str: return self._buttonExec @buttonExec.setter def buttonExec(self, value: str): self._buttonExec = value @classmethod def initFromDict(cls, dict:dict): lb = LiveButton() for key, value in dict.items(): if value is None: setattr(lb, key, value) else: setattr(lb, key, value) return lb class StereoConfig(): intents = ["DepthMap", "3DVideo",] intentNames = ["Depth Map", "3D Video",] intentAlgos = [["StereoBM", "StereoSGBM",],[]] intentAlgoNames = [["Block Matching", "Semi-Global Matching",],[]] intentAlgoLinks = [ [ "https://docs.opencv.org/4.6.0/d9/dba/classcv_1_1StereoBM.html", "https://docs.opencv.org/4.6.0/d2/d85/classcv_1_1StereoSGBM.html", ], [], ] calibrationPatterns = ["Chessboard",] calibrationPatternRefs = ["https://github.com/opencv/opencv/blob/4.x/doc/pattern.png",] def __init__(self): self._calibPhotosOK = {} self._calibShowCorners = False self._calibPhotos = {} self._calibPhotosCrn = {} self._calibPhotosCount = {} self._calibPhotosPath = "" self._calibPhotosSubPath = "" self._calibPhotosIdx = {} self._calibCameraOK = {} self._calibRmsReproError = {} self._calibStereoOK = False self._rectifyScale = 1 self._stereoRectifyOK = False self._calibDataSubPath = "" self._calibDataFile = "" self._calibDate = None self._calibDataOK = False self._calibPatternIdx = 0 self._calibPatternSize = (9, 6) # Default chessboard size self._calibPhotosTarget = 20 # Default number of calibration photos self._calibPhotoRecording = False self._calibPhotoRecordingMsg = "" self._applyCalibRectify = False self._intentIdx = 0 self._intentAlgoIdx = 0 self._bm_numDisparitiesFactor = 1 self._bm_blockSize = 21 self._sgbm_minDisparity = 0 self._sgbm_numDisparitiesFactor = 1 self._sgbm_blockSize = 3 self._sgbm_P1 = 0 self._sgbm_P2 = 0 self._sgbm_disp12MaxDiff = 0 self._sgbm_preFilterCap = 0 self._sgbm_uniquenessRatio = 0 self._sgbm_speckleWindowSize = 0 self._sgbm_speckleRange = 0 self._sgbm_mode = 0 @property def calibPhotosOK(self) -> dict: return self._calibPhotosOK @calibPhotosOK.setter def calibPhotosOK(self, value: dict): if isinstance(value, dict): self._calibPhotosOK = value else: raise ValueError("calibPhotosOK must be a dictionary") @property def calibShowCorners(self) -> bool: return self._calibShowCorners @calibShowCorners.setter def calibShowCorners(self, value: bool): self._calibShowCorners = value def isCalibPhotosOK(self, camL: str, camR: str) -> bool: """ Check if calibration photos are OK for the given camera IDs """ res = True if camL in self._calibPhotosOK: res = res and self._calibPhotosOK[camL] else: res = False if not camR is None: if camR in self._calibPhotosOK: res = res and self._calibPhotosOK[camR] else: res = False return res def isCalibCamerasOK(self, camL: str, camR: str) -> bool: """ Check if camera calibration is OK for the given camera IDs """ res = True if camL in self._calibCameraOK: res = res and self._calibCameraOK[camL] else: res = False if not camR is None: if camR in self._calibCameraOK: res = res and self._calibCameraOK[camR] else: res = False return res @property def calibPhotos(self) -> dict: return self._calibPhotos @calibPhotos.setter def calibPhotos(self, value: dict): if isinstance(value, dict): self._calibPhotos = value else: raise ValueError("calibPhotos must be a dictionary") @property def calibPhotosCrn(self) -> dict: return self._calibPhotosCrn @calibPhotosCrn.setter def calibPhotosCrn(self, value: dict): if isinstance(value, dict): self._calibPhotosCrn = value else: raise ValueError("calibPhotosCrn must be a dictionary") @property def calibPhotosCount(self) -> dict: return self._calibPhotosCount @calibPhotosCount.setter def calibPhotosCount(self, value: dict): if isinstance(value, dict): self._calibPhotosCount = value else: raise ValueError("calibPhotosCount must be a dictionary") def getCalibPhotosCount(self, cam: str) -> int: """ Get the number of calibration photos for the given camera ID """ if cam is None: return 0 if cam in self._calibPhotosCount: return self._calibPhotosCount[cam] else: return 0 def hasCalibPhotos(self, camL: str, camR: str) -> bool: """ Check if calibration photos have been taken for the given camera IDs """ res = True if camL in self._calibPhotosCount: res = res and self._calibPhotosCount[camL] > 0 else: res = False if camR in self._calibPhotosCount: res = res and self._calibPhotosCount[camR] > 0 else: res = False return res @property def calibPhotosPath(self) -> str: return self._calibPhotosPath @calibPhotosPath.setter def calibPhotosPath(self, value: str): if isinstance(value, str): self._calibPhotosPath = value else: raise ValueError("calibPhotosPath must be a string") @property def calibPhotosSubPath(self) -> str: return self._calibPhotosSubPath @calibPhotosSubPath.setter def calibPhotosSubPath(self, value: str): if isinstance(value, str): self._calibPhotosSubPath = value else: raise ValueError("calibPhotosSubPath must be a string") @property def calibPhotosIdx(self) -> dict: return self._calibPhotosIdx @calibPhotosIdx.setter def calibPhotosIdx(self, value: dict): if isinstance(value, dict): self._calibPhotosIdx = value else: raise ValueError("calibPhotosIdx must be a dictionary") def getCalibPhotosIdx(self, cam: str) -> int: """ Get the index of the calibration photos for the given camera ID """ res = 0 if not cam is None: if cam in self._calibPhotosIdx: res = self._calibPhotosIdx[cam] return res @property def calibCameraOK(self) -> dict: return self._calibCameraOK @calibCameraOK.setter def calibCameraOK(self, value: dict): if isinstance(value, dict): self._calibCameraOK = value else: raise ValueError("calibCameraOK must be a dictionary") @property def calibRmsReproError(self) -> dict: return self._calibRmsReproError @calibRmsReproError.setter def calibRmsReproError(self, value: dict): if isinstance(value, dict): self._calibRmsReproError = value else: raise ValueError("calibRmsReproError must be a dictionary") @property def calibStereoOK(self) -> bool: return self._calibStereoOK @calibStereoOK.setter def calibStereoOK(self, value: bool): if isinstance(value, bool): self._calibStereoOK = value else: raise ValueError("calibStereoOK must be a boolean") @property def rectifyScale(self) -> int: return self._rectifyScale @rectifyScale.setter def rectifyScale(self, value: int): if isinstance(value, int): self._rectifyScale = value else: raise ValueError("rectifyScale must be an integer") @property def stereoRectifyOK(self) -> bool: return self._stereoRectifyOK @stereoRectifyOK.setter def stereoRectifyOK(self, value: bool): if isinstance(value, bool): self._stereoRectifyOK = value else: raise ValueError("stereoRectifyOK must be a boolean") @property def calibDataSubPath(self) -> str: return self._calibDataSubPath @calibDataSubPath.setter def calibDataSubPath(self, value: str): if isinstance(value, str): self._calibDataSubPath = value else: raise ValueError("calibDataSubPath must be a string") @property def calibDataFile(self) -> str: return self._calibDataFile @calibDataFile.setter def calibDataFile(self, value: str): if isinstance(value, str): self._calibDataFile = value else: raise ValueError("calibDataFile must be a string") @property def calibDate(self) -> str: return self._calibDate @calibDate.setter def calibDate(self, value: datetime): if value is None: val = None else: val = datetime( year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute, ) self._calibDate = val @property def calibDataOK(self) -> bool: return self._calibDataOK @calibDataOK.setter def calibDataOK(self, value: bool): if isinstance(value, bool): self._calibDataOK = value else: raise ValueError("calibDataOK must be a boolean") @property def calibPatternIdx(self) -> int: return self._calibPatternIdx @calibPatternIdx.setter def calibPatternIdx(self, value: int): if value >= 0 and value < len(StereoConfig.calibrationPatterns): self._calibPatternIdx = value else: raise ValueError("Invalid calibration pattern index") @property def calibPattern(self) -> str: return StereoConfig.calibrationPatterns[self._calibPatternIdx] @property def calibPatternRef(self) -> str: if self._calibPatternIdx < len(StereoConfig.calibrationPatternRefs): return StereoConfig.calibrationPatternRefs[self._calibPatternIdx] else: return "" @property def calibPatternSize(self) -> tuple: return self._calibPatternSize @calibPatternSize.setter def calibPatternSize(self, value: tuple): if isinstance(value, tuple) and len(value) == 2: if value[0] > 0 and value[1] > 0: self._calibPatternSize = value else: raise ValueError("Invalid calibration pattern size. Must be positive integers") else: raise ValueError("calibPatternSize must be a tuple of two integers") @property def calibPhotosTarget(self) -> int: return self._calibPhotosTarget @calibPhotosTarget.setter def calibPhotosTarget(self, value: int): if isinstance(value, int) and value > 0: self._calibPhotosTarget = value else: raise ValueError("calibPhotosTarget must be a positive integer") @property def calibPhotoRecording(self) -> bool: return self._calibPhotoRecording @calibPhotoRecording.setter def calibPhotoRecording(self, value: bool): if isinstance(value, bool): self._calibPhotoRecording = value else: raise ValueError("calibPhotoRecording must be a boolean") @property def calibPhotoRecordingMsg(self) -> str: return self._calibPhotoRecordingMsg @calibPhotoRecordingMsg.setter def calibPhotoRecordingMsg(self, value: str): if isinstance(value, str): self._calibPhotoRecordingMsg = value else: raise ValueError("calibPhotoRecordingMsg must be a string") @property def applyCalibRectify(self) -> bool: return self._applyCalibRectify @applyCalibRectify.setter def applyCalibRectify(self, value: bool): if isinstance(value, bool): self._applyCalibRectify = value else: raise ValueError("applyCalibRectify must be a boolean") @property def intentIdx(self) -> int: return self._intentIdx @intentIdx.setter def intentIdx(self, value: int): if value >= 0 and value < len(StereoConfig.intents): self._intentIdx = value else: raise ValueError("Invalid intent index") @property def intent(self) -> str: return StereoConfig.intents[self._intentIdx] @property def intentName(self) -> str: return StereoConfig.intentNames[self._intentIdx] @property def intentAlgoIdx(self) -> int: return self._intentAlgoIdx @intentAlgoIdx.setter def intentAlgoIdx(self, value: int): if value >= 0 and value < len(StereoConfig.intentAlgos[self._intentIdx]): self._intentAlgoIdx = value else: raise ValueError("Invalid intent algorithm index") @property def intentAlgo(self) -> str: return StereoConfig.intentAlgos[self._intentIdx][self._intentAlgoIdx] @property def intentAlgoName(self) -> str: return StereoConfig.intentAlgoNames[self._intentIdx][self._intentAlgoIdx] @property def bm_numDisparitiesFactor(self) -> int: return self._bm_numDisparitiesFactor @bm_numDisparitiesFactor.setter def bm_numDisparitiesFactor(self, value: int): if value >= 0: self._bm_numDisparitiesFactor = value else: raise ValueError("Invalid value for bm_numDisparitiesFactor. Must be >= 0") @property def bm_blockSize(self) -> int: return self._bm_blockSize @bm_blockSize.setter def bm_blockSize(self, value: int): if value > 1 and value <= 255 and value % 2 == 1: self._bm_blockSize = value else: raise ValueError("Invalid value for bm_blockSize. Must be odd and in range [3;255]") @property def sgbm_minDisparity(self) -> int: return self._sgbm_minDisparity @sgbm_minDisparity.setter def sgbm_minDisparity(self, value: int): if value >= 0: self._sgbm_minDisparity = value else: raise ValueError("Invalid value for sgbm_minDisparity. Must be >= 0") @property def sgbm_numDisparitiesFactor(self) -> int: return self._sgbm_numDisparitiesFactor @sgbm_numDisparitiesFactor.setter def sgbm_numDisparitiesFactor(self, value: int): if value >= 0: self._sgbm_numDisparitiesFactor = value else: raise ValueError("Invalid value for sgbm_numDisparitiesFactor. Must be >= 0") @property def sgbm_blockSize(self) -> int: return self._sgbm_blockSize @sgbm_blockSize.setter def sgbm_blockSize(self, value: int): if value > 1 and value <= 255 and value % 2 == 1: self._sgbm_blockSize = value else: raise ValueError("Invalid value for sgbm_blockSize. Must be odd and in range [3;255]") @property def sgbm_P1(self) -> int: return self._sgbm_P1 @sgbm_P1.setter def sgbm_P1(self, value: int): if value >= 0: self._sgbm_P1 = value else: raise ValueError("Invalid value for sgbm_P1. Must be >= 0") @property def sgbm_P2(self) -> int: return self._sgbm_P2 @sgbm_P2.setter def sgbm_P2(self, value: int): if value >= 0: self._sgbm_P2 = value else: raise ValueError("Invalid value for sgbm_P2. Must be >= 0") @property def sgbm_disp12MaxDiff(self) -> int: return self._sgbm_disp12MaxDiff @sgbm_disp12MaxDiff.setter def sgbm_disp12MaxDiff(self, value: int): if value >= 0: self._sgbm_disp12MaxDiff = value else: raise ValueError("Invalid value for sgbm_disp12MaxDiff. Must be >= 0") @property def sgbm_preFilterCap(self) -> int: return self._sgbm_preFilterCap @sgbm_preFilterCap.setter def sgbm_preFilterCap(self, value: int): if value >= 0: self._sgbm_preFilterCap = value else: raise ValueError("Invalid value for sgbm_preFilterCap. Must be >= 0") @property def sgbm_uniquenessRatio(self) -> int: return self._sgbm_uniquenessRatio @sgbm_uniquenessRatio.setter def sgbm_uniquenessRatio(self, value: int): if value >= 0: self._sgbm_uniquenessRatio = value else: raise ValueError("Invalid value for sgbm_uniquenessRatio. Must be >= 0") @property def sgbm_speckleWindowSize(self) -> int: return self._sgbm_speckleWindowSize @sgbm_speckleWindowSize.setter def sgbm_speckleWindowSize(self, value: int): if value >= 0: self._sgbm_speckleWindowSize = value else: raise ValueError("Invalid value for sgbm_speckleWindowSize. Must be >= 0") @property def sgbm_speckleRange(self) -> int: return self._sgbm_speckleRange @sgbm_speckleRange.setter def sgbm_speckleRange(self, value: int): if value >= 0: self._sgbm_speckleRange = value else: raise ValueError("Invalid value for sgbm_speckleRange. Must be >= 0") @property def sgbm_mode(self) -> int: return self._sgbm_mode @sgbm_mode.setter def sgbm_mode(self, value: int): if value >= 0 and value <= 3: self._sgbm_mode = value else: raise ValueError("Invalid value for sgbm_mode. Must be 0 (default), 1 (SGBM), 2 (H-H), or 3 (H-H with subpixel refinement)") def getNextPhotoIdx(self) -> int: """ Return the next available index for fotos """ logger.debug("StereoConfig.getNextPhotoIdx") index = -1 if len(self.calibPhotos) > 0: for cam in self._calibPhotos: logger.debug("StereoConfig.getNextPhotoIdx - cam: %s - len(calibPhotos[cam])=%s", cam, len(self._calibPhotos[cam])) if len(self._calibPhotos[cam]) == 0: break else: for idx in range(0, len(self._calibPhotos[cam])): sp = self._calibPhotos[cam][idx] logger.debug("StereoConfig.getNextPhotoIdx - calibPhotos[%s][%s]=%s", cam, idx, sp) p = sp.find("/img") if p >= 0: inds = sp[p+4:p+7] if inds.isdigit(): ind = int(inds) else: raise ValueError(f"Invalid entry in calibration photos list: {sp}") else: raise ValueError(f"Invalid entry in calibration photos list: {sp}") logger.debug("StereoConfig.getNextPhotoIdx inds=%s, ind=%s", inds, ind) if ind > idx + 1: index = idx logger.debug("StereoConfig.getNextPhotoIdx - Found valid index: %s", index) break if index == -1: index = len(self._calibPhotos[cam]) logger.debug("StereoConfig.getNextPhotoIdx - No gap fond. index: %s", index) break if index >= 0: break if index == -1: index = 1 return index @classmethod def initFromDict(cls, dict:dict): sc = StereoConfig() for key, value in dict.items(): if key == "_calibPatternSize": setattr(sc, key, tuple(value)) else: if value is None: setattr(sc, key, value) else: setattr(sc, key, value) return sc class ServerConfig(): def __init__(self): self._serverStartTime = None self._unsavedChanges = False self._error = None self._error2 = None self._errorSource = None self._errorc2 = None self._errorc22 = None self._errorc2Source = None self._database = None self._raspiModelFull = "" self._raspiModelLower5 = False self._boardRevision = "" self._kernelVersion = "" self._debianVersion = "" self._noCamera = False self._supportedCameras = [] self._usbCamAvailable = False self._aiCamAvailable = False self._piCameras = [] self._activeCamera = 0 self._activeCameraIsUsb = False self._activeCameraHasAi = False self._activeCameraUsbDev = "" self._activeCameraInfo = "" self._activeCameraModel = "" self._secondCamera = None self._secondCameraIsUsb = False self._secondCameraHasAi = False self._secondCameraUsbDev = "" self._secondCameraInfo = "" self._secondCameraModel = "" self._hasMicrophone = False self._defaultMic = "" self._isMicMuted = False self._recordAudio = False self._audioSync = 0.3 self._photoRoot = "." self._cameraPhotoSubPath = "." self._prgOutputPath = "." self._photoType = "jpg" self._rawPhotoType = "dng" self._videoType = "mp4" self._isZoomModeDraw = False self._zoomFactor = 100 self._zoomFactorStep = 10 self._scalerCropLiveView = (0, 0, 4608, 2592) self._scalerCropMin = (0, 0, 4608, 2592) self._scalerCropMax = (0, 0, 4608, 2592) self._scalerCropDef = (0, 0, 4608, 2592) self._syncAspectRatio = True self._curMenu = "live" self._lastLiveTab = "focus" self._lastConfigTab = "cfglive" self._lastInfoTab = "camprops" self._lastPhotoSeriesTab = "series" self._lastTriggerTab = "trgcontrol" self._lastCamTab = "webcam" self._lastConsoleTab = "versbuttons" self._lastSettingsTab = "settingsparams" self._isLiveStream = False self._isLiveStream2 = None self._isVideoRecording = False self._isVideoRecording2 = False self._isStereoCamActive = False self._isStereoCamRecording = False self._isAudioRecording = False self._isPhotoSeriesRecording = False self._isTriggerRecording = False self._isTriggerWaiting = False self._isTriggerTesting = False self._isEventhandling = False self._isEventsWaiting = False self._isDisplayHidden = True self._displayPhoto = None self._displayFile = None self._displayMeta = None self._displayMetaFirst = 0 self._displayMetaLast = 999 self._displayHistogram = None self._displayContent = "meta" self._displayBuffer = {} self._cv2Available = False self._numpyAvailable = False self._matplotlibAvailable = False self._flaskJwtLibAvailable = False self._imx500Available = False self._munkresAvailable = False self._useUsbCameras = True self._useStereo = False self._useHistograms = False self._useCameraAi = False self._requireAuthForStreaming = False self._locLongitude = 0.0 self._locLatitude = 0.0 self._locElevation = 0.0 self._locTzKey = "localtime" self._pvCamera = None self._pvFrom = None self._pvTo = None self._pvList = [] self._useAPI = False self._API_active = False self._jwtAuthenticationActive = False self._jwtKeyStore = "" self._jwtAccessTokenExpirationMin = 60 self._jwtRefreshTokenExpirationDays = 0 self._streamingClients = [] self._vButtonsRows = 0 self._vButtonsCols = 0 self._vButtons = [] self._vButtonCommand = None self._vButtonArgs = None self._vButtonReturncode = None self._vButtonStdout = None self._vButtonStderr = None self._vButtonHasCommandLine = False self._aButtonsRows = 0 self._aButtonsCols = 0 self._aButtons = [] self._aButtonAction = None self._lButtonsRows = 0 self._lButtonsCols = 0 self._lButtons = [] self._curDeviceId = "" self._curDevice = None self._curDeviceType = None self._gpioDevices = [] self._cfgPath = None self._cfgBackupPath = None self._changeLog = [] self._versionCurrent = "" self._versionLatest = "" self._versionCheckTime = None self._versionCheckIntervalHours = 24 self._versionCheckEnabled = True self._versionCheckFrom = "" self._updateDone = False self._webCamActiveCamPhotoCfg = "LIVE" self._webCamSecondCamPhotoCfg = "LIVE" # Check access of microphone self.checkMicrophone() # Get Raspi Info model = self.getPiModel() self._raspiModelFull = model if model.startswith("Raspberry Pi 5"): self._raspiModelLower5 = False elif model.startswith("Raspberry Pi 4"): self._raspiModelLower5 = True elif model.startswith("Raspberry Pi 3"): self._raspiModelLower5 = True elif model.startswith("Raspberry Pi 2"): self._raspiModelLower5 = True elif model.startswith("Raspberry Pi 1"): self._raspiModelLower5 = True elif model.startswith("Raspberry Pi Zero W"): self._raspiModelLower5 = True elif model.startswith("Raspberry Pi Zero 2 W"): self._raspiModelLower5 = True else: self._raspiModelLower5 = False boardRev = self.getBoardRevision() self._boardRevision = boardRev debianVers = self.getDebianVersion() self._debianVersion = debianVers kernelVers = self.getKernelVersion() self._kernelVersion = kernelVers @property def serverStartTime(self) -> datetime: return self._serverStartTime @serverStartTime.setter def serverStartTime(self, value: datetime): self._serverStartTime = value @property def serverStartTimeStr(self) -> str: if self._serverStartTime is None: return "System time not synced at raspiCamSrv start" else: return self._serverStartTime.isoformat() @property def unsavedChanges(self) -> bool: return self._unsavedChanges @unsavedChanges.setter def unsavedChanges(self, value: bool): self._unsavedChanges = value @property def changeLog(self) -> list[dict]: return self._changeLog @changeLog.setter def changeLog(self, value: list): self._changeLog = value def addChangeLogEntry(self, entry: str): """ Adds a new entry to the change log """ entry = { "time": datetime.now(), "entry": entry} self._changeLog.append(entry) def clearChangeLog(self): """ Clears the change log """ self._changeLog = [] @property def error(self) -> str: return self._error @error.setter def error(self, value: str): self._error = value if value is None: self._errorSource = None self._error2 = None @property def error2(self) -> str: return self._error2 @error2.setter def error2(self, value: str): self._error2 = value @property def errorSource(self) -> str: return self._errorSource @errorSource.setter def errorSource(self, value: str): self._errorSource = value @property def errorc2(self) -> str: return self._errorc2 @errorc2.setter def errorc2(self, value: str): self._errorc2 = value if value is None: self._errorc2Source = None self._errorc22 = None @property def errorc22(self) -> str: return self._errorc22 @errorc22.setter def errorc22(self, value: str): self._errorc22 = value @property def errorc2Source(self) -> str: return self._errorc2Source @errorc2Source.setter def errorc2Source(self, value: str): self._errorc2Source = value @property def database(self) -> str: return self._database @database.setter def database(self, value: str): self._database = value @property def raspiModelFull(self) -> str: return self._raspiModelFull @raspiModelFull.setter def raspiModelFull(self, value: str): self._raspiModelFull = value @property def raspiModelLower5(self) -> bool: return self._raspiModelLower5 @raspiModelLower5.setter def raspiModelLower5(self, value: bool): self._raspiModelLower5 = value @property def boardRevision(self) -> str: return self._boardRevision @boardRevision.setter def boardRevision(self, value: str): self._boardRevision = value @property def kernelVersion(self) -> str: return self._kernelVersion @kernelVersion.setter def kernelVersion(self, value: str): self._kernelVersion = value @property def debianVersion(self) -> str: return self._debianVersion @debianVersion.setter def debianVersion(self, value: str): self._debianVersion = value @property def noCamera(self) -> bool: return self._noCamera @noCamera.setter def noCamera(self, value: bool): self._noCamera = value @property def supportedCameras(self) -> list: return self._supportedCameras @supportedCameras.setter def supportedCameras(self, value: list): self._supportedCameras = value @property def usbCamAvailable(self) -> bool: return self._usbCamAvailable @usbCamAvailable.setter def usbCamAvailable(self, value: bool): self._usbCamAvailable = value @property def aiCamAvailable(self) -> bool: return self._aiCamAvailable @aiCamAvailable.setter def aiCamAvailable(self, value: bool): self._aiCamAvailable = value @noCamera.setter def noCamera(self, value: bool): self._noCamera = value @property def piCameras(self) -> list: return self._piCameras @piCameras.setter def piCameras(self, value: list): self._piCameras = value @property def activeCamera(self) -> int: return self._activeCamera @activeCamera.setter def activeCamera(self, value: int): self._activeCamera = value @property def activeCameraIsUsb(self) -> bool: return self._activeCameraIsUsb @activeCameraIsUsb.setter def activeCameraIsUsb(self, value: bool): self._activeCameraIsUsb = value @property def activeCameraHasAi(self) -> bool: return self._activeCameraHasAi @activeCameraHasAi.setter def activeCameraHasAi(self, value: bool): self._activeCameraHasAi = value @property def activeCameraUsbDev(self) -> str: return self._activeCameraUsbDev @activeCameraUsbDev.setter def activeCameraUsbDev(self, value: str): self._activeCameraUsbDev = value @property def activeCameraInfo(self) -> str: return self._activeCameraInfo @activeCameraInfo.setter def activeCameraInfo(self, value: str): self._activeCameraInfo = value @property def activeCameraModel(self) -> str: return self._activeCameraModel @activeCameraModel.setter def activeCameraModel(self, value: str): self._activeCameraModel = value @property def secondCamera(self) -> int: return self._secondCamera @secondCamera.setter def secondCamera(self, value: int): self._secondCamera = value @property def secondCameraIsUsb(self) -> bool: return self._secondCameraIsUsb @secondCameraIsUsb.setter def secondCameraIsUsb(self, value: bool): self._secondCameraIsUsb = value @property def secondCameraHasAi(self) -> bool: return self._secondCameraHasAi @secondCameraHasAi.setter def secondCameraHasAi(self, value: bool): self._secondCameraHasAi = value @property def secondCameraUsbDev(self) -> str: return self._secondCameraUsbDev @secondCameraUsbDev.setter def secondCameraUsbDev(self, value: str): self._secondCameraUsbDev = value @property def secondCameraInfo(self) -> str: return self._secondCameraInfo @secondCameraInfo.setter def secondCameraInfo(self, value: str): self._secondCameraInfo = value @property def secondCameraModel(self) -> str: return self._secondCameraModel @secondCameraModel.setter def secondCameraModel(self, value: str): self._secondCameraModel = value @property def hasMicrophone(self) -> bool: return self._hasMicrophone @hasMicrophone.setter def hasMicrophone(self, value: bool): self._hasMicrophone = value @property def defaultMic(self) -> str: return self._defaultMic @defaultMic.setter def defaultMic(self, value: str): self._defaultMic = value @property def isMicMuted(self) -> bool: return self._isMicMuted @isMicMuted.setter def isMicMuted(self, value: bool): self._isMicMuted = value @property def recordAudio(self) -> bool: return self._recordAudio @recordAudio.setter def recordAudio(self, value: bool): self._recordAudio = value @property def audioSync(self) -> float: return self._audioSync @audioSync.setter def audioSync(self, value: float): self._audioSync = value @property def photoRoot(self): return self._photoRoot @photoRoot.setter def photoRoot(self, value: str): self._photoRoot = value @property def cameraPhotoSubPath(self): return self._cameraPhotoSubPath @cameraPhotoSubPath.setter def cameraPhotoSubPath(self, value: str): self._cameraPhotoSubPath = value @property def prgOutputPath(self): return self._prgOutputPath @prgOutputPath.setter def prgOutputPath(self, value: str): self._prgOutputPath = value @property def cameraHistogramSubPath(self): return self._cameraPhotoSubPath + "/hist" @property def photoType(self) -> str: return self._photoType @photoType.setter def photoType(self, value: str): if value.lower() == "jpg" \ or value.lower() == "jpeg" \ or value.lower() == "png" \ or value.lower() == "gif" \ or value.lower() == "bmp": self._photoType = value else: raise ValueError("Invalid photo format") @property def rawPhotoType(self) -> str: return self._rawPhotoType @rawPhotoType.setter def rawPhotoType(self, value: str): if value.lower() == "dng": self._rawPhotoType = value else: raise ValueError("Invalid raw photo format") @property def videoType(self) -> str: return self._videoType @videoType.setter def videoType(self, value: str): if value.lower() == "h264" \ or value.lower() == "mp4": self._videoType = value else: raise ValueError("Invalid video format") @property def isZoomModeDraw(self) -> bool: return self._isZoomModeDraw @isZoomModeDraw.setter def isZoomModeDraw(self, value: bool): self._isZoomModeDraw = value @property def zoomFactor(self): return self._zoomFactor @zoomFactor.setter def zoomFactor(self, value: int): if value > 100: value = 100 if value < self.zoomFactorStep: value = self.zoomFactorStep self._zoomFactor = value @property def zoomFactorStep(self): return self._zoomFactorStep @zoomFactorStep.setter def zoomFactorStep(self, value: int): if value > 20: value = 20 if value < 2: value = 2 self._zoomFactorStep = value @property def scalerCropLiveView(self) -> tuple: return self._scalerCropLiveView @scalerCropLiveView.setter def scalerCropLiveView(self, value: tuple): self._scalerCropLiveView = value @property def scalerCropLiveViewStr(self) -> str: return "(" + str(self._scalerCropLiveView[0]) + "," + str(self._scalerCropLiveView[1]) + "," + str(self._scalerCropLiveView[2]) + "," + str(self._scalerCropLiveView[3]) + ")" @scalerCropLiveViewStr.setter def scalerCropLiveViewStr(self, value: str): self._scalerCropLiveView = CameraControls._parseRectTuple(value) @property def scalerCropMin(self) -> tuple: return self._scalerCropMin @scalerCropMin.setter def scalerCropMin(self, value: tuple): self._scalerCropMin = value @property def scalerCropMax(self) -> tuple: return self._scalerCropMax @scalerCropMax.setter def scalerCropMax(self, value: tuple): self._scalerCropMax = value @property def scalerCropDef(self) -> tuple: return self._scalerCropDef @scalerCropDef.setter def scalerCropDef(self, value: tuple): self._scalerCropDef = value @property def syncAspectRatio(self) -> bool: return self._syncAspectRatio @syncAspectRatio.setter def syncAspectRatio(self, value: bool): self._syncAspectRatio = value @property def curMenu(self) -> str: return self._curMenu @curMenu.setter def curMenu(self, value: str): self._curMenu = value @property def lastLiveTab(self): return self._lastLiveTab @lastLiveTab.setter def lastLiveTab(self, value: str): self._lastLiveTab = value @property def lastConfigTab(self): return self._lastConfigTab @lastConfigTab.setter def lastConfigTab(self, value: str): self._lastConfigTab = value @property def lastInfoTab(self): return self._lastInfoTab @lastInfoTab.setter def lastInfoTab(self, value: str): self._lastInfoTab = value @property def lastPhotoSeriesTab(self): return self._lastPhotoSeriesTab @lastPhotoSeriesTab.setter def lastPhotoSeriesTab(self, value: str): self._lastPhotoSeriesTab = value @property def lastTriggerTab(self): return self._lastTriggerTab @lastTriggerTab.setter def lastTriggerTab(self, value: str): self._lastTriggerTab = value @property def lastCamTab(self): return self._lastCamTab @lastCamTab.setter def lastCamTab(self, value: str): self._lastCamTab = value @property def lastConsoleTab(self): return self._lastConsoleTab @lastConsoleTab.setter def lastConsoleTab(self, value: str): self._lastConsoleTab = value @property def lastSettingsTab(self): return self._lastSettingsTab @lastSettingsTab.setter def lastSettingsTab(self, value: str): self._lastSettingsTab = value @property def isDisplayHidden(self) -> bool: return self._isDisplayHidden @isDisplayHidden.setter def isDisplayHidden(self, value: bool): self._isDisplayHidden = value @property def isLiveStream(self) -> bool: return self._isLiveStream @isLiveStream.setter def isLiveStream(self, value: bool): self._isLiveStream = value @property def isLiveStream2(self) -> bool: return self._isLiveStream2 @isLiveStream2.setter def isLiveStream2(self, value: bool): self._isLiveStream2 = value @property def isVideoRecording(self) -> bool: return self._isVideoRecording @isVideoRecording.setter def isVideoRecording(self, value: bool): self._isVideoRecording = value @property def isVideoRecording2(self) -> bool: return self._isVideoRecording2 @isVideoRecording2.setter def isVideoRecording2(self, value: bool): self._isVideoRecording2 = value @property def isStereoCamActive(self) -> bool: return self._isStereoCamActive @isStereoCamActive.setter def isStereoCamActive(self, value: bool): self._isStereoCamActive = value @property def isStereoCamRecording(self) -> bool: return self._isStereoCamRecording @isStereoCamRecording.setter def isStereoCamRecording(self, value: bool): self._isStereoCamRecording = value @property def isAudioRecording(self) -> bool: return self._isAudioRecording @isAudioRecording.setter def isAudioRecording(self, value: bool): self._isAudioRecording = value @property def isPhotoSeriesRecording(self) -> bool: return self._isPhotoSeriesRecording @isPhotoSeriesRecording.setter def isPhotoSeriesRecording(self, value: bool): self._isPhotoSeriesRecording = value @property def isTriggerRecording(self) -> bool: return self._isTriggerRecording @isTriggerRecording.setter def isTriggerRecording(self, value: bool): self._isTriggerRecording = value @property def isTriggerWaiting(self) -> bool: return self._isTriggerWaiting @isTriggerWaiting.setter def isTriggerWaiting(self, value: bool): self._isTriggerWaiting = value @property def isTriggerTesting(self) -> bool: return self._isTriggerTesting @isTriggerTesting.setter def isTriggerTesting(self, value: bool): self._isTriggerTesting = value @property def isEventhandling(self) -> bool: return self._isEventhandling @isEventhandling.setter def isEventhandling(self, value: bool): self._isEventhandling = value @property def isEventsWaiting(self) -> bool: return self._isEventsWaiting @isEventsWaiting.setter def isEventsWaiting(self, value: bool): self._isEventsWaiting = value @property def buttonClear(self) -> str: return "Clr(" + str(self.displayBufferCount) + ")" @property def displayPhoto(self): return self._displayPhoto @displayPhoto.setter def displayPhoto(self, value: str): self._displayPhoto = value @property def displayFile(self): return self._displayFile @displayFile.setter def displayFile(self, value: str): self._displayFile = value @property def displayMeta(self): return self._displayMeta @displayMeta.setter def displayMeta(self, value: str): self._displayMeta = value @property def displayMetaFirst(self): return self._displayMetaFirst @displayMetaFirst.setter def displayMetaFirst(self, value: int): self._displayMetaFirst = value @property def displayMetaLast(self): return self._displayMetaLast @displayMetaLast.setter def displayMetaLast(self, value: int): self._displayMetaLast = value @property def displayHistogram(self) -> str: return self._displayHistogram @displayHistogram.setter def displayHistogram(self, value: str): self._displayHistogram = value @property def displayContent(self) -> str: return self._displayContent @displayContent.setter def displayContent(self, value: str): if value == "meta" \ or value == "hist": self._displayContent = value else: self._displayContent = "meta" @property def cv2Available(self) -> bool: return self._cv2Available @cv2Available.setter def cv2Available(self, value: bool): self._cv2Available = value @property def numpyAvailable(self) -> bool: return self._numpyAvailable @numpyAvailable.setter def numpyAvailable(self, value: bool): self._numpyAvailable = value @property def matplotlibAvailable(self) -> bool: return self._matplotlibAvailable @matplotlibAvailable.setter def matplotlibAvailable(self, value: bool): self._matplotlibAvailable = value @property def flaskJwtLibAvailable(self) -> bool: return self._flaskJwtLibAvailable @flaskJwtLibAvailable.setter def flaskJwtLibAvailable(self, value: bool): self._flaskJwtLibAvailable = value @property def imx500Available(self) -> bool: return self._imx500Available @imx500Available.setter def imx500Available(self, value: bool): self._imx500Available = value @property def munkresAvailable(self) -> bool: return self._munkresAvailable @munkresAvailable.setter def munkresAvailable(self, value: bool): self._munkresAvailable = value @property def useUsbCameras(self) -> bool: if self.supportsUsbCamera == False: self._useUsbCameras = False return self._useUsbCameras @useUsbCameras.setter def useUsbCameras(self, value: bool): self._useUsbCameras = value @property def useStereo(self) -> bool: if self.supportsStereo == False: self._useStereo = False return self._useStereo @useStereo.setter def useStereo(self, value: bool): self._useStereo = value @property def useHistograms(self) -> bool: return self._useHistograms @useHistograms.setter def useHistograms(self, value: bool): self._useHistograms = value @property def useCameraAi(self) -> bool: return self._useCameraAi @useCameraAi.setter def useCameraAi(self, value: bool): self._useCameraAi = value @property def supportsExtMotionDetection(self) -> bool: sup = self.cv2Available \ and self.matplotlibAvailable \ and self.numpyAvailable return sup @property def supportsHistograms(self) -> bool: sup = self.cv2Available \ and self.matplotlibAvailable \ and self.numpyAvailable return sup @property def supportsStereo(self) -> bool: sup = self.cv2Available \ and self.numpyAvailable \ and self.activeCameraModel == self.secondCameraModel return sup @property def supportsAPI(self) -> bool: sup = self.flaskJwtLibAvailable == True return sup @property def supportsUsbCamera(self) -> bool: sup = self.cv2Available == True return sup @property def whyNotSupportsHistograms(self) -> str: why = "" if not self.supportsHistograms: why = "Histograms are not supported because" if not self.cv2Available: why = why + "
module cv2 is not available" if not self.matplotlibAvailable: why = why + "
module matplotlib is not available" if not self.numpyAvailable: why = why + "
module numpy is not available" return why @property def whyNotsupportsExtMotionDetection(self) -> str: why = "" if not self.supportsExtMotionDetection: why = "Extended motion detection is not supported because" if not self.cv2Available: why = why + "
module cv2 is not available" if not self.matplotlibAvailable: why = why + "
module matplotlib is not available" if not self.numpyAvailable: why = why + "
module numpy is not available" return why @property def whyNotSupportsStereo(self) -> str: why = "" if not self.supportsStereo: why = "Stereo Vision is not supported because" if not self.cv2Available: why = why + "
module cv2 is not available" if not self.numpyAvailable: why = why + "
module numpy is not available" if self.secondCamera is None: why = why + "
at least two cameras are required" else: if self.activeCameraModel != self.secondCameraModel: why = why + "
active and second camera are of different model" return why @property def whyNotSupportsAPI(self) -> str: why = "" if not self.supportsAPI: why = "The raspiCamSrv API is not supported because" if not self.flaskJwtLibAvailable: why = why + "
module flask_jwt_extended is not available" return why @property def whyNotSupportsUsbCamera(self) -> str: why = "" if not self.supportsUsbCamera: why = "USB Camera support is not available because" if not self.cv2Available: why = why + "
module cv2 is not available" return why @property def whyNotSupportsAiCamera(self) -> str: why = "" if self.aiCamAvailable == False \ or self.imx500Available == False \ or self.cv2Available == False: why = "Camera AI features are not available because" else: if self.munkresAvailable == False: why = "Some Camera AI features are not available because" why = why + "
module munkres is not available" why = why + "
Install in venv with 'pip install --break-system-packages munkres'" if self.aiCamAvailable == False: why = why + "
No AI camera (imx500) is currently connected" if self.imx500Available == False: why = why + "
Package imx500-all is not installed." why = why + "
Install with: 'sudo apt install imx500-all'" if self.cv2Available == False: why = why + "
Module cv2 is not available" return why @property def requireAuthForStreaming(self) -> bool: return self._requireAuthForStreaming @requireAuthForStreaming.setter def requireAuthForStreaming(self, value: bool): self._requireAuthForStreaming = value @property def locLongitude(self) -> float: return self._locLongitude @locLongitude.setter def locLongitude(self, value: float): self._locLongitude = value @property def locLatitude(self) -> float: return self._locLatitude @locLatitude.setter def locLatitude(self, value: float): self._locLatitude = value @property def locElevation(self) -> float: return self._locElevation @locElevation.setter def locElevation(self, value: float): self._locElevation = value @property def locTzKey(self) -> str: return self._locTzKey @locTzKey.setter def locTzKey(self, value: str): self._locTzKey = value def timeZoneKeys(self) -> list: tzl = [] tzs = zoneinfo.available_timezones() for tz in tzs: tzl.append(tz) tzl.sort() return tzl @property def pvCamera(self) -> int: return self._pvCamera @pvCamera.setter def pvCamera(self, value: int): self._pvCamera = value @property def pvFrom(self) -> date: return self._pvFrom @pvFrom.setter def pvFrom(self, value: date): self._pvFrom = value @property def pvFromStr(self) -> str: return self._pvFrom.isoformat()[:10] @pvFromStr.setter def pvFromStr(self, value: str): try: d = date.fromisoformat(value) except ValueError: d = datetime.now() v = datetime(year=d.year, month=d.month, day=d.day, hour=0, minute=0) self._pvFrom = v @property def pvTo(self) -> date: return self._pvTo @pvTo.setter def pvTo(self, value: date): self._pvTo = value @property def pvToStr(self) -> str: return self._pvTo.isoformat()[:10] @pvToStr.setter def pvToStr(self, value: str): try: d = date.fromisoformat(value) except ValueError: d = datetime.now() v = datetime(year=d.year, month=d.month, day=d.day, hour=23, minute=59, second=59) self._pvTo = v @property def pvList(self) -> list: return self._pvList @pvList.setter def pvList(self, value: list): self._pvList = value @property def jwtAuthenticationActive(self) -> bool: return self._jwtAuthenticationActive @jwtAuthenticationActive.setter def jwtAuthenticationActive(self, value: bool): self._jwtAuthenticationActive = value @property def jwtKeyStore(self) -> str: return self._jwtKeyStore @jwtKeyStore.setter def jwtKeyStore(self, value: str): self._jwtKeyStore = value @property def jwtAccessTokenExpirationMin(self) -> int: return self._jwtAccessTokenExpirationMin @jwtAccessTokenExpirationMin.setter def jwtAccessTokenExpirationMin(self, value: int): self._jwtAccessTokenExpirationMin = value @property def jwtRefreshTokenExpirationDays(self) -> int: return self._jwtRefreshTokenExpirationDays @jwtRefreshTokenExpirationDays.setter def jwtRefreshTokenExpirationDays(self, value: int): self._jwtRefreshTokenExpirationDays = value @property def streamingClients(self) -> list: return self._streamingClients @streamingClients.setter def streamingClients(self, value: list): self._streamingClients = value def registerStreamingClient(self, ipaddr: str, stream: str, thread: int): cl = None for scl in self.streamingClients: if scl["ipaddr"] == ipaddr: cl = scl break if cl is None: cl = {} cl["ipaddr"] = ipaddr streams = [] s = {} s["stream"] = stream s["thread"] = thread streams.append(s) cl["streams"] = streams self.streamingClients.append(cl) else: streams = cl["streams"] append = True if len(streams) > 0: for s in streams: if s["thread"] == thread and s["stream"] == stream: append = False break if append == True: s = {} s["stream"] = stream s["thread"] = thread streams.append(s) def unregisterStreamingClient(self, ipaddr: str, stream: str, thread: int): remcl = -1 idxcl = 0 for scl in self.streamingClients: if scl["ipaddr"] == ipaddr: streams = scl["streams"] rems = -1 idxs = 0 for s in streams: if s["thread"] == thread and s["stream"] == stream: rems = idxs idxs += 1 if rems >= 0: streams.pop(rems) if len(streams) == 0: remcl = idxcl idxcl += 1 if remcl >= 0: self.streamingClients.pop(remcl) def streamingClientStreams(self, ipaddr: str) -> str: res = "" for scl in self.streamingClients: if scl["ipaddr"] == ipaddr: streams = scl["streams"] for s in streams: stream = s["stream"] if len(res) == 0: res = stream else: res = res + ", " + stream return res def updateStreamingClients(self): for cl in self.streamingClients: ip = cl["ipaddr"] streams = cl["streams"] for s in streams: thread = s["thread"] is_alive = any([th for th in threading.enumerate() if th.ident == thread]) if is_alive == False: self.unregisterStreamingClient(ip,s["stream"], thread) @property def vButtonsRows(self) -> int: return self._vButtonsRows @vButtonsRows.setter def vButtonsRows(self, value: int): self._vButtonsRows = value @property def vButtonsCols(self) -> int: return self._vButtonsCols @vButtonsCols.setter def vButtonsCols(self, value: int): self._vButtonsCols = value @property def vButtons(self) -> list[list[vButton]]: return self._vButtons @vButtons.setter def vButtons(self, value: list): self._vButtons = value @property def vButtonCommand(self) -> str: return self._vButtonCommand @vButtonCommand.setter def vButtonCommand(self, value: str): self._vButtonCommand = value @property def vButtonArgs(self) -> list: return self._vButtonArgs @vButtonArgs.setter def vButtonArgs(self, value: list): self._vButtonArgs = value @property def vButtonReturncode(self) -> int: return self._vButtonReturncode @vButtonReturncode.setter def vButtonReturncode(self, value: int): self._vButtonReturncode = value @property def vButtonStdout(self) -> str: return self._vButtonStdout @vButtonStdout.setter def vButtonStdout(self, value: str): self._vButtonStdout = value @property def vButtonStderr(self) -> str: return self._vButtonStderr @vButtonStderr.setter def vButtonStderr(self, value: str): self._vButtonStderr = value @property def vButtonHasCommandLine(self) -> bool: return self._vButtonHasCommandLine @vButtonHasCommandLine.setter def vButtonHasCommandLine(self, value: bool): self._vButtonHasCommandLine = value @property def aButtonsRows(self) -> int: return self._aButtonsRows @aButtonsRows.setter def aButtonsRows(self, value: int): self._aButtonsRows = value @property def aButtonsCols(self) -> int: return self._aButtonsCols @aButtonsCols.setter def aButtonsCols(self, value: int): self._aButtonsCols = value @property def aButtons(self) -> list[list[ActionButton]]: return self._aButtons @aButtons.setter def aButtons(self, value: list): self._aButtons = value @property def aButtonAction(self) -> str: return self._aButtonAction @aButtonAction.setter def aButtonAction(self, value: str): self.aButtonAction = value @property def lButtonsRows(self) -> int: return self._lButtonsRows @lButtonsRows.setter def lButtonsRows(self, value: int): self._lButtonsRows = value @property def lButtonsCols(self) -> int: return self._lButtonsCols @lButtonsCols.setter def lButtonsCols(self, value: int): self._lButtonsCols = value @property def lButtons(self) -> list[list[LiveButton]]: return self._lButtons @lButtons.setter def lButtons(self, value: list): self._lButtons = value @property def curDeviceId(self) -> str: return self._curDeviceId @curDeviceId.setter def curDeviceId(self, value: str): self._curDeviceId = value @property def curDevice(self) -> GPIODevice: return self._curDevice @curDevice.setter def curDevice(self, value: GPIODevice): self._curDevice = value @property def curDeviceType(self) -> dict: return self._curDeviceType @curDeviceType.setter def curDeviceType(self, value: dict): self._curDeviceType = value @property def gpioDevices(self) ->list[GPIODevice]: return self._gpioDevices @gpioDevices.setter def gpioDevices(self, value: list[GPIODevice]): self._gpioDevices = value @property def cfgPath(self) -> str: return self._cfgPath @cfgPath.setter def cfgPath(self, value: str): self._cfgPath = value @property def cfgBackupPath(self) -> str: return self._cfgBackupPath @cfgBackupPath.setter def cfgBackupPath(self, value: str): self._cfgBackupPath = value @property def versionCurrent(self) -> str: return currentVersion @property def versionLatest(self) -> str: if self._versionLatest == "": self._versionLatest = currentVersion return self._versionLatest @versionLatest.setter def versionLatest(self, value: str): self._versionLatest = value @property def versionCheckTime(self) -> datetime: return self._versionCheckTime @versionCheckTime.setter def versionCheckTime(self, value: datetime): dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._versionCheckTime = dt @property def versionCheckTimeIso(self) -> str: if self.versionCheckTime is None: return None else: return self.versionCheckTime.isoformat() @property def versionCheckIntervalHours(self) -> int: return self._versionCheckIntervalHours @versionCheckIntervalHours.setter def versionCheckIntervalHours(self, value: int): self._versionCheckIntervalHours = value @property def versionCheckEnabled(self) -> bool: return self._versionCheckEnabled @versionCheckEnabled.setter def versionCheckEnabled(self, value: bool): self._versionCheckEnabled = value @property def versionCheckFrom(self) -> str: if self._versionCheckFrom == "": self._versionCheckFrom = self.versionCurrent if self.isLaterVersion(self.versionCurrent, self._versionCheckFrom): self._versionCheckFrom = self.versionCurrent return self._versionCheckFrom @versionCheckFrom.setter def versionCheckFrom(self, value: str): self._versionCheckFrom = value def getLatestVersion(self, now: bool = False) -> str: """ Get the latest version from GitHub releases Args: now (bool, optional): If True, the version is fetched from GitHub even if the last check was recent. Defaults to False. Returns: str: the latest version string """ version = currentVersion if self.versionCheckEnabled == False: return version version = self.versionLatest url = "https://raw.githubusercontent.com/signag/raspi-cam-srv/main/docs/ReleaseNotes.md" try: if now == False: if self.versionCheckTime is not None: delta = datetime.now() - self.versionCheckTime hours = delta.total_seconds() / 3600 if hours < self.versionCheckIntervalHours: return self.versionLatest else: return self.versionLatest response = requests.get(url) response.raise_for_status() # Raise error if request failed content = response.text lines = content.splitlines() for line in lines: if line.startswith("## V"): version = line[3:].strip() self.versionLatest = version break self.versionCheckTime = datetime.now() except Exception as e: logger.error(f"Error getting latest version from GitHub: {e}") version = self.versionLatest return version @property def canUpdate(self) -> bool: """ Check whether installed version can be updated """ if self.versionCheckEnabled == False: return False verIgnore = self.versionCheckFrom[1:].split(".") for i in range(len(verIgnore)): verIgnore[i] = int(verIgnore[i]) verLatest = self.versionLatest[1:].split(".") for i in range(len(verLatest)): verLatest[i] = int(verLatest[i]) if len(verIgnore) != len(verLatest): return True if len(verIgnore) != 3: return True if verLatest[0] > verIgnore[0]: return True if verLatest[0] == verIgnore[0]: if verLatest[1] > verIgnore[1]: return True if verLatest[1] == verIgnore[1]: if verLatest[2] > verIgnore[2]: return True return False def isLaterVersion(self, v1: str, v2: str) -> bool: """ Check whether version v1 is later than version v2 """ ver1 = v1[1:].split(".") for i in range(len(ver1)): ver1[i] = int(ver1[i]) ver2 = v2[1:].split(".") for i in range(len(ver2)): ver2[i] = int(ver2[i]) if len(ver1) != len(ver2): return False if len(ver1) != 3: return False if ver1[0] > ver2[0]: return True if ver1[0] == ver2[0]: if ver1[1] > ver2[1]: return True if ver1[1] == ver2[1]: if ver1[2] > ver2[2]: return True return False @property def updateDone(self) -> bool: return self._updateDone @updateDone.setter def updateDone(self, value: bool): self._updateDone = value @property def webCamActiveCamPhotoCfg(self) -> str: return self._webCamActiveCamPhotoCfg @webCamActiveCamPhotoCfg.setter def webCamActiveCamPhotoCfg(self, value: str): self._webCamActiveCamPhotoCfg = value @property def webCamSecondCamPhotoCfg(self) -> str: return self._webCamSecondCamPhotoCfg @webCamSecondCamPhotoCfg.setter def webCamSecondCamPhotoCfg(self, value: str): self._webCamSecondCamPhotoCfg = value @property def API_active(self) -> bool: return self._API_active @API_active.setter def API_active(self, value: bool): self._API_active = value @property def useAPI(self) -> bool: return self._useAPI @useAPI.setter def useAPI(self, value: bool): self._useAPI = value @property def processInfo(self) -> str: pi = self._countThreads("raspiCamSrv") # This subprocess runs in an own thread, # So we need to reduce prcNlwp to get the real number of threads threadCount = pi[2] - 1 return f"PID:{pi[0]} Start:{pi[1]} #Threads:{threadCount} CPU Process:{pi[3]} Threads:{pi[4]}" @property def ffmpegProcessInfo(self) -> str: pi = self._countThreads("ffmpeg") if pi[2] == 0: return f"No ffmpeg process active" else: return f"PID:{pi[0]} Start:{pi[1]} #Threads:{pi[2]} CPU Process:{pi[3]} Threads:{pi[4]}" @property def deviceTypes(self) -> list: return gpioDeviceTypes def getDevice(self, id: str) -> GPIODevice: device = None for dev in self.gpioDevices: if dev.id == id: device = dev break return device def getDeviceType(self, id: str) -> dict: deviceType = None for typ in self.deviceTypes: if typ["type"] == id: deviceType = typ break return deviceType @property def freeGpioPins(self) -> list[int]: """ Return a list with the numbers of free GPIO pins Returns: list[int]: the free GPIO pins """ pins = [] for pin in range(0, 28): pins.append(pin) logger.debug("freeGpioPins") for device in self.gpioDevices: typ = device.type deviceParams = device.params devType = self.getDeviceType(typ) for param, value in devType["params"].items(): if "isPin" in value: if value["isPin"] == True: pin = deviceParams[param] if type(pin) is int: if pin in pins: pins.remove(pin) return pins @property def pythonInfo(self) -> str: """Get Python version and location """ info = "" try: import sys version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" ex = sys.executable pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except Exception as e: info = f"Error: {e}" return info @property def flaskInfo(self) -> str: """Get version and location of Flask module """ info = "" try: import flask version = importlib.metadata.version("flask") ex = os.path.dirname(flask.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def libcameraInfo(self) -> str: try: result = subprocess.run( ["dpkg", "-l"], capture_output=True, text=True, check=False ) except Exception: return "Unknown" vers = "" for line in result.stdout.splitlines(): if "libcamera" in line and line.startswith("ii"): parts = line.split() if len(parts) >= 3: pkg_name = parts[1] version = parts[2] # Prefer the main runtime package if pkg_name.startswith("libcamera0"): vers = version if vers == "": # Fallback: return first libcamera-related package version for line in result.stdout.splitlines(): if "libcamera" in line and line.startswith("ii"): parts = line.split() if len(parts) >= 3: vers = parts[2] if vers == "": info = "Unknown" else: info = f"Ver: {vers}" return info @property def picamera2Info(self) -> str: """Get version and location of picamera2 module """ info = "" try: import picamera2 version = importlib.metadata.version("picamera2") ex = os.path.dirname(picamera2.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def openCvInfo(self) -> str: """Get version and location of openCV module """ info = "" try: import cv2 version = cv2.__version__ path = os.path.dirname(cv2.__file__) info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def numpyInfo(self) -> str: """Get version and location of numpy module """ info = "" try: import numpy version = numpy.__version__ ex = os.path.dirname(numpy.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def matplotlibInfo(self) -> str: """Get version and location of matplotlib module """ info = "" try: import matplotlib version = matplotlib.__version__ ex = os.path.dirname(matplotlib.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def flask_jwt_extended(self) -> str: """Get version and location of flask_jwt_extended module """ info = "" try: import flask_jwt_extended version = importlib.metadata.version("flask_jwt_extended") ex = os.path.dirname(flask_jwt_extended.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def imx500Info(self) -> str: """Get version and location of imx500-all package """ info = "" res = self._get_dpkg_info("imx500-all") if res["installed"] == False: info = "Package not installed" else: version = res["version"] files = res["files"] path = "N/A" info = f"Ver: {version} - Loc: {path}" return info @property def munkresInfo(self) -> str: """Get version and location of munkres module """ info = "" try: import munkres version = importlib.metadata.version("munkres") ex = os.path.dirname(munkres.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def gunicornInfo(self) -> str: """Get version and location of gunicorn module """ info = "" try: import gunicorn version = importlib.metadata.version("gunicorn") ex = os.path.dirname(gunicorn.__file__) pex = Path(ex) path = pex.parent info = f"Ver: {version} - Loc: {path}" except ModuleNotFoundError as e: info = "Module not found" except Exception as e: info = f"Error: {e}" return info @property def wsgiInfo(self) -> str: """Get WSGI server """ from flask import request software = request.environ.get("SERVER_SOFTWARE", "").lower() if "gunicorn" in software: threads = os.getenv("GUNICORN_THREADS", "?") return f"gunicorn (worker with {threads} threads)" elif "werkzeug" in software: return "werkzeug (Flask built-in development server)" elif "waitress" in software: return "waitress" elif "uwsgi" in software: return "uwsgi" else: return "unknown" @property def startupInfo(self) -> str: """Return info how server is started """ info = "Unknown" if self.detect_startup_source() == 1: info = "Server started via systemd system service" if self.detect_startup_source() == 2: info = "Server started via systemd user service" if self.detect_startup_source() == 3: info = "Server started via command line" return info @property def environmentInfo(self) -> str: """Return info whether or not server is running in a container """ info = "Unknown" if self.runningInContainer(): info = "Docker Container" else: info = "Host System" return info def _checkModule(self, moduleName: str): logger.debug("_checkModule for module: %s", moduleName) module = None try: module = importlib.import_module(moduleName) except ModuleNotFoundError as e: logger.debug("_checkModule for module: %s - ModuleNotFoundError: %s", moduleName, e) module = None except ImportError as e: logger.debug("_checkModule for module: %s - ImportError: %s", moduleName, e) module = None except Exception as e: logger.debug("_checkModule for module: %s - Exception: %s", moduleName, e) module = None except: logger.debug("_checkModule for module: %s - Other exception", moduleName) module = None return module def checkEnvironment(self): """ Check the availability of specific modules which might be required for specific tasks. - cv2 - numpy - matplotlib - flask_jwt_extended - imx500 (for Sony IMX500/IMX501 camera support) - munkres (for Hungarian Algorithm support used in pose estimation) """ logger.debug("checkEnvironment") self.cv2Available = self._checkModule("cv2") is not None self.numpyAvailable = self._checkModule("numpy") is not None self.matplotlibAvailable = self._checkModule("matplotlib") is not None self.flaskJwtLibAvailable = self._checkModule("flask_jwt_extended") is not None self.imx500Available = self._get_dpkg_info("imx500-all")["installed"] self.munkresAvailable = self._checkModule("munkres") is not None if self.supportsHistograms: self.useHistograms = True else: self.useHistograms = False if self.supportsAPI: self.useAPI = True else: self.useAPI = False logger.debug("cv2Available: %s numpyAvailable: %s matplotlibAvailable: %s flaskJwtLibAvailable: %s", self. cv2Available, self.numpyAvailable, self.matplotlibAvailable, self.flaskJwtLibAvailable) def _get_dpkg_info(self, package: str) -> dict: """ Get information about a Debian package using dpkg-query""" # Check whether the package is installed check = subprocess.run( ["dpkg-query", "-W", "-f=${Status}", package], capture_output=True, text=True ) if check.returncode != 0 or "installed" not in check.stdout: return { "installed": False, "version": None, "files": [] } # Package is installed → get version version = subprocess.run( ["dpkg-query", "-W", "-f=${Version}", package], capture_output=True, text=True ).stdout.strip() # Get installed file list files = subprocess.run( ["dpkg", "-L", package], capture_output=True, text=True ).stdout.splitlines() return { "installed": True, "version": version, "files": files } def is_time_synchronized(self) -> tuple[bool, bool]: """ Check if the system time is synchronized with NTP server """ err = False sync = False if self.runningInContainer(): logger.debug("Running in container - assuming time is synchronized") return (err, True) try: output = subprocess.check_output(["timedatectl"], text=True) for line in output.splitlines(): if "System clock synchronized:" in line: sync = "yes" in line.split(":")[1].strip().lower() return (err, sync) except Exception as e: logger.error(f"Error checking time sync: {e}") err = True return (err, sync) def runningInContainer(self): if os.path.exists("/.dockerenv"): return True try: with open("/proc/1/cgroup", "rt") as f: content = f.read() return "docker" in content or "containerd" in content or "kubepods" in content except FileNotFoundError: return False def wait_for_time_sync(self, timeout:int=60, interval:int=2) -> bool: """ Wait for time synchronization with NTP server Args: timeout (int, optional): Timeout in seconds. Defaults to 60. interval (int, optional): test cycle interval in seconds. Defaults to 2. Returns: bool: True if time is synchronized, False otherwise """ logger.debug("ServerConfig.wait_for_time_sync") for _ in range(int(timeout / interval)): (err, sync) = self.is_time_synchronized() if err == True: break if sync == True: logger.debug("System time is synchronized") self.serverStartTime = datetime.now() return True else: logger.debug("Still waiting for time synchronization...") sleep(interval) logger.debug("Timeout while waiting for system time synchronization") return False @property def displayBufferCount(self) -> int: """ Returns the number of elements in the display buffer """ return len(self._displayBuffer) @property def displayBufferIndex(self) -> str: """ Returns the index of the active element in the form (x/y) """ res = "" if self.isDisplayBufferIn(): for i, (key, value) in enumerate(self._displayBuffer.items()): if key == self.displayFile: res = "(" + str(i + 1) + "/" + str(self.displayBufferCount) + ")" break return res def isDisplayBufferIn(self) -> bool: """Determine whether the current display is in the buffer""" res = False if len(self._displayBuffer) > 0: if self._displayFile in self._displayBuffer: res = True return res def displayBufferAdd(self): """ Adds the current display photo to the buffer if it is not yet included """ if self.isDisplayBufferIn() == False: el = {} el["displayPhoto"] = self._displayPhoto el["displayFile"] = self._displayFile el["displayMeta"] = self._displayMeta el["displayHisto"] = self._displayHistogram el["displayMetaFirst"] = self._displayMetaFirst el["displayMetaLast"] = self._displayMetaLast self._displayBuffer[self._displayFile] = el def displayBufferRemove(self): """ Removes the current display photo from the buffer and set active display to next element """ if self.displayBufferCount > 0: if self.displayBufferCount == 1: # If the buffer contains just one element: clear it self.displayBufferClear() else: # Buffer contains more than one element if self.isDisplayBufferIn(): # Active element is in buffer idel = -1 if self.isDisplayBufferIn() == True: # If active element in buffer: find and delete it for i, (key, value) in enumerate(self._displayBuffer.items()): if key == self.displayFile: idel = i # idel is now the index of the element to activate (show) del self._displayBuffer[key] break if idel >= 0: # If the previouslay active element has been deleted, # activate another element # This will normally the next in buffer ... if idel >= self.displayBufferCount: # ... except when the last element has been deleted. # then activate the previous element idel = idel - 1 for i, (key, value) in enumerate(self._displayBuffer.items()): if i == idel: self.displayFile = key self.displayPhoto = value["displayPhoto"] self.displayMeta = value["displayMeta"] self.displayHistogram = value["displayHisto"] self.displayMetaFirst = value["displayMetaFirst"] self.displayMetaLast = value["displayMetaLast"] break else: # Active element is not in buffer: Just clear active element self.displayFile = None self.displayPhoto = None self.displayMeta = None self.displayHistogram = None self.displayMetaFirst = 0 self.displayMetaLast = 999 else: # Buffer is empty: Just clear active element self.displayFile = None self.displayPhoto = None self.displayMeta = None self.displayHistogram = None self.displayMetaFirst = 0 self.displayMetaLast = 999 def displayBufferClear(self): """ Clears the display buffer as well as the current display """ self._displayBuffer.clear() self.displayFile = None self.displayPhoto = None self.displayMeta = None self.displayHistogram = None self.displayMetaFirst = 0 self.displayMetaLast = 999 def displayBufferCheck(self): """ Clear display info for entries for which files no longer exist """ if not self.displayPhoto is None: done = False while not done: fp = self.photoRoot + "/" + self.displayPhoto if os.path.isfile(fp): done = True else: self.displayBufferRemove() if self.displayPhoto is None: done = True if self.displayBufferCount > 0: keysToRemove = [] for key, value in self._displayBuffer.items(): fp = self.photoRoot + "/" + value["displayPhoto"] if not os.path.isfile(fp): keysToRemove.append(key) for key in keysToRemove: del self._displayBuffer[key] def isDisplayBufferFirst(self) -> bool: """Determine whether the current display is the first element in the buffer""" res = False if self.isDisplayBufferIn(): for i, (key, value) in enumerate(self._displayBuffer.items()): if i == 0: if key == self.displayFile: res = True else: break return res def isDisplayBufferLast(self) -> bool: """Determine whether the current display is the last element in the buffer""" res = False l = len(self._displayBuffer) - 1 if self.isDisplayBufferIn(): for i, (key, value) in enumerate(self._displayBuffer.items()): if i == l: if key == self.displayFile: res = True return res def displayBufferFirst(self): """Change the current display element to the first in buffer""" firstKey = None firstEl = None if self.displayBufferCount > 0: for i, (key, value) in enumerate(self._displayBuffer.items()): if i == 0: firstKey = key firstEl = value break if firstKey: self.displayFile = firstKey self.displayPhoto = firstEl["displayPhoto"] self.displayMeta = firstEl["displayMeta"] self.displayHistogram = firstEl["displayHisto"] self.displayMetaFirst = firstEl["displayMetaFirst"] self.displayMetaLast = firstEl["displayMetaLast"] def displayBufferNext(self): """Change the current display element to the next in buffer""" nextKey = None nextEl = None if self.isDisplayBufferIn(): if not self.isDisplayBufferLast(): found = False for i, (key, value) in enumerate(self._displayBuffer.items()): if key == self.displayFile: found = True else: if found: nextKey = key nextEl = value break else: self.displayBufferFirst() if nextKey: self.displayFile = nextKey self.displayPhoto = nextEl["displayPhoto"] self.displayMeta = nextEl["displayMeta"] self.displayHistogram = nextEl["displayHisto"] self.displayMetaFirst = nextEl["displayMetaFirst"] self.displayMetaLast = nextEl["displayMetaLast"] def displayBufferPrev(self): """Change the current display element to the previous in buffer""" prevKey = None prevEl = None if self.isDisplayBufferIn(): if not self.isDisplayBufferFirst(): for i, (key, value) in enumerate(self._displayBuffer.items()): if key == self.displayFile: break prevKey = key prevEl = value if prevKey: self.displayFile = prevKey self.displayPhoto = prevEl["displayPhoto"] self.displayMeta = prevEl["displayMeta"] self.displayHistogram = prevEl["displayHisto"] self.displayMetaFirst = prevEl["displayMetaFirst"] self.displayMetaLast = prevEl["displayMetaLast"] def _lineGen(self, s): """Generator to yield lines of a text """ while len(s) > 0: p = s.find("\n") if p >= 0: if p == 0: line = "" else: line = s[:p] s = s[p+1:] else: line = s s = "" yield line def _checkMicrophoneNoJson(self): """Check connection of microphone for older PulseAudio versions where pactl has no -fjson option """ logger.debug("ServerConfig._checkMicrophoneNoJson") hasMic = False defMic = "" isMute = False try: result = subprocess.run(["pactl", "list", "sources"], capture_output=True, text=True, check=True).stdout logger.debug("ServerConfig._checkMicrophoneNoJson - got result from 'pactl list sources: \n%s'", result) sourceId = "" desc = "" getPorts = False for line in self._lineGen(result): if line.startswith("Source"): # Start of a new source if sourceId == "": # First source sourceId = line[8:] desc = "" getPorts = False else: # Terminate last source (actually nothing specific) sourceId = line[8:] desc = "" getPorts = False else: if line.startswith("\t"): line = line[1:] if line.startswith("Description:"): desc = line[13:] if getPorts: if line.find("type: Mic") > 0: # We stop if the first microphone has been found # This version of pactl does not allow to get the default mic. hasMic = True defMic = desc break getPorts = False else: if line.startswith("Ports:"): getPorts = True except CalledProcessError as e: # In case pactl cannot be run, ignore the exception # And assume that no microphone is connected pass except Exception as e: pass logger.debug("ServerConfig._checkMicrophoneNoJson - hasMic=%s, defMic=%s'", hasMic, defMic) return hasMic, defMic, isMute def checkMicrophone(self): """Check whether a microphone is connected. Update configuration with description of default configuration. This infomation is obtained by querying the PulseAudio server through pactl """ logger.debug("ServerConfig._checkMicrophone") hasMic = False defMic = "" isMute = True try: result = subprocess.run(["pactl", "-fjson", "list", "sources"], capture_output=True, text=True, check=True).stdout logger.debug("ServerConfig._checkMicrophone - got result from 'pactl -fjson list sources'") sources=json.loads(result) if len(sources) > 0: definput = subprocess.run(["pactl", "get-default-source"], capture_output=True, text=True, check=True).stdout if definput.endswith("\n"): definput = definput[:len(definput) - 1] for source in sources: if "name" in source: srcName = source["name"] if srcName == definput: if "ports" in source: ports = source["ports"] for port in ports: if "type" in port: type = port["type"] if type.casefold() == "mic": hasMic = True break if hasMic == True: if "description" in source: defMic = source["description"] if "mute" in source: isMute = source["mute"] else: isMute = False except CalledProcessError as e: # In case pactl cannot be run successfully, assume an older PulseAudio version # and try without -fjson option hasMic, defMic, isMute = self._checkMicrophoneNoJson() except Exception as e: pass if hasMic == True: self.hasMicrophone = True if len(defMic) > 0: self.defaultMic = defMic else: self._defaultMic = "Unknown description" self.isMicMuted = isMute else: self.hasMicrophone = False self.defaultMic = "No Microphone found" self.recordAudio = False self.isMicMuted = False logger.debug("ServerConfig._checkMicrophone - hasMicrophone=%s, defaultMic=%s", self.hasMicrophone, self.defaultMic) @staticmethod def getPiModel() -> str: """ Get the Raspberry Pi model """ logger.debug("CameraCfg.getPiModel") model = "" try: with open('/proc/device-tree/model') as f: model = f.read() if model.endswith("\x00"): model = model[:len(model)-1] logger.debug("CameraCfg.getPiModel - model: %s", model) except Exception as e: pass return model @staticmethod def getBoardRevision(): """ Get the revision of the Raspberry Pi board """ logger.debug("CameraCfg.getBoardRevision") boardRev = "0000" try: with open('/proc/cpuinfo','r') as f: for line in f: if line[0:8]=='Revision': length=len(line) boardRev = line[11:length-1] except Exception as e: logger.error("Error opening /proc/cpuinfo : %s", e) boardRev = "0000" logger.debug("CameraCfg.getBoardRevision - boardRev = %s", boardRev) return boardRev def getDebianVersion(self): """ Get the Debian Version of the installed OS """ logger.debug("CameraCfg.getDebianVersion") debianVers = "" try: with open('/etc/debian_version','r') as f: for line in f: debianVers += line except Exception as e: logger.error("Error opening /etc/debian_version : %s", e) debianVers = "" debianVers = self.getOsName() + " - Version " + debianVers + " - " + self.getOSArch() logger.debug("CameraCfg.getDebianVersion - debianVers = %s", debianVers) return debianVers def getOSArch(self): """ Get the architecture (32/64) of the installed OS """ logger.debug("CameraCfg.getOSArch") osArch = "" arch = "" try: result = subprocess.run(["dpkg-architecture", "--query", "DEB_HOST_ARCH"], capture_output=True, text=True, check=True).stdout for line in self._lineGen(result): arch += line.strip() if arch == "arm64" \ or arch == "aarch64" \ or arch.find("64") >= 0: osArch = "64-bit" else: osArch = "32-bit" except Exception as e: logger.error("Error executing dpkg-architecture --query DEB_HOST_ARCH : %s", e) osArch = "error" logger.debug("CameraCfg.getOSArch - osArch = %s", osArch) return osArch def getKernelVersion(self): """ Get the Kernel Version of the installed OS """ logger.debug("CameraCfg.getKernelVersion") kernelVers = "" try: result = subprocess.run(["uname", "-r"], capture_output=True, text=True, check=True).stdout for line in self._lineGen(result): kernelVers += line.strip() except Exception as e: logger.error("Error opening /etc/debian_version : %s", e) kernelVers = "" logger.debug("CameraCfg.getKernelVersion - kernelVers = %s", kernelVers) return kernelVers def getOsName(self): """ Get the name of the installed OS """ logger.debug("CameraCfg.getOsName") osName = "" logger.debug("CameraCfg.getOsName - trying lsb_release") try: result = subprocess.run(["lsb_release", "-a"], capture_output=True, text=True, check=True).stdout for line in self._lineGen(result): logger.debug("CameraCfg.getOsName - line:%s", line) if line[0:12] == "Description:": osName = line[13:].strip() break except Exception as e: osName = "" if osName == "": logger.debug("CameraCfg.getOsName - trying cat /etc/os-release") try: result = subprocess.run(["cat", "/etc/os-release"], capture_output=True, text=True, check=True).stdout for line in self._lineGen(result): logger.debug("CameraCfg.getOsName - line:%s", line) if line[0:12] == "PRETTY_NAME=": osName = line[13:].strip() osName = osName.strip('"') break except Exception as e: osName = "" logger.debug("CameraCfg.getOsName - osName = %s", osName) return osName def checkJwtSettings(self) -> tuple: """ Get secret key for JSON Wob Tokens JWT The secret key is expected in the JWT secrets file If a secret key is found, JWT authentication for the API is enabled """ logger.debug("ServerConfig.checkJwtSettings") self.jwtAuthenticationActive = False # Try to get secret key from the file err = None msg = "" jwtSecretKey = None if self.jwtKeyStore != "": logger.debug("ServerConfig.checkJwtSettings - jwtKeyStore = %s", self.jwtKeyStore) if not os.path.exists(self.jwtKeyStore): fp = Path(self.jwtKeyStore) dir = fp.parent.absolute() fn = fp.name if not os.path.exists(dir): os.makedirs(dir, exist_ok=True) logger.debug("ServerConfig.checkJwtSettings - dir created: %s", dir) self.jwtKeyStore = str(dir) + "/" + fn Path(self.jwtKeyStore).touch(exist_ok=True) logger.debug("ServerConfig.checkJwtSettings - file created: %s", self.jwtKeyStore) else: logger.debug("ServerConfig.checkJwtSettings - path exists: %s", self.jwtKeyStore) if os.path.isdir(self.jwtKeyStore): err = "The 'Password File Path' must be a file and not a directory!" secrets = {} if err is None: if os.stat(self.jwtKeyStore).st_size > 0: with open(self.jwtKeyStore, "r") as f: try: secrets = json.load(f) except Exception as e: err = "The file specified as 'JWT Secret Key File Path' has content which is not in JSON format" if err is None: jwtSecretKey = "" if "jwtSecrets" in secrets: jwtSecrets = secrets["jwtSecrets"] if "jwtSecretKey" in jwtSecrets: jwtSecretKey = jwtSecrets["jwtSecretKey"] logger.debug("ServerConfig.checkJwtSettings - JWT secret key read from file") msg = "JWT secret key read from Secret Key Store" else: jwtSecrets = {} if jwtSecretKey == "": jwtSecretKey = token_urlsafe() logger.debug("ServerConfig.checkJwtSettings - jwtSecretKey generated: %s", jwtSecretKey) msg = "New JWT secret key generated" secrets["jwtSecrets"] = jwtSecrets jwtSecrets["jwtSecretKey"] = jwtSecretKey with open(self.jwtKeyStore, "w") as f: try: json.dump(secrets,fp=f, indent=4) logger.debug("ServerConfig.checkJwtSettings - - saved secrets to file %s", self.jwtKeyStore) except Exception as e: logger.err("ServerConfig.checkJwtSettings - - error while saving secrets to file %s: %s", self.jwtKeyStore, e) err = "Error writing to " + self.jwtKeyStore + ": " + str(e) else: logger.debug("ServerConfig.checkJwtSettings - jwtKeyStore not set") msg = "API inactive - No JWT Secret Key Store specified" if jwtSecretKey is None: self.jwtAuthenticationActive = False else: self.jwtAuthenticationActive = True logger.debug("ServerConfig.checkJwtSettings - jwtAuthenticationActive = %s", self.jwtAuthenticationActive) return (jwtSecretKey, err, msg) def detect_startup_source(self) -> int: """Detect the source from which the application was started. Returns: int: Type of the startup source. 1: systemd system unit 2: systemd user unit 3: command line 0: unknown """ logger.debug("ServerConfig.detect_startup_source") ret = 0 # Check parent parent = psutil.Process(os.getpid()).parent().name() # Check cgroup cgroup = Path("/proc/self/cgroup").read_text() # systemd user or system unit if "system.slice" in cgroup: ret = 1 if "user.slice" in cgroup and ".service" in cgroup: ret = 2 # Command line terminal if parent in ("bash", "zsh", "fish") or "session-" in cgroup: ret = 3 logger.debug("ServerConfig.detect_startup_source - ret=%s", ret) return ret @staticmethod def _lineGen(s): """Generator to yield lines of a text """ while len(s) > 0: p = s.find("\n") if p >= 0: if p == 0: line = "" else: line = s[:p] s = s[p+1:] else: line = s s = "" yield line def _countThreads(self, process: str=None): """Count number of threads for a given process """ cntAll = -1 cntReq = 0 prcPid = 0 prcPids = "" prcStime = "" prcNlwp = 0 prcTime = "" thrTime = "" thrTimed = timedelta(0) prcCnt = 1 prcIdx = 0 try: result = subprocess.run(["ps", "-e", "-L", "-f"], capture_output=True, text=True, check=True).stdout for line in self._lineGen(result): cntAll += 1 if cntAll > 0: uid = line[sUID:eUID].strip() pid = int(line[sPID:ePID].strip()) ppid = int(line[sPPID:ePPID].strip()) lwp = int(line[sLWP:eLWP].strip()) c = int(line[sC:eC].strip()) nlwp = int(line[sNLWP:eNLWP].strip()) stime = line[sSTIME:eSTIME].strip() tty = line[sTTY:eTTY].strip() time = line[sTIME:eTIME].strip() cmd = line[sCMD:].strip() if not process is None: if cmd.find(process) >= 0: if cmd.find("gunicorn") >= 0: prcCnt = 2 if pid == lwp: cntReq += 1 prcIdx += 1 if prcCnt > 1: if prcPids == "": prcPids = f"{pid}" else: prcPids += f", {pid}" prcPid = pid prcStime = stime prcNlwp += nlwp t = datetime.strptime(time, "%H:%M:%S") td = timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) if prcTime == "": prcTime = td else: prcTime += td else: if pid == prcPid: cntReq += 1 t = datetime.strptime(time, "%H:%M:%S") td = timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) thrTimed += td if cntReq >= prcNlwp: if prcIdx >= prcCnt: break else: p = 0 p = line.find("UID", p) sUID = p p = line.find("PID", p + 3) ePID = p + 3 sPID = ePID - 6 eUID = sPID p = line.find("PPID", p + 3) ePPID = p + 4 sPPID = ePID p = line.find("LWP", p + 4) eLWP = p + 3 sLWP = ePPID p = line.find("C", p + 3) eC = p + 1 sC = eLWP p = line.find("NLWP", p + 1) eNLWP = p + 4 sNLWP = eC p = line.find("STIME", p + 4) eSTIME = p + 5 sSTIME = eNLWP p = line.find("TTY", p + 5) sTTY = p - 1 p = line.find("TIME", p + 3) eTTY = p - 5 eTIME = p + 4 sTIME = eTTY p = line.find("CMD", p + 4) sCMD = p except CalledProcessError as e: pass except Exception as e: pass if process is None: return (cntAll,) else: thrTime = str(thrTimed) if prcCnt > 1: prcTime = str(prcTime) prcPid = prcPids return (prcPid, prcStime, prcNlwp, prcTime, thrTime) def getBaseHelpUrl(self) -> str: """ Get the base URL for help pages """ logger.debug("ServerConfig.getBaseHelpUrl") baseHelpUrl = "https://signag.github.io/raspi-cam-srv/" + versionDoc.docversion logger.debug("ServerConfig.getBaseHelpUrl - baseHelpUrl = %s", baseHelpUrl) return baseHelpUrl @classmethod def initFromDict(cls, dict:dict): sc = ServerConfig() for key, value in dict.items(): # logger.debug("serverConfig.initFromDict - processing key %s", key) if key == "_scalerCropLiveView": setattr(sc, key, tuple(value)) elif key == "_scalerCropMin": setattr(sc, key, tuple(value)) elif key == "_scalerCropMax": setattr(sc, key, tuple(value)) elif key == "_scalerCropDef": setattr(sc, key, tuple(value)) elif key == "_displayMeta": if value is None: setattr(sc, key, value) else: metat = {} for ckey, cvalue in value.items(): vt = cvalue if ckey == "ScalerCrop": vt = tuple(cvalue) elif ckey == "FrameDurationLimits": vt = tuple(cvalue) elif ckey == "ColourGains": vt = tuple(cvalue) elif ckey == "ColourCorrectionMatrix": vt = tuple(cvalue) elif ckey == "SensorBlackLevels": vt = tuple(cvalue) elif ckey == "AfWindows": afws = () for el in cvalue: afw = (tuple(el),) afws += afw vt = afws else: vt = cvalue metat[ckey] = vt setattr(sc, key, metat) elif key == "_displayBuffer": if value is None: setattr(sc, key, value) else: dbt = {} for bkey, bvalue in value.items(): belt = {} for belmetakey, belmetavalue in bvalue.items(): if belmetakey == "displayMeta": metat = {} for ckey, cvalue in belmetavalue.items(): vt = cvalue if ckey == "ScalerCrop": vt = tuple(cvalue) elif ckey == "FrameDurationLimits": vt = tuple(cvalue) elif ckey == "ColourGains": vt = tuple(cvalue) elif ckey == "ColourCorrectionMatrix": vt = tuple(cvalue) elif ckey == "SensorBlackLevels": vt = tuple(cvalue) elif ckey == "AfWindows": afws = () for el in cvalue: afw = (tuple(el),) afws += afw vt = afws else: vt = cvalue metat[ckey] = vt belt[belmetakey] = metat else: belt[belmetakey] = belmetavalue dbt[bkey] = belt setattr(sc, key, dbt) elif key == "_noCamera": setattr(sc, key, False) elif key == "_pvList": # Photo viewer list shall not be imported # It will be filled on demand setattr(sc, key, []) elif key == "_streamingClients": # Streaming clients shall not be imported # They will be populated during server runtime when clients start/stop streaming setattr(sc, key, []) elif key == "_pvCamera": setattr(sc, key, None) elif key == "_pvFrom": setattr(sc, key, None) elif key == "_pvTo": setattr(sc, key, None) elif key == "_vButtons": if value is None: setattr(sc, key, value) else: vButtons = [] for row in value: vButtonRow = [] for btn in row: button = vButton.initFromDict(btn) vButtonRow.append(button) vButtons.append(vButtonRow) setattr(sc, key, vButtons) # Initialize last vButton execution result elif key == "_vButtonCommand": setattr(sc, key, None) elif key == "_vButtonArgs": setattr(sc, key, None) elif key == "_vButtonReturncode": setattr(sc, key, None) elif key == "_vButtonStdout": setattr(sc, key, None) elif key == "_vButtonStderr": setattr(sc, key, None) elif key == "_aButtons": if value is None: setattr(sc, key, value) else: aButtons = [] for row in value: aButtonRow = [] for btn in row: button = ActionButton.initFromDict(btn) aButtonRow.append(button) aButtons.append(aButtonRow) setattr(sc, key, aButtons) elif key == "_lButtons": if value is None: setattr(sc, key, value) else: lButtons = [] for row in value: lButtonRow = [] for btn in row: button = LiveButton.initFromDict(btn) lButtonRow.append(button) lButtons.append(lButtonRow) setattr(sc, key, lButtons) elif key == "_gpioDevices": if value is None: setattr(sc, key, value) else: gpioDevices = [] for device in value: gpioDevice = GPIODevice.initFromDict(device) gpioDevices.append(gpioDevice) setattr(sc, key, gpioDevices) elif key == "_curDevice": if value is None: setattr(sc, key, value) else: curDevice = GPIODevice.initFromDict(value) setattr(sc, key, curDevice) elif key == "_curDeviceType": # Take the current device type from a fresh declaration rather than from stored data # This will allow later modifications of gpioDeviceTypes being immediately effective if value is None: setattr(sc, key, value) else: type = value["type"] for typ in gpioDeviceTypes: if typ["type"] == type: setattr(sc, key, typ) break elif key == "_unsavedChanges": setattr(sc, key, False) elif key == "_isTriggerTesting": # Never start with trigger testing active setattr(sc, key, False) elif key == "_debianVersion": # Do not overwrite the Debian version from stored configuration # It has been set when the ServerConfig singleton has been instantiated pass elif key == "_kernelVersion": # Do not overwrite the kernel version from stored configuration # It has been set when the ServerConfig singleton has been instantiated pass elif key == "_serverStartTime": # Do not overwrite the server start time # It has been set when the ServerConfig singleton has been instantiated pass elif key == "_versionCurrent": setattr(sc, key, "") elif key == "_versionLatest": setattr(sc, key, "") elif key == "_updateDone": setattr(sc, key, False) else: setattr(sc, key, value) # Reset process status variables sc.isLiveStream = False sc.isLiveStream2 = False sc.isStereoCamActive = False sc.isAudioRecording = False sc.isPhotoSeriesRecording = False sc.isTriggerRecording = False sc.isVideoRecording = False sc.isEventhandling = False sc.isStereoCamActive = False sc.isStereoCamRecording = False sc.changeLog = [] # Set the sc.curDevice attribute to the corresponding object from sc.gpioDevices # After import fom the JSON file sc.curDevice is an own object and not the one # from the sc.gpioDevices list. for device in sc.gpioDevices: if device.id == sc.curDeviceId: sc.curDevice = device break return sc def sliderPosToCtrlVal(self, min:float, max:float, default:float, pos:float) -> float: """Convert slider position (-1 ... 1) to control value (min ... max; default) Function: pos < 0: value = default + (default - min) * pos^3 pos >= 0: value = default + (max - default) * pos^3 -1 -> min 0 -> default 1 -> max """ if pos < 0: val = default + (default - min) * (pos ** 3) else: val = default + (max - default) * (pos ** 3) val = round(val, 3) return val def ctrlValToSliderPos(self, min:float, max:float, default:float, val:float) -> float: """Convert control value (min ... max; default) to slider position (-1 ... 1) Function: val < default: pos = - ((default - val) / (default - min))^(1/3) val >= default: pos = ((val - default) / (max - default))^(1/3) min -> -1 default -> 0 max -> 1 """ logger.debug("CameraCfg.ctrlValToSliderPos - min: %f, max: %f, default: %f, val: %f", min, max, default, val) if default <= min: pos = ((val - default) / (max - default)) ** (1/3) elif default >= max: pos = - ((default - val) / (default - min)) ** (1/3) else: if val < default: pos = - ((default - val) / (default - min)) ** (1/3) else: pos = ((val - default) / (max - default)) ** (1/3) pos = round(pos, 3) logger.debug("CameraCfg.ctrlValToSliderPos - pos: %f", pos) return pos class Secrets(): """ Class for secrets which are never persisted """ def __init__(self) -> None: self._notifyUser = "" self._notifyPwd = "" self._jwtSecretKey = "" @property def notifyUser(self) -> str: return self._notifyUser @notifyUser.setter def notifyUser(self, value: str): self._notifyUser = value @property def notifyPwd(self) -> str: return self._notifyPwd @notifyPwd.setter def notifyPwd(self, value: str): self._notifyPwd = value @property def jwtSecretKey(self) -> str: return self._jwtSecretKey @jwtSecretKey.setter def jwtSecretKey(self, value: str): self._jwtSecretKey = value class CameraCfg(): _instance = None def __new__(cls): if cls._instance is None: cls._instance = super(CameraCfg, cls).__new__(cls) cls._cameras = [] cls._sensorModes = [] cls._rawFormats = [] cls._tuningConfig = TuningConfig() cls._aiConfig = AiConfig() cls._controls = CameraControls() cls._controlsBackup: CameraControls = None cls._cameraProperties = CameraProperties() cls._liveViewConfig = CameraConfig() cls._liveViewConfig.id = "LIVE" cls._liveViewConfig.use_case = "Live view" cls._liveViewConfig.stream = "lores" cls._liveViewConfig.buffer_count = 6 cls._liveViewConfig.encode = "main" cls._liveViewConfig.controls["FrameDurationLimits"] = (33333, 33333) cls._photoConfig = CameraConfig() cls._photoConfig.id = "FOTO" cls._photoConfig.use_case = "Photo" cls._photoConfig.buffer_count = 1 cls._photoConfig.controls["FrameDurationLimits"] = (100, 1000000000) cls._rawConfig = CameraConfig() cls._rawConfig.id = "PRAW" cls._rawConfig.use_case = "Raw Photo" cls._rawConfig.buffer_count = 1 cls._rawConfig.stream = "raw" cls._rawConfig.controls["FrameDurationLimits"] = (100, 1000000000) cls._videoConfig = CameraConfig() cls._videoConfig.buffer_count = 6 cls._videoConfig.id = "VIDO" cls._videoConfig.use_case = "Video" cls._videoConfig.buffer_count = 6 cls._videoConfig.encode = "main" cls._videoConfig.controls["FrameDurationLimits"] = (33333, 33333) cls._cameraConfigs = [] cls._triggerConfig = TriggerConfig() cls._serverConfig = ServerConfig() # For Raspi models < 5 the lowres format must be YUV # See Picamera2 manual ch. 4.2, p. 16 if cls._serverConfig.raspiModelLower5: cls._liveViewConfig.format = "YUV420" if cls._serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") \ or cls._serverConfig.raspiModelFull.startswith("Raspberry Pi 4") \ or cls._serverConfig.raspiModelFull.startswith("Raspberry Pi 3") \ or cls._serverConfig.raspiModelFull.startswith("Raspberry Pi 2") \ or cls._serverConfig.raspiModelFull.startswith("Raspberry Pi 1"): # For Pi Zero and 4 reduce buffer_count defaults for live view and video cls._liveViewConfig.buffer_count = 2 cls._videoConfig.buffer_count = 2 cls._streamingCfg = {} cls._streamingCfgInvalid = False cls._stereoCfg = StereoConfig() cls._secrets = Secrets() return cls._instance @property def cameras(self) -> list: return self._cameras @cameras.setter def cameras(self, value: list): self._cameras = value @property def controls(self) -> CameraControls: return self._controls @controls.setter def controls(self, value: CameraControls): self._controls = value @property def tuningConfig(self) -> TuningConfig: return self._tuningConfig @tuningConfig.setter def tuningConfig(self, value: TuningConfig): self._tuningConfig = value @property def aiConfig(self) -> AiConfig: return self._aiConfig @aiConfig.setter def aiConfig(self, value: AiConfig): self._aiConfig = value @property def controlsBackup(self) -> CameraControls: return self._controlsBackup @controlsBackup.setter def controlsBackup(self, value: CameraControls): self._controlsBackup = value @property def cameraProperties(self) -> CameraProperties: return self._cameraProperties @cameraProperties.setter def cameraProperties(self, value: CameraProperties): self._cameraProperties = value @property def sensorModes(self) -> list: return self._sensorModes @sensorModes.setter def sensorModes(self, value: list): self._sensorModes = value @property def rawFormats(self) -> list: return self._rawFormats @rawFormats.setter def rawFormats(self, value: list): self._rawFormats = value @property def nrSensorModes(self) -> int: return len(self._sensorModes) @property def liveViewConfig(self) -> CameraConfig: return self._liveViewConfig @liveViewConfig.setter def liveViewConfig(self, value: CameraConfig): self._liveViewConfig = value @property def photoConfig(self) -> CameraConfig: return self._photoConfig @photoConfig.setter def photoConfig(self, value: CameraConfig): self._photoConfig = value @property def rawConfig(self) -> CameraConfig: return self._rawConfig @rawConfig.setter def rawConfig(self, value: CameraConfig): self._rawConfig = value @property def videoConfig(self) -> CameraConfig: return self._videoConfig @videoConfig.setter def videoConfig(self, value: CameraConfig): self._videoConfig = value @property def cameraConfigs(self) -> list: return self._cameraConfigs @cameraConfigs.setter def cameraConfigs(self, value: list): self._cameraConfigs = value @property def triggerConfig(self) -> TriggerConfig: return self._triggerConfig @triggerConfig.setter def triggerConfig(self, value: TriggerConfig): self._triggerConfig = value @property def serverConfig(self) -> ServerConfig: return self._serverConfig @serverConfig.setter def serverConfig(self, value: ServerConfig): self._serverConfig = value @property def streamingCfg(self) -> dict: return self._streamingCfg @streamingCfg.setter def streamingCfg(self, value: dict): self._streamingCfg = value @property def streamingCfgInvalid(self) -> dict: return self._streamingCfgInvalid @streamingCfgInvalid.setter def streamingCfgInvalid(self, value: dict): self._streamingCfgInvalid = value @property def stereoCfg(self) -> StereoConfig: return self._stereoCfg @stereoCfg.setter def stereoCfg(self, value: StereoConfig): self._stereoCfg = value @property def secrets(self) -> Secrets: return self._secrets @secrets.setter def secrets(self, value: Secrets): self._secrets = value def setSupportedCameras(self): """ Set up the list of supported cameras """ self.serverConfig.usbCamAvailable = False self.serverConfig.aiCamAvailable = False supCams = [] for cam in self.cameras: if cam.isUsb == False: supCams.append(cam) if cam.model == "imx500": self.serverConfig.aiCamAvailable = True else: if cam.usbDev != "UNKNOWN": self.serverConfig.usbCamAvailable = True if self.serverConfig.useUsbCameras == True: supCams.append(cam) if len(self.cameras) == 0: self.serverConfig.noCamera = True else: if len(supCams) == 0: self.serverConfig.noCamera = True else: self.serverConfig.noCamera = False if self.serverConfig.noCamera == True: self.triggerConfig._noCamera = True else: self.triggerConfig._noCamera = False self.serverConfig.supportedCameras = supCams def setPiCameras(self): """ Set up the list of Raspberry Pi cameras """ piCams = [] for cam in self.cameras: if cam.isUsb == False: piCams.append(cam) self.serverConfig.piCameras = piCams def resetActiveCameraSettings(self): """ Reset configuration and controls for the active camera """ # self._tuningConfig = TuningConfig() # self._aiConfig = AiConfig() self._controls = CameraControls() self._controlsBackup: CameraControls = None self._cameraProperties = CameraProperties() self._liveViewConfig = CameraConfig() self._liveViewConfig.id = "LIVE" self._liveViewConfig.use_case = "Live view" self._liveViewConfig.stream = "lores" self._liveViewConfig.buffer_count = 6 self._liveViewConfig.encode = "main" self._liveViewConfig.controls["FrameDurationLimits"] = (33333, 33333) self._photoConfig = CameraConfig() self._photoConfig.id = "FOTO" self._photoConfig.use_case = "Photo" self._photoConfig.buffer_count = 1 self._photoConfig.controls["FrameDurationLimits"] = (100, 1000000000) self._rawConfig = CameraConfig() self._rawConfig.id = "PRAW" self._rawConfig.use_case = "Raw Photo" self._rawConfig.buffer_count = 1 self._rawConfig.stream = "raw" self._rawConfig.controls["FrameDurationLimits"] = (100, 1000000000) self._videoConfig = CameraConfig() self._videoConfig.buffer_count = 6 self._videoConfig.id = "VIDO" self._videoConfig.use_case = "Video" self._videoConfig.buffer_count = 6 self._videoConfig.encode = "main" self._videoConfig.controls["FrameDurationLimits"] = (33333, 33333) # For Raspi models < 5 the lowres format must be YUV # See Picamera2 manual ch. 4.2, p. 16 if self._serverConfig.raspiModelLower5: self._liveViewConfig.format = "YUV420" if self._serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") \ or self._serverConfig.raspiModelFull.startswith("Raspberry Pi 4") \ or self._serverConfig.raspiModelFull.startswith("Raspberry Pi 3") \ or self._serverConfig.raspiModelFull.startswith("Raspberry Pi 2") \ or self._serverConfig.raspiModelFull.startswith("Raspberry Pi 1"): # For Pi Zero and 4 reduce buffer_count defaults for live view and video self._liveViewConfig.buffer_count = 2 self._videoConfig.buffer_count = 2 def _persistCl(self, cl, fn: str, cfgPath: str): """ Store class dictionary for class cl in the config file fn """ fp = cfgPath + "/" + fn Path(fp).touch() f = open(fp, "w") cj = self._toJson(cl) f.write(str(cj)) f.close() def persist(self, cfgPath: str): """ Store class dictionary in the config file """ if cfgPath: if not os.path.exists(cfgPath): os.makedirs(cfgPath, exist_ok=True) self._persistCl(self.cameras, "cameras.json", cfgPath) self._persistCl(self.tuningConfig, "tuningConfig.json", cfgPath) self._persistCl(self.aiConfig, "aiConfig.json", cfgPath) self._persistCl(self.sensorModes, "sensorModes.json", cfgPath) self._persistCl(self.rawFormats, "rawFormats.json", cfgPath) self._persistCl(self.cameraProperties, "cameraProperties.json", cfgPath) self._persistCl(self.cameraConfigs, "cameraConfigs.json", cfgPath) self._persistCl(self.liveViewConfig, "liveViewConfig.json", cfgPath) self._persistCl(self.photoConfig, "photoConfig.json", cfgPath) self._persistCl(self.rawConfig, "rawConfig.json", cfgPath) self._persistCl(self.videoConfig, "videoConfig.json", cfgPath) self._persistCl(self.controls, "controls.json", cfgPath) self._persistCl(self.serverConfig, "serverConfig.json", cfgPath) self._persistCl(self.triggerConfig, "triggerConfig.json", cfgPath) self._persistCl(self.streamingCfg, "streamingCfg.json", cfgPath) self._persistCl(self.stereoCfg, "stereoCfg.json", cfgPath) def _toJson(self, cl): return json.dumps(cl, default=lambda o: getattr(o, '__dict__', str(o)), indent=4) def _loadConfigCl(self, cl, fn: str, cfgPath: str): """ Load configuration from files, except camera-specific configs """ fp = cfgPath + "/" + fn obj = cl() if os.path.exists(fp): with open(fp) as f: try: cldict = json.load(f) obj = cl.initFromDict(cldict) except Exception as e: logger.error("Error loading from %s: %s", fp, e) obj = cl() return obj def _initStreamingConfigFromDisc(self, fn: str, cfgPath: str) -> dict: """ Load streaming configuration """ sc = {} scdict = {} fp = cfgPath + "/" + fn if os.path.exists(fp): with open(fp) as f: try: scdict = json.load(f) except Exception as e: logger.error("Error loading StreamingConfig from %s: %s", fp, e) scdict = {} if len(scdict) > 0: for camKey, camValue in scdict.items(): scfg = {} for key, value in camValue.items(): if key == "liveconfig": scfg["liveconfig"] = CameraConfig.initFromDict(value) elif key == "photoconfig": scfg["photoconfig"] = CameraConfig.initFromDict(value) elif key == "rawconfig": scfg["rawconfig"] = CameraConfig.initFromDict(value) elif key == "videoconfig": scfg["videoconfig"] = CameraConfig.initFromDict(value) elif key == "controls": scfg["controls"] = CameraControls.initFromDict(value) elif key == "tuningconfig": scfg["tuningconfig"] = TuningConfig.initFromDict(value) elif key == "aiconfig": scfg["aiconfig"] = AiConfig.initFromDict(value) elif key == "cameraproperties": scfg["cameraproperties"] = CameraProperties.initFromDict(value) else: scfg[key] = value sc[camKey] = scfg return sc def _initGpioDevicesFromDisc(self, fn: str, cfgPath: str) -> list: """ Load GPIO devices """ devs = [] fdevs = {} fp = cfgPath + "/" + fn if os.path.exists(fp): with open(fp) as f: try: fdevs = json.load(f) except Exception as e: logger.error("Error loading GPIO devices from %s: %s", fp, e) fdevs = [] if len(fdevs) > 0: for dev in fdevs.items(): devo = GPIODevice.initFromDict(dev) devs.append(devo) return devs def loadConfig(self, cfgPath): """ Load configuration from files, except camera-specific configs """ if cfgPath: if os.path.exists(cfgPath): self.tuningConfig = self._loadConfigCl(TuningConfig, "tuningConfig.json", cfgPath) self.aiConfig = self._loadConfigCl(AiConfig, "aiConfig.json", cfgPath) self.serverConfig = self._loadConfigCl(ServerConfig, "serverConfig.json", cfgPath) self.liveViewConfig = self._loadConfigCl(CameraConfig, "liveViewConfig.json", cfgPath) self.photoConfig = self._loadConfigCl(CameraConfig, "photoConfig.json", cfgPath) self.rawConfig = self._loadConfigCl(CameraConfig, "rawConfig.json", cfgPath) self.videoConfig = self._loadConfigCl(CameraConfig, "videoConfig.json", cfgPath) self.controls = self._loadConfigCl(CameraControls, "controls.json", cfgPath) self.triggerConfig = self._loadConfigCl(TriggerConfig, "triggerConfig.json", cfgPath) self.streamingCfg = self._initStreamingConfigFromDisc("streamingCfg.json", cfgPath) self.stereoCfg = self._loadConfigCl(StereoConfig, "stereoCfg.json", cfgPath) self.gpioDevices = self._initGpioDevicesFromDisc("gpioDevices.json", cfgPath) sc = self.secrets tc = self.triggerConfig (usr, pwd, err) = tc.checkNotificationRecipient() if tc.notifyConOK == True: sc.notifyUser = usr sc.notifyPwd = pwd srv = self.serverConfig if srv.useAPI == True: (secretKey, err, msg) = srv.checkJwtSettings() if err is None: sc.jwtSecretKey = secretKey @staticmethod def _lineGen(s): """Generator to yield lines of a text""" while len(s) > 0: p = s.find("\n") if p >= 0: if p == 0: line = "" else: line = s[:p] s = s[p + 1 :] else: line = s s = "" yield line def setUsbCameraProperties(self) -> bool: """Set properties of the active USB camera from v4l2-ctl output Returns: bool: True if properties have been found, False otherwise """ logger.debug("CameraCfg.setUsbCameraProperties") usbDev = self.serverConfig.activeCameraUsbDev cfgProps = CameraProperties() #cfgProps.hasFocus = False # Assume no focus control for USB cameras cfgProps.hasFlicker = False # Assume no flicker control for USB cameras cfgProps.hasHdr = False # Assume no HDR control for USB cameras cfgProps.unitCellSize = None cfgProps.location = None cfgProps.rotation = None cfgProps.pixelArraySize = None cfgProps.pixelArrayActiveAreas = None cfgProps.colorFilterArrangement = None cfgProps.scalerCropMaximum = None cfgProps.systemDevices = None cfgProps.colorSpace = None found = False try: result = subprocess.run( ["v4l2-ctl", f"--device={usbDev}", "--all"], capture_output=True, text=True, ).stdout for line in self._lineGen(result): # Find model fmtMatch = re.match(r"Model\s+:\s+(.+)", line.strip()) if fmtMatch: found = True cfgProps.model = fmtMatch.group(1) # Find color space fmtMatch = re.match(r"Colorspace\s+:\s+(.+)", line.strip()) if fmtMatch: cfgProps.colorSpace = fmtMatch.group(1) except CalledProcessError as e: logger.error("CameraInfo.setUsbCameraProperties - CalledProcessError: %s", e) # In case v4l2-ctl cannot be run, ignore the exception pass except Exception as e: logger.error("CameraInfo.setUsbCameraProperties - Exception: %s", e) pass if found == False: logger.debug("CameraCfg.setUsbCameraProperties - No USB camera found") return False maxWidth, maxHeight = self.getUsbPixelArraySize() cfgProps.pixelArraySize = (maxWidth, maxHeight) activeAreas = [] activeArea = (0, 0, maxWidth, maxHeight) activeAreas.append(activeArea) cfgProps.pixelArrayActiveAreas = activeAreas cfgProps.scalerCropMaximum = (0, 0, maxWidth, maxHeight) self.cameraProperties = cfgProps logger.debug("CameraCfg.setUsbCameraProperties - USB camera properties found") return True def getUsbPixelArraySize(self) -> tuple: """Get the pixel array size of the active USB camera from v4l2-ctl output Returns: tuple: (maxWidth, maxHeight) """ logger.debug("CameraCfg.getUsbPixelArraySize") maxWidth = 0 maxHeight = 0 usbDev = self.serverConfig.activeCameraUsbDev try: result = subprocess.run( ["v4l2-ctl", f"--device={usbDev}", "--list-formats-ext"], capture_output=True, text=True, ).stdout for line in self._lineGen(result): # Evaluate size block header fmtMatch = re.match(r"Size: Discrete (\d+)x(\d+)", line.strip()) if fmtMatch: width = int(fmtMatch.group(1)) height = int(fmtMatch.group(2)) if width > maxWidth: maxWidth = width if height > maxHeight: maxHeight = height except CalledProcessError as e: logger.error("CameraInfo.getUsbPixelArraySize - CalledProcessError: %s", e) # In case v4l2-ctl cannot be run, ignore the exception pass except Exception as e: logger.error("CameraInfo.getUsbPixelArraySize - Exception: %s", e) pass return (maxWidth, maxHeight) def setUsbSensorModes(self) -> bool: """Set the sensor modes of the active USB camera from v4l2-ctl output Returns: bool: True if sensor modes have been found, False otherwise """ logger.debug("CameraCfg.setUsbSensorModes") cfgSensorModes = [] cfgRawFormats = [] usbDev = self.serverConfig.activeCameraUsbDev found = False try: result = subprocess.run( ["v4l2-ctl", f"--device={usbDev}", "--list-formats-ext"], capture_output=True, text=True, ).stdout for line in self._lineGen(result): # Evaluate format block header fmtMatch = re.match(r"\[(\d+)\]: '(\w+)' \((.+)\)", line.strip()) if fmtMatch: fmtId = int(fmtMatch.group(1)) fmtCode = fmtMatch.group(2) fmtDesc = fmtMatch.group(3) if not fmtId in cfgRawFormats: cfgRawFormats.append(fmtCode) # Evaluate size block header fmtMatch = re.match(r"Size: Discrete (\d+)x(\d+)", line.strip()) if fmtMatch: width = int(fmtMatch.group(1)) height = int(fmtMatch.group(2)) cfgSensorMode = SensorMode() cfgSensorMode.id = str(len(cfgSensorModes)) cfgSensorMode.format = fmtCode if fmtCode == "YUYV": cfgSensorMode.bit_depth = 16 elif fmtCode == "NV12": cfgSensorMode.bit_depth = 12 elif fmtCode == "MJPG": cfgSensorMode.bit_depth = 8 else: cfgSensorMode.bit_depth = None cfgSensorMode.size = (width, height) cfgSensorModes.append(cfgSensorMode) found = True # Evaluate Interval line fmtMatch = re.match( r"Interval: Discrete (\d).(\d+)s \((\d+).(\d+) fps\)", line.strip() ) if fmtMatch: fps = int(fmtMatch.group(3)) if len(cfgSensorModes) > 0: if cfgSensorModes[-1].fps is None: cfgSensorModes[-1].fps = fps except CalledProcessError as e: logger.error("CameraInfo.setUsbSensorModes - CalledProcessError: %s", e) pass except Exception as e: logger.error("CameraInfo.setUsbSensorModes - Exception: %s", e) pass if found == False: logger.debug("CameraCfg.setUsbSensorModes - No sensor modes found") return False self.sensorModes = cfgSensorModes self.rawFormats = cfgRawFormats logger.debug("CameraCfg.setUsbSensorModes - sensor modes found") return True def setUsbCamControls(self): """Set the controls of the active USB camera from v4l2-ctl output """ logger.debug("CameraCfg.setUsbCamControls") try: usbDev = self.serverConfig.activeCameraUsbDev # Run v4l2-ctl result = subprocess.run( ["v4l2-ctl", "-d", usbDev, "--list-ctrls"], capture_output=True, text=True ) lines = result.stdout.strip().split("\n") controls = {} # Regex for the format: # name HEX (type) : min=... max=... step=... default=... regex = re.compile( r"^(?P[\w\-]+)\s+" r"(?P0x[0-9a-fA-F]+)\s+" r"\((?P\w+)\)\s*:\s*" r"(?:min=(?P-?\d+))?\s*" r"(?:max=(?P-?\d+))?\s*" r"(?:step=(?P-?\d+))?\s*" r"(?:default=(?P-?\d+))?", re.IGNORECASE ) for line in lines: line = line.strip() match = regex.search(line) if match: info = match.groupdict() # Convert numeric fields for key in ("min", "max", "step", "default"): if info[key] is not None: info[key] = int(info[key]) name = info["name"] info.pop("name") info.pop("hex") controls[name] = info logger.debug("CameraCfg.setUsbCamControls - %s USB camera controls found", len(controls)) except CalledProcessError as e: logger.error("CameraCfg.setUsbCamControls - CalledProcessError: %s", e) pass except Exception as e: logger.error("CameraCfg.setUsbCamControls - Exception: %s", e) pass # Map USB camera controls to standard controls ctrl = self.controls usbCC = ctrl.usbCamControls if "focus_automatic_continuous" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "focus_automatic_continuous" usbCtrl["type"] = controls["focus_automatic_continuous"]["type"] mapping = {} mapping["0"] = 0 # Manual focus mapping["1"] = 1 # Auto focus mapping["2"] = 1 # Continuous auto focus usbCtrl["mapping"] = mapping usbCC["AfMode"] = usbCtrl ctrl.afMode = 2 # Default to auto focus logger.debug("CameraCfg.setUsbCamControls - Camera has focus control include_afMode = %s", ctrl.include_afMode) self.cameraProperties.hasFocus = True if "focus_absolute" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "focus_absolute" usbCtrl["type"] = controls["focus_absolute"]["type"] usbCtrl["min"] = controls["focus_absolute"]["min"] usbCtrl["max"] = controls["focus_absolute"]["max"] usbCtrl["step"] = controls["focus_absolute"]["step"] usbCtrl["default"] = controls["focus_absolute"]["default"] if usbCtrl["default"] < usbCtrl["min"] or usbCtrl["default"] > usbCtrl["max"]: usbCtrl["default"] = int((usbCtrl["min"] + usbCtrl["max"]) / 2) usbCC["LensPosition"] = usbCtrl if usbCtrl["default"] != 0: ctrl.lensPosition = 1.0 / usbCtrl["default"] else: ctrl.lensPosition = 9999.0 # Set to max if "white_balance_automatic" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "white_balance_automatic" usbCtrl["type"] = controls["white_balance_automatic"]["type"] mapping = {} mapping["0"] = 0 # Manual WB mapping["1"] = 1 # Auto WB usbCtrl["mapping"] = mapping usbCC["AwbEnable"] = usbCtrl ctrl.awbEnable = 1 # Default to auto WB if "white_balance_temperature" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "white_balance_temperature" usbCtrl["type"] = controls["white_balance_temperature"]["type"] mapping = {} mapping["0"] = 2000 # Tungsten mapping["2"] = 3000 # Tungsern mapping["3"] = 4000 # Fluorescent mapping["4"] = 3200 # Indoor mapping["5"] = 5300 # Daylight mapping["6"] = 6200 # Cloudy mapping["7"] = 6500 # Cloudy usbCtrl["mapping"] = mapping usbCC["AwbMode"] = usbCtrl ctrl.awbMode = 5 # Default to daylight if "brightness" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "brightness" usbCtrl["type"] = controls["brightness"]["type"] usbCtrl["min"] = controls["brightness"]["min"] usbCtrl["max"] = controls["brightness"]["max"] usbCtrl["step"] = controls["brightness"]["step"] usbCtrl["default"] = controls["brightness"]["default"] if usbCtrl["default"] < usbCtrl["min"] or usbCtrl["default"] > usbCtrl["max"]: usbCtrl["default"] = int((usbCtrl["min"] + usbCtrl["max"]) / 2) usbCC["Brightness"] = usbCtrl ctrl.brightness = usbCtrl["default"] if "contrast" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "contrast" usbCtrl["type"] = controls["contrast"]["type"] usbCtrl["min"] = controls["contrast"]["min"] usbCtrl["max"] = controls["contrast"]["max"] usbCtrl["step"] = controls["contrast"]["step"] usbCtrl["default"] = controls["contrast"]["default"] if usbCtrl["default"] < usbCtrl["min"] or usbCtrl["default"] > usbCtrl["max"]: usbCtrl["default"] = int((usbCtrl["min"] + usbCtrl["max"]) / 2) usbCC["Contrast"] = usbCtrl ctrl.contrast = usbCtrl["default"] if "saturation" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "saturation" usbCtrl["type"] = controls["saturation"]["type"] usbCtrl["min"] = controls["saturation"]["min"] usbCtrl["max"] = controls["saturation"]["max"] usbCtrl["step"] = controls["saturation"]["step"] usbCtrl["default"] = controls["saturation"]["default"] if usbCtrl["default"] < usbCtrl["min"] or usbCtrl["default"] > usbCtrl["max"]: usbCtrl["default"] = int((usbCtrl["min"] + usbCtrl["max"]) / 2) usbCC["Saturation"] = usbCtrl ctrl.saturation = usbCtrl["default"] if "sharpness" in controls: usbCtrl = {} usbCtrl["ctrlName"] = "sharpness" usbCtrl["type"] = controls["sharpness"]["type"] usbCtrl["min"] = controls["sharpness"]["min"] usbCtrl["max"] = controls["sharpness"]["max"] usbCtrl["step"] = controls["sharpness"]["step"] usbCtrl["default"] = controls["sharpness"]["default"] if usbCtrl["default"] < usbCtrl["min"] or usbCtrl["default"] > usbCtrl["max"]: usbCtrl["default"] = int((usbCtrl["min"] + usbCtrl["max"]) / 2) usbCC["Sharpness"] = usbCtrl ctrl.sharpness = usbCtrl["default"] ================================================ FILE: raspiCamSrv/camera_pi.py ================================================ import io import time import datetime import threading from _thread import get_ident, allocate_lock from raspiCamSrv.camCfg import ( CameraInfo, CameraCfg, SensorMode, CameraConfig, TuningConfig, AiConfig, ) from typing import List from raspiCamSrv.photoseriesCfg import Series from picamera2 import Picamera2, CameraConfiguration, StreamConfiguration, Controls from picamera2 import CompletedRequest, MappedArray from libcamera import Transform, Size, ColorSpace, controls from libcamera import Rectangle from picamera2.encoders import JpegEncoder, MJPEGEncoder from picamera2.outputs import FileOutput, FfmpegOutput, CircularOutput from picamera2.encoders import H264Encoder from threading import Condition, Lock import copy import os from pathlib import Path import logging import gc import math import subprocess from subprocess import CalledProcessError from functools import lru_cache from typing import Dict # Try to import SensorConfiguration, which is missing in Bullseye Picamera2 distributions try: from picamera2.configuration import SensorConfiguration useSensorConfiguration = True except ImportError: useSensorConfiguration = False # Try to import cv2 try: import cv2 cv2Available = True except ImportError: cv2Available = False # Try to import numpy try: import numpy as np numpyAvailable = True except ImportError: numpyAvailable = False # Try to import imx500 modules try: from picamera2.devices.imx500.postprocess import softmax from picamera2.devices.imx500.postprocess import COCODrawer from picamera2.devices.imx500.postprocess_highernet import \ postprocess_higherhrnet from picamera2.devices.imx500 import (NetworkIntrinsics, postprocess_nanodet_detection) imx500Available = True except ImportError: imx500Available = False logger = logging.getLogger(__name__) prgLogger = logging.getLogger("pc2_prg") class CameraStopError(RuntimeError): pass class UsbCameraOpenError(RuntimeError): """Exception raised when a USB camera is unexpectedly not open The reason why the USB camera is found to be not open after it had been opened are currently not yet clear. """ # TODO: Clarify under which conditions this exception is raised pass class UsbCameraNoFrameReceivedError(RuntimeError): """Exception raised when a USB camera does not deliver frames for 1 second after being opened The reason is currently not yet clear. """ # TODO: Clarify under which conditions this exception is raised pass class Classification: def __init__(self, idx: int, score: float): """Create a Classification object, recording the idx and score.""" self.idx = idx self.score = score class Detection: def __init__(self, coords, category, conf, metadata): """Create a Detection object, recording the bounding box, category and confidence.""" # logger.debug("Thread %s: Detection.__init__ - coords: %s category: %s conf: %s", get_ident(), coords, category, conf) self.category = category self.conf = conf coords = (coords[0][0], coords[1][0], coords[2][0], coords[3][0]) config = Camera.cam.camera_configuration() # logger.debug("Thread %s: Detection.__init__ - camera_configuration: %s", get_ident(), config) if "lores" in config and config["lores"] is not None: # logger.debug("Thread %s: Detection.__init__ - converting coords for lores stream", get_ident()) self.box = Camera.cam_imx500.convert_inference_coords(coords, metadata, Camera.cam, stream="lores") else: self.box = None if "main" in config and config["main"] is not None: # logger.debug("Thread %s: Detection.__init__ - converting coords for lores stream", get_ident()) self.box_main = Camera.cam_imx500.convert_inference_coords(coords, metadata, Camera.cam, stream="main") else: self.box_main = None # logger.debug("Thread %s: Detection.__init__ - box: %s box_main: %s", get_ident(), self.box, self.box_main) class Cam2Detection: def __init__(self, coords, category, conf, metadata): """Create a Detection object, recording the bounding box, category and confidence.""" self.category = category self.conf = conf coords = (coords[0][0], coords[1][0], coords[2][0], coords[3][0]) config = Camera.cam2.camera_configuration() if "lores" in config and config["lores"] is not None: self.box = Camera.cam2_imx500.convert_inference_coords(coords, metadata, Camera.cam2, stream="lores") else: self.box = None if "main" in config and config["main"] is not None: self.box_main = Camera.cam2_imx500.convert_inference_coords(coords, metadata, Camera.cam2, stream="main") else: self.box_main = None class StreamingOutput(io.BufferedIOBase): def __init__(self): # logger.debug("Thread %s: StreamingOutput.__init__", get_ident()) self.frame = None self.lock = Lock() self.condition = Condition(self.lock) def write(self, buf): # logger.debug("Thread %s: StreamingOutput.write", get_ident()) with self.condition: self.frame = buf # logger.debug("Thread %s: StreamingOutput.write - got buffer of length %s", get_ident(), len(buf)) self.condition.notify_all() # logger.debug("Thread %s: StreamingOutput.write - notification done", get_ident()) # logger.debug("Thread %s: StreamingOutput.write - write done", get_ident()) class CameraController: """The class controls status change actions for the camera""" def __init__(self, isUsb: bool = False, usbDev: str = None, forActiveCamera: bool =True): logger.debug( "Thread %s: CameraController.__init__ - isUsb: %s, usbDev: %s, forActiveCamera: %s", get_ident(), isUsb, usbDev, forActiveCamera ) if not useSensorConfiguration: logger.info( "Could not import SensorConfiguration from picamera2.configuration. Bypassing sensor configuration" ) self._activeCfg: CameraConfiguration = None self._requestedCfg: CameraConfiguration = CameraConfiguration() self._activeEncoders = {} self._isUsb = isUsb self._usbDev = usbDev self._forActiveCamera = forActiveCamera logger.debug( "Thread %s: CameraController.__init__ - requestedCfg: %s", get_ident(), self._requestedCfg, ) @property def configuration(self) -> CameraConfiguration: return self._requestedCfg @property def isUsb(self) -> bool: return self._isUsb @property def usbDev(self) -> str: return self._usbDev def requestCameraForConfig( self, cam: Picamera2, camNum, cfg: CameraConfig, cfgPhoto: CameraConfig = None, forLiveStream: bool = False, forActiveCamera=True, forceExclusive: bool = False, ): """Request camera start for a specific configuration Parameters: cam Camera camNum Camera number isUsb Whether the camera is a USB camera cfg Configuration for which camera is requested If None, request start for the active configuration cfgPhoto Photo configuration. To be provided when cfg is a raw photo configuration forLiveStream: The request is for the Live Stream -> don't deactivate Live Stream forActiveCamera: Whether the request is for the active camera forceExclusive: Whether the request is for an exclusive camera start Return: True if start is exclusive for the requested configuration False if the active configuration is used imx500 IMX500 device if used, else None """ if cfg: logger.debug( "Thread %s: CameraController.requestCameraForConfig cfg: %s", get_ident(), cfg.__dict__, ) else: logger.debug( "Thread %s: CameraController.requestCameraForConfig cfg: %s", get_ident(), cfg, ) if cfgPhoto: logger.debug( "Thread %s: CameraController.requestCameraForConfig - cfgPhoto: %s", get_ident(), cfgPhoto.__dict__, ) else: logger.debug( "Thread %s: CameraController.requestCameraForConfig - cfgPhoto: %s", get_ident(), cfgPhoto, ) logger.debug( "Thread %s: CameraController.requestCameraForConfig - forLiveStream: %s", get_ident(), forLiveStream, ) logger.debug( "Thread %s: CameraController.requestCameraForConfig - forActiveCamera: %s", get_ident(), forActiveCamera, ) logger.debug( "Thread %s: CameraController.requestCameraForConfig - forceExclusive: %s", get_ident(), forceExclusive, ) exclusive = False imx500 = None if cfg: self.requestConfig(cfg, cfgPhoto=cfgPhoto) if forceExclusive == False: cam, started, imx500 = self.requestStart( cam, camNum, self.isUsb, self.usbDev, forActiveCamera ) else: started = False if started: logger.debug( "Thread %s: CameraController.requestCameraForConfig - camera started", get_ident(), ) else: logger.debug( "Thread %s: CameraController.requestCameraForConfig: Camara stop required", get_ident(), ) if not forLiveStream: if forActiveCamera == True: Camera.liveViewDeactivated = True else: Camera.liveView2Deactivated = True logger.debug( "Thread %s: CameraController.requestCameraForConfig - Live stream deactivated", get_ident(), ) if forActiveCamera == True: Camera.stopLiveStream() else: Camera.stopLiveStream2() logger.debug( "Thread %s: CameraController.requestCameraForConfig: Live stream stopped", get_ident(), ) cam, stopped = self.requestStop(cam) if stopped: if forActiveCamera == True: cam, started, imx500 = Camera.ctrl.requestStart( cam, camNum, self.isUsb, self.usbDev, forActiveCamera ) else: cam, started, imx500 = Camera.ctrl2.requestStart( cam, camNum, self.isUsb, self.usbDev, forActiveCamera ) if started: logger.debug( "Thread %s: CameraController.requestCameraForConfig - camera started", get_ident(), ) else: logger.error( "Thread %s: CameraController.requestCameraForConfig - camera could not be started", get_ident(), ) raise RuntimeError( "CameraController.requestCameraForConfig - Camera could not be started" ) else: logger.error( "Thread %s: CameraController.requestCameraForConfig - camera did not stop", get_ident(), ) raise RuntimeError( "CameraController.requestCameraForConfig - Camera did not stop" ) exclusive = True return cam, exclusive, imx500 def restoreLivestream(self, cam, exclusive: bool): """Restart the live stream after exclusive camera use by other task""" logger.debug( "Thread %s: CameraController.restoreLivestream - exclusive: %s", get_ident(), exclusive, ) if exclusive: logger.debug( "Thread %s: CameraController.restoreLivestream - Need to stop camera and restart live stream", get_ident(), ) cam, stopped = self.requestStop(cam) if not stopped: logger.error( "Thread %s: CameraController.restoreLivestream - camera did not stop", get_ident(), ) raise RuntimeError( "CameraController.restoreLivestream - Camera did not stop" ) Camera.liveViewDeactivated = False logger.debug( "Thread %s: CameraController.restoreLivestream - Live stream activated", get_ident(), ) Camera.startLiveStream() logger.debug( "Thread %s: CameraController.restoreLivestream: Live stream started", get_ident(), ) else: logger.debug( "Thread %s: CameraController.restoreLivestream - Restart live stream not required", get_ident(), ) return cam def restoreLivestream2(self, cam, exclusive: bool): """Restart the live stream 2 after exclusive camera use by other task""" logger.debug( "Thread %s: CameraController.restoreLivestream2 - exclusive: %s", get_ident(), exclusive, ) if exclusive: logger.debug( "Thread %s: CameraController.restoreLivestream2 - Need to stop camera and restart live stream", get_ident(), ) cam, stopped = self.requestStop(cam) if not stopped: logger.error( "Thread %s: CameraController.restoreLivestream2 - camera did not stop", get_ident(), ) raise RuntimeError( "CameraController.restoreLivestream2 - Camera did not stop" ) Camera.liveView2Deactivated = False logger.debug( "Thread %s: CameraController.restoreLivestream2 - Live stream activated", get_ident(), ) Camera.startLiveStream2() logger.debug( "Thread %s: CameraController.restoreLivestream2: Live stream started", get_ident(), ) else: logger.debug( "Thread %s: CameraController.restoreLivestream2 - Restart live stream not required", get_ident(), ) return cam def requestStart( self, cam, camNum, isUsb=False, camUsbDev=None, forActiveCamera=True ): """Request to start the camera If the camera is not yet started, it is configured and started forActiveCamera: Whether the request is for the active camera Return: - True if the camera was started or if the camera had been started before with the same configuration - False if the camera was already started or if an exception occurs during start """ logger.debug( "Thread %s: CameraController.requestStart - camNum: %s isUsb: %s camUsbDev: %s", get_ident(), camNum, isUsb, camUsbDev, ) res = False imx500 = None if isUsb == False: logger.debug( "Thread %s: CameraController.requestStart - cam.started: %s", get_ident(), cam.started, ) if cam.started == False: try: logger.debug( "Thread %s: CameraController.requestStart - cam.is_open: %s", get_ident(), cam.is_open, ) if cam.is_open == False: cfg = CameraCfg() if forActiveCamera == True: tc = cfg.tuningConfig ai = cfg.aiConfig else: strc = cfg.streamingCfg camNumStr = str(camNum) if camNumStr in strc: scfg = strc[camNumStr] if "tuningconfig" in scfg: tc = scfg["tuningconfig"] else: tc = TuningConfig() if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() else: tc = TuningConfig() ai = AiConfig() if tc.loadTuningFile == False: cam = Picamera2(camNum) prgLogger.debug("picam2 = Picamera2(%s)", camNum) else: tuning = Picamera2.load_tuning_file( tc.tuningFile, tc.tuningFolder ) logger.debug( "Thread %s: CameraController.requestStart - Tuning file loaded: File=%s Folder=%s", get_ident(), tc.tuningFile, tc.tuningFolder, ) cam = Picamera2(camNum, tuning=tuning) logger.debug( "Thread %s: CameraController.requestStart - Initialized camera %s with tuning", get_ident(), camNum, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug( "picam2 = Picamera2(%s, tuning=tuning)", camNum ) # Set model for AI Camera if ai.enable: # Try to import IMX500 try: from picamera2.devices import IMX500 logger.debug( "Thread %s: CameraController.requestStart - import IMX500 successful", get_ident(), ) except ImportError: logger.error( "CameraController.requestStart - Could not import IMX500 from picamera2.devices", ) ai.enable = False if ai.enable: modelPath = os.path.join(ai.modelFolder, ai.modelFile) imx500 = IMX500(modelPath) logger.debug( "Thread %s: CameraController.requestStart - IMX500 instantiated with model: %s", get_ident(), modelPath, ) else: logger.debug( "Thread %s: CameraController.requestStart - Camera is already open", get_ident(), ) imx500 = Camera.cam_imx500 self._activeCfg = self.copyConfig(self._requestedCfg) logger.debug( "Thread %s: CameraController.requestStart - activeCfg b: %s", get_ident(), self._activeCfg, ) wrkCfg = self.copyConfig(self._activeCfg) cam.configure(wrkCfg) logger.debug( "Thread %s: CameraController.requestStart - activeCfg a: %s", get_ident(), self._activeCfg, ) if self.isUsb == False: if prgLogger.level == logging.DEBUG: self.codeGenConfig(self._activeCfg) prgLogger.debug("picam2.configure(ccfg)") logger.debug( "Thread %s: CameraController.requestStart - Camera configured", get_ident(), ) cam.start(show_preview=False) prgLogger.debug("picam2.start(show_preview=False)") logger.debug( "Thread %s: CameraController.requestStart - Camera started", get_ident(), ) res = True # let camera warm up time.sleep(1.5) prgLogger.debug("time.sleep(1.5)") except Exception as e: logger.error( "Thread %s: CameraController.requestStart - Error starting camera: %s", get_ident(), e, ) cfg = CameraCfg() sc = cfg.serverConfig if not sc.error: sc.error = "Error while starting camera: " + str(e) sc.errorSource = "CameraController.requestStart" else: isIdentical, dif = self.compareConfig( self._requestedCfg, self._activeCfg ) if isIdentical: logger.debug( "Thread %s: CameraController.requestStart - Camera was already started with same configuration.", get_ident(), ) res = True else: logger.debug( "Thread %s: CameraController.requestStart - Camera was already started, but with different configuration. Difference is: %s", get_ident(), dif, ) imx500 = Camera.cam_imx500 else: logger.debug( "Thread %s: CameraController.requestStart - cam.isOpened: %s", get_ident(), cam.isOpened(), ) # For USB cameras, just open the camera if not already opened if cam.isOpened() == False: cam = cv2.VideoCapture(camUsbDev, cv2.CAP_V4L2) if (not cam) or (cam.isOpened() == False): logger.error( "Thread %s: CameraController.requestStart - Error: USB camera not opened", get_ident(), ) cfg = CameraCfg() sc = cfg.serverConfig sc.error = "Error while initializing camera: USB camera not opened" sc.errorSource = "CV2" else: res = True logger.debug( "Thread %s: CameraController.requestStart - USB camera started", get_ident(), ) else: logger.debug( "Thread %s: CameraController.requestStart - USB Camera was already opened.", get_ident(), ) res = True if cam.isOpened() == True: # Apply configuration self._activeCfg = self.copyConfig(self._requestedCfg) wrkCfg = self.copyConfig(self._activeCfg) fmt = wrkCfg.main.format width = wrkCfg.main.size[0] height = wrkCfg.main.size[1] cam.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*fmt)) cam.set(cv2.CAP_PROP_FRAME_WIDTH, width) cam.set(cv2.CAP_PROP_FRAME_HEIGHT, height) logger.debug( "Thread %s: CameraController.requestStart - USB Cam started with format: %s, size: %s x %s", get_ident(), fmt, width, height, ) logger.debug("Thread %s: CameraController.requestStart: %s", get_ident(), res) return cam, res, imx500 def requestStop(self, cam, close=False): """Request to stop the camera If the camera is started, - stop the active encoders, if any - stop the camera - if close: close the camera Return: - True if the camera was stopped / closed or if the camera was not started - False if the camera could not be stopped """ logger.debug("Thread %s: CameraController.requestStop", get_ident()) res = False if self.isUsb == False: try: if cam.started == True: # First stop encoders logger.debug( "Thread %s: CameraController.requestStop - Stopping %s encoders", get_ident(), len(self._activeEncoders), ) while len(self._activeEncoders) > 0: task, encoder = self._activeEncoders.popitem() cam.stop_encoder(encoder) encoder = None prgLogger.debug("picam2.stop_encoder(encoder)") logger.debug( "Thread %s: CameraController.requestStop - Stopped Encoder for %s", get_ident(), task, ) # Then stop the camera logger.debug( "Thread %s: CameraController.requestStop - Stopping camera", get_ident(), ) cam.stop() prgLogger.debug("picam2.stop()") cnt = 0 while cam.started == True: time.sleep(0.01) cnt += 1 if cnt > 200: logger.error( "Thread %s: CameraController.requestStop - Camera did not stop", get_ident(), ) raise TimeoutError( "CameraController.requestStop: Camera did not stop within 2 sec" ) if cnt < 200: logger.debug( "Thread %s: CameraController.requestStop - Camera stopped", get_ident(), ) res = True else: res = True except TimeoutError: raise except Exception as e: logger.error( "Thread %s: CameraController.requestStop - error: %s", get_ident(), e, ) raise if close == True: try: if cam.is_open == True: logger.debug( "Thread %s: CameraController.requestStop - About to close camera", get_ident(), ) prgLogger.debug("picam2.close()") cam.close() logger.debug( "Thread %s: CameraController.requestStop - Camera closed", get_ident(), ) except Exception as e: logger.debug( "Thread %s: CameraController.requestStop - Ignoring error while closing camera: %s", get_ident(), e, ) gc.collect() prgLogger.debug("gc.collect()") logger.debug( "Thread %s: CameraController.requestStop - Garbage collection completed", get_ident(), ) else: # For USB cameras, just close the camera try: if cam.isOpened() == True: logger.debug( "Thread %s: CameraController.requestStop - About to close USB camera", get_ident(), ) cam.release() logger.debug( "Thread %s: CameraController.requestStop - USB Camera closed", get_ident(), ) else: logger.debug( "Thread %s: CameraController.requestStop - USB Camera was not opened", get_ident(), ) res = True except Exception as e: logger.debug( "Thread %s: CameraController.requestStop - Ignoring error while closing USB camera: %s", get_ident(), e, ) gc.collect() logger.debug( "Thread %s: CameraController.requestStop - Garbage collection completed", get_ident(), ) logger.debug("Thread %s: CameraController.requestStop: %s", get_ident(), res) if self._forActiveCamera == True: Camera.camWaitingForFirstFrame = True Camera.camProgressCounter = 0 else: Camera.cam2WaitingForFirstFrame = True Camera.cam2ProgressCounter = 0 return cam, res def requestConfig( self, cfg: CameraConfig, test: bool = False, cfgPhoto: CameraConfig = None ): """Register a new configuration Parameters: cfg: configuration to register test: Run in test mode without modifying self._requestedCfg cfgPhoto Configuration for Photo. Required only if cfg is a Raw Photo configuration. In this case, cfgPhoto is used to configure the main stream for placeholder jpg photos Return: configChange: True/False if the requested configuration caused a change in configuration configChangeReason: Reason for configuration change: list of discrepancies If there are no configuration conflicts, the requested configuration is merged into the active configuration. Otherwise, the active configuration is replaced by the requested configuration and configChange is set to True and configChangeReason is filled with detected conflicts """ logger.debug( "Thread %s: CameraController.requestConfig - test: %s cfg : %s", get_ident(), test, cfg.__dict__, ) if cfgPhoto: logger.debug( "Thread %s: CameraController.requestConfig - test: %s cfgPhoto: %s", get_ident(), test, cfgPhoto.__dict__, ) else: logger.debug( "Thread %s: CameraController.requestConfig - test: %s cfgPhoto: %s", get_ident(), test, cfgPhoto, ) cfgRef = self._requestedCfg configChange = False configChangeReason = "" if not test: if cfgRef.use_case: if cfgRef.use_case.find(cfg.use_case) < 0: cfgRef.use_case += "," + cfg.use_case else: cfgRef.use_case = cfg.use_case # Transform of new config must be identical to existing if cfgRef.transform: if ( cfgRef.transform.hflip != cfg.transform_hflip or cfgRef.transform.vflip != cfg.transform_vflip ): configChange = True configChangeReason += "transform," else: if not test: cfgRef.transform = Transform( vflip=cfg.transform_vflip, hflip=cfg.transform_hflip ) # For buffer_count, always choose the larger one if not test: if cfgRef.buffer_count: if cfg.buffer_count > cfgRef.buffer_count: cfgRef.buffer_count = cfg.buffer_count else: cfgRef.buffer_count = cfg.buffer_count if self.isUsb == False: # Colour space must be identical cosp = cfg.colour_space if cosp == "sYCC": colourSpace = ColorSpace.Sycc() elif cosp == "Smpte170m": colourSpace = ColorSpace.Smpte170m() elif cosp == "Rec709": colourSpace = ColorSpace.Rec709() else: colourSpace = ColorSpace.Sycc() if cfgRef.colour_space: if cfgRef.colour_space != colourSpace: configChange = True configChangeReason += "colourSpace," else: if not test: cfgRef.colour_space = colourSpace # queue must be identical if cfgRef.queue: if cfgRef.queue != cfg.queue: configChange = True configChangeReason += "queue," else: if not test: cfgRef.queue = cfg.queue # display must be identical if cfgRef.display: if cfgRef.display != cfg.display: configChange = True configChangeReason += "display," else: if not test: cfgRef.display = cfg.display # encode is not used. Always set it to 'main' if not test: cfgRef.encode = "main" # Sensor is not explicitely set in the configuration # It will be selected and updated by picamera2 automaticallx if useSensorConfiguration: if not cfgRef.sensor: if not test: sensor = SensorConfiguration() sensor.output_size = None sensor.bit_depth = None cfgRef.sensor = sensor #'main' stream must be identical if cfg.stream == "main": if cfgRef.main: if cfgRef.main.size != cfg.stream_size: configChange = True configChangeReason += "main.size," if cfgRef.main.format != cfg.format: configChange = True configChangeReason += "main.format," else: if not test: mstream = StreamConfiguration() mstream.size = cfg.stream_size mstream.format = cfg.format mstream.stride = None mstream.framesize = None cfgRef.main = mstream #'lores' stream must be identical if cfg.stream == "lores": if cfgRef.lores: if cfgRef.lores.size != cfg.stream_size: configChange = True configChangeReason += "lores.size," if cfgRef.lores.format != cfg.format: configChange = True configChangeReason += "lores.format," else: if not test: lstream = StreamConfiguration() lstream.size = cfg.stream_size lstream.format = cfg.format lstream.stride = None lstream.framesize = None cfgRef.lores = lstream #'raw' stream must be identical if cfg.stream == "raw": if cfgRef.raw: if cfgRef.raw.size: if cfgRef.raw.size != cfg.stream_size: configChange = True configChangeReason += "raw.size," else: if not test: cfgRef.raw.size = cfg.stream_size if cfgRef.raw.format: if cfgRef.raw.format != cfg.format: configChange = True configChangeReason += "raw.format," else: if not test: cfgRef.raw.format = cfg.format else: if not test: rstream = StreamConfiguration() rstream.size = cfg.stream_size rstream.format = cfg.format rstream.stride = None rstream.framesize = None cfgRef.raw = rstream if cfgPhoto: if cfgRef.main: if cfgRef.main.size != cfgPhoto.stream_size: configChange = True configChangeReason += "main.size," if cfgRef.main.format != cfgPhoto.format: configChange = True configChangeReason += "main.format," else: if not test: mstream = StreamConfiguration() mstream.size = cfgPhoto.stream_size mstream.format = cfgPhoto.format mstream.stride = None mstream.framesize = None cfgRef.main = mstream if not test: if cfgRef.controls: for key, value in cfg.controls.items(): if not key in cfgRef.controls: cfgRef.controls[key] = value else: ctrls = copy.deepcopy(cfg.controls) cfgRef.controls = ctrls if not test: if configChange: # If cofig change is detected, replace entire configuration camCfg = CameraConfiguration() camCfg.use_case = cfg.use_case camCfg.transform = Transform( vflip=cfg.transform_vflip, hflip=cfg.transform_hflip ) camCfg.buffer_count = cfg.buffer_count cosp = cfg.colour_space if self.isUsb == False: if cosp == "sYCC": colourSpace = ColorSpace.Sycc() elif cosp == "Smpte170m": colourSpace = ColorSpace.Smpte170m() elif cosp == "Rec709": colourSpace = ColorSpace.Rec709() else: colourSpace = ColorSpace.Sycc() camCfg.colour_space = colourSpace else: camCfg.colour_space = cfgRef.colour_space camCfg.queue = cfg.queue camCfg.display = cfg.display camCfg.encode = cfg.encode stream = StreamConfiguration() stream.size = cfg.stream_size stream.format = cfg.format if cfg.stream == "main": camCfg.main = stream camCfg.lores = None camCfg.raw = None if cfg.stream == "lores": camCfg.main = stream camCfg.lores = stream camCfg.raw = None if cfg.stream == "raw": if cfgPhoto: mstream = StreamConfiguration() mstream.size = cfgPhoto.stream_size mstream.format = cfgPhoto.format camCfg.main = mstream else: camCfg.main = stream camCfg.lores = None camCfg.raw = stream ctrls = copy.deepcopy(cfg.controls) if len(ctrls) == 0: raise ValueError( "controls in camera configuration must not be empty" ) else: camCfg.controls = ctrls cfgRef = camCfg # Automatically align the stream size, if selected if cfg.stream_size_align and cfg.sensor_mode == "custom": cfgRef.align() if cfg.stream == "main": cfg.stream_size = cfgRef.main.size if cfg.stream == "lores": cfg.stream_size = cfgRef.lores.size self._requestedCfg = cfgRef logger.debug( "Thread %s: CameraController.requestConfig - configChange: %s", get_ident(), configChange, ) logger.debug( "Thread %s: CameraController.requestConfig - configChangeReason: %s", get_ident(), configChangeReason, ) logger.debug( "Thread %s: CameraController.requestConfig - cfg: %s", get_ident(), self._requestedCfg, ) return configChange, configChangeReason def codeGenConfig(self, cfg: CameraConfiguration): """Generate code for the given configuration""" logger.debug( "Thread %s: CameraController.codeGenConfig cfg: %s", get_ident(), cfg.__dict__, ) prgLogger.debug("ccfg = CameraConfiguration()") prgLogger.debug('ccfg.use_case = "%s"', cfg.use_case) if cfg.encode: prgLogger.debug('ccfg.encode = "%s"', cfg.encode) else: prgLogger.debug("ccfg.encode = None") if cfg.display: prgLogger.debug('ccfg.display = "%s"', cfg.display) else: prgLogger.debug("ccfg.display = None") prgLogger.debug("ccfg.buffer_count = %s", cfg.buffer_count) prgLogger.debug("ccfg.queue = %s", cfg.queue) if cfg.transform: prgLogger.debug( "ccfg.transform = Transform(vflip=%s, hflip=%s)", cfg.transform.vflip, cfg.transform.hflip, ) else: prgLogger.debug("ccfg.transform = None") if cfg.colour_space.__str__().find("sYCC") >= 0: prgLogger.debug("ccfg.colour_space = ColorSpace.Sycc()") if cfg.colour_space.__str__().find("SMPTE170M") >= 0: prgLogger.debug("ccfg.colour_space = ColorSpace.Smpte170m()") if cfg.colour_space.__str__().find("Rec709") >= 0: prgLogger.debug("ccfg.colour_space = ColorSpace.Rec709()") if cfg.controls: prgLogger.debug("ccfg.controls = %s", cfg.controls) else: prgLogger.debug("ccfg.controls = None") if useSensorConfiguration: if cfg.sensor: prgLogger.debug("ccfg.sensor = SensorConfiguration()") prgLogger.debug("ccfg.sensor.output_size = %s", cfg.sensor.output_size) prgLogger.debug("ccfg.sensor.bit_depth = %s", cfg.sensor.bit_depth) else: prgLogger.debug("ccfg.sensor = None") if cfg.main: prgLogger.debug("ccfg.main = StreamConfiguration()") prgLogger.debug("ccfg.main.size = %s", cfg.main.size) prgLogger.debug('ccfg.main.format = "%s"', cfg.main.format) prgLogger.debug("ccfg.main.stride = %s", cfg.main.stride) prgLogger.debug("ccfg.main.framesize = %s", cfg.main.framesize) else: prgLogger.debug("ccfg.main = None") if cfg.lores: prgLogger.debug("ccfg.lores = StreamConfiguration()") prgLogger.debug("ccfg.lores.size = %s", cfg.lores.size) prgLogger.debug('ccfg.lores.format = "%s"', cfg.lores.format) prgLogger.debug("ccfg.lores.stride = %s", cfg.lores.stride) prgLogger.debug("ccfg.lores.framesize = %s", cfg.lores.framesize) else: prgLogger.debug("ccfg.lores = None") if cfg.raw: prgLogger.debug("ccfg.raw = StreamConfiguration()") prgLogger.debug("ccfg.raw.size = %s", cfg.raw.size) prgLogger.debug('ccfg.raw.format = "%s"', cfg.raw.format) prgLogger.debug("ccfg.raw.stride = %s", cfg.raw.stride) prgLogger.debug("ccfg.raw.framesize = %s", cfg.raw.framesize) else: prgLogger.debug("ccfg.raw = None") def copyConfig(self, cfg: CameraConfiguration) -> CameraConfiguration: """Return a copy of the given configuration""" logger.debug( "Thread %s: CameraController.copyConfig cfg(in) : %s", get_ident(), cfg.__dict__, ) ccfg = CameraConfiguration() ccfg.use_case = cfg.use_case ccfg.encode = cfg.encode ccfg.display = cfg.display ccfg.buffer_count = cfg.buffer_count ccfg.queue = cfg.queue if cfg.transform: ccfg.transform = Transform( vflip=cfg.transform.vflip, hflip=cfg.transform.hflip ) else: ccfg.transform = None ccfg.colour_space = cfg.colour_space if cfg.controls: ccfg.controls = copy.copy(cfg.controls) else: ccfg.controls = None if useSensorConfiguration: if cfg.sensor: ccfg.sensor = SensorConfiguration() ccfg.sensor.output_size = copy.copy(cfg.sensor.output_size) ccfg.sensor.bit_depth = cfg.sensor.bit_depth else: ccfg.sensor = None if cfg.main: ccfg.main = StreamConfiguration() ccfg.main.size = copy.copy(cfg.main.size) ccfg.main.format = cfg.main.format ccfg.main.stride = cfg.main.stride ccfg.main.framesize = cfg.main.framesize else: ccfg.main = None if cfg.lores: ccfg.lores = StreamConfiguration() ccfg.lores.size = copy.copy(cfg.lores.size) ccfg.lores.format = cfg.lores.format ccfg.lores.stride = cfg.lores.stride ccfg.lores.framesize = cfg.lores.framesize else: ccfg.lores = None if cfg.raw: ccfg.raw = StreamConfiguration() ccfg.raw.size = copy.copy(cfg.raw.size) ccfg.raw.format = cfg.raw.format ccfg.raw.stride = cfg.raw.stride ccfg.raw.framesize = cfg.raw.framesize else: ccfg.raw = None logger.debug( "Thread %s: CameraController.copyConfig cfg(out): %s", get_ident(), ccfg.__dict__, ) return ccfg def compareConfig( self, cfg1: CameraConfiguration, cfg2: CameraConfiguration ) -> bool: """Check equality of configurations Return: result (bool): True if configurations are identical False if configuration differ difference (str): List of differences """ logger.debug( "Thread %s: CameraController.compareConfig cfg1: %s", get_ident(), cfg1.__dict__, ) logger.debug( "Thread %s: CameraController.compareConfig cfg2: %s", get_ident(), cfg2.__dict__, ) res = True dif = "" if cfg1.encode: if cfg2.encode: if cfg1.encode != cfg2.encode: res = False dif += "encode," else: res = False dif += "encode," else: if cfg2.encode: res = False dif += "encode," if cfg1.display: if cfg2.display: if cfg1.display != cfg2.display: res = False dif += "display," else: res = False dif += "display," else: if cfg2.display: res = False dif += "display," if cfg1.buffer_count: if cfg2.buffer_count: if cfg1.buffer_count != cfg2.buffer_count: res = False dif += "buffer_count," else: res = False dif += "buffer_count," else: if cfg2.buffer_count: res = False dif += "buffer_count," if cfg1.transform: if cfg2.transform: if ( cfg1.transform.hflip != cfg2.transform.hflip or cfg1.transform.vflip != cfg2.transform.vflip ): res = False dif += "transform," else: res = False dif += "transform," else: if cfg2.transform: res = False dif += "transform," if cfg1.colour_space: if cfg2.colour_space: if cfg1.colour_space != cfg2.colour_space: res = False dif += "colour_space," else: res = False dif += "colour_space," else: if cfg2.colour_space: res = False dif += "colour_space," if cfg1.queue: if cfg2.queue: if cfg1.queue != cfg2.queue: res = False dif += "queue," else: res = False dif += "queue," else: if cfg2.queue: res = False dif += "queue," if useSensorConfiguration: if cfg1.sensor: if cfg2.sensor: if cfg1.sensor.bit_depth != cfg2.sensor.bit_depth: res = False dif += "sensor.bit_depth," if cfg1.sensor.output_size != cfg2.sensor.output_size: res = False dif += "sensor.output_size," else: res = False dif += "sensor," else: if cfg2.sensor: res = False dif += "sensor," if cfg1.main: if cfg2.main: if cfg1.main.size != cfg2.main.size: res = False dif += "main.size," if cfg1.main.format != cfg2.main.format: res = False dif += "main.format," else: res = False dif += "main," else: if cfg2.main: res = False dif += "main," if cfg1.lores: if cfg2.lores: if cfg1.lores.size != cfg2.lores.size: res = False dif += "lores.size," if cfg1.lores.format != cfg2.lores.format: res = False dif += "lores.format," else: res = False dif += "lores," else: if cfg2.lores: res = False dif += "lores," if cfg1.raw: if cfg2.raw: if cfg1.raw.size != cfg2.raw.size: res = False dif += "raw.size," if cfg1.raw.format != cfg2.raw.format: res = False dif += "raw.format," else: res = False dif += "raw," else: if cfg2.raw: res = False dif += "raw," if cfg1.controls: resCtrls = True if cfg2.controls: for key, value in cfg1.controls.items(): if key in cfg2.controls: if value != cfg2.controls[key]: resCtrls = False if len(cfg1.controls) != len(cfg2.controls): resCtrls = False else: res = False dif += "controls," else: if cfg2.controls: res = False dif += "controls," if not resCtrls: res = False dif += "controls," logger.debug( "Thread %s: CameraController.compareConfig res: %s, dif: %s", get_ident(), res, dif, ) return res, dif def clearConfig(self): """Clear the configuration""" logger.debug("Thread %s: CameraController.clearConfig", get_ident()) self._requestedCfg = CameraConfiguration() def registerEncoder(self, task: str, encoder): """Register an encoder which needs to be stopped when stopping the camera""" logger.debug( "Thread %s: CameraController.registerEncoder: %s", get_ident(), encoder ) self._activeEncoders[task] = encoder def stopEncoder(self, cam, task: str): """Stop an encoder for a specific task""" logger.debug("Thread %s: CameraController.stopEncoder: %s", get_ident(), task) if task in self._activeEncoders: encoder = self._activeEncoders[task] cam.stop_encoder(encoder) prgLogger.debug("picam2.stop_encoder(encoder)") del self._activeEncoders[task] logger.debug( "Thread %s: CameraController.stopEncoder - Encoder stopped", get_ident() ) class CameraEvent(object): """An Event-like class that signals all active clients when a new frame is available. """ def __init__(self): # logger.debug("Thread %s: CameraEvent.__init__", get_ident()) self.events = {} def wait(self): """Invoked from each client's thread to wait for the next frame.""" # logger.debug("Thread %s: CameraEvent.wait", get_ident()) ident = get_ident() if ident not in self.events: # this is a new client # add an entry for it in the self.events dict # each entry has two elements, a threading.Event() and a timestamp self.events[ident] = [threading.Event(), time.time()] # logger.debug("Thread %s: CameraEvent.wait - Event ident: %s added to events dict. time:%s", get_ident(), ident, self.events[ident][1]) # for ident, event in self.events.items(): # logger.debug("Thread %s: CameraEvent.wait - Event ident: %s Flag: %s Time: %s (Flag False -> blocking)", get_ident(), ident, self.events[ident][0].is_set(), event[1]) return self.events[ident][0].wait() def set(self): """Invoked by the camera thread when a new frame is available.""" # logger.debug("Thread %s: CameraEvent.set", get_ident()) now = time.time() remove = None for ident, event in self.events.items(): if not event[0].isSet(): # if this client's event is not set, then set it # also update the last set timestamp to now event[0].set() event[1] = now # logger.debug("Thread %s: CameraEvent.set - Event ident: %s Flag: False -> True (unblock/notify)", get_ident(), ident) else: # if the client's event is already set, it means the client # did not process a previous frame # if the event stays set for more than 5 seconds, then assume # the client is gone and remove it # logger.debug("Thread %s: CameraEvent.set - Event ident: %s Flag: True (Last image not processed).", get_ident(), ident) if now - event[1] > 5: # logger.debug("Thread %s: CameraEvent.set - Event ident: %s too old; marked for removal.", get_ident(), ident) remove = ident if remove: del self.events[remove] # logger.debug("Thread %s: CameraEvent.set - Event ident: %s removed.", get_ident(), ident) def clear(self): """Invoked from each client's thread after a frame was processed.""" ident = get_ident() if ident in self.events: self.events[get_ident()][0].clear() # logger.debug("Thread %s: CameraEvent.clear - Flag set to False -> blocking.", get_ident()) def toDict(self): """Convert the event to a dict representation.""" return { "events": { ident: { "flag": event[0].is_set(), "time": event[1], "timeHR": datetime.datetime.fromtimestamp(event[1]).strftime( "%Y-%m-%d %H:%M:%S.%f" ), } for ident, event in self.events.items() } } class Camera: logger.debug("Thread %s: Camera - setting class variables", get_ident()) _instance = None ENCODER_LIVESTREAM = "LIVESTREAM" ENCODER_VIDEO = "VIDEO" ENCODER_PHOTOSERIES = "PHOTOSERIES" cam = None camIsUsb = False camUsbDev = "" camHasAi = False camWaitingForFirstFrame = True camProgressCounter = 0 cam_imx500 = None cam_imx500_last_detections = [] cam_imx500_last_results = None cam_imx500_labels = None cam_imx500_last_boxes = None cam_imx500_last_scores = None cam_imx500_last_keypoints = None cam_imx500_WINDOW_SIZE_H_W = (480, 640) cam_imx500_last_overlay = None cam_drawer = None camNum = -1 cam2 = None cam2IsUsb = False cam2UsbDev = "" cam2HasAi = False cam2WaitingForFirstFrame = True cam2ProgressCounter = 0 cam2_imx500 = None cam2_imx500_last_detections = [] cam2_imx500_last_results = None cam2_imx500_labels = None cam2_imx500_last_boxes = None cam2_imx500_last_scores = None cam2_imx500_last_keypoints = None cam2_imx500_WINDOW_SIZE_H_W = (480, 640) cam2_imx500_last_overlay = None cam2_drawer = None camNum2 = -1 ctrl: CameraController = None ctrl2: CameraController = None videoOutput = None videoOutput2 = None prgVideoOutput = None prgVideoOutput2 = None photoSeries: Series = None thread = None # background thread that reads frames from camera threadLock = allocate_lock() # lock for stopping the camera thread thread2 = None # background thread for second camera thread2Lock = allocate_lock() # lock for stopping the second camera thread threadUsbVideo = None # background thread that records video from USB camera threadUsbVideoLock = allocate_lock() # lock for stopping the USB video thread logUsbFrameApplyControls = False logUsbFrame2ApplyControls = False liveViewDeactivated = False liveView2Deactivated = False videoThread = None videoThread2 = None photoSeriesThread = None frame = None # current frame is stored here by background thread frame2 = None # current frame for second camera frameRaw = None # current raw frame is stored here by background thread frame2Raw = None # current raw frame for second camera streamOutput = None # output for MJPEG streaming for live stream stream2Output = None # output for MJPEG streaming for second camera last_access = 0 # time of last client access to the camera last_access2 = 0 # time of last client access for second camera stopRequested = False # Request to stop the background thread stopRequested2 = False # Request to stop the background thread for second camera stopVideoRequested = False # Request to stop the video thread stopVideoRequested2 = False # Request to stop the video thread stopUsbVideoRequested = False # Request to stop the video thread videoDuration = 0 # Planned duration of video recording in sec videoDuration2 = 0 # Planned duration of video recording in sec stopPhotoSeriesRequested = False # Request to stop the photoseries thread resetScalerCropRequested = False event = CameraEvent() event2 = None # Callbacks when_photo_taken = None when_photo_2_taken = None when_series_photo_taken = None when_recording_starts = None when_recording_stops = None when_recording_2_starts = None when_recording_2_stops = None when_streaming_1_starts = None when_streaming_1_stops = None when_streaming_2_starts = None when_streaming_2_stops = None COLOURS = np.array([ \ [128.0, 0.0, 0.0, 255.0], \ [0.0, 128.0, 0.0, 255.0], \ [128.0, 128.0, 0.0, 255.0], \ [0.0, 0.0, 128.0, 255.0], \ [128.0, 0.0, 128.0, 255.0], \ [0.0, 128.0, 128.0, 255.0], \ [128.0, 128.0, 128.0, 255.0], \ [64.0, 0.0, 0.0, 255.0], \ [192.0, 0.0, 0.0, 255.0], \ [64.0, 128.0, 0.0, 255.0], \ [192.0, 128.0, 0.0, 255.0], \ [64.0, 0.0, 128.0, 255.0], \ [192.0, 0.0, 128.0, 255.0], \ [64.0, 128.0, 128.0, 255.0], \ [192.0, 128.0, 128.0, 255.0], \ [0.0, 64.0, 0.0, 255.0], \ [128.0, 64.0, 0.0, 255.0], \ [0.0, 192.0, 0.0, 255.0], \ [128.0, 192.0, 0.0, 255.0], \ [0.0, 64.0, 128.0, 255.0], \ [0.0, 0.0, 0.0, 255.0] \ ]) def __new__(cls): logger.debug("Thread %s: Camera.__new__", get_ident()) if cls._instance is None: logger.debug( "Thread %s: Camera.__new__ - Instantiating Camera Class", get_ident() ) cls._instance = super(Camera, cls).__new__(cls) cls.cam = None cls.camIsUsb = False cls.camUsbDev = "" cls.camHasAi = False cls.camWaitingForFirstFrame = True cls.camProgressCounter = 0 cls.cam_imx500 = None cls.cam_imx500_last_detections = [] cls.cam_imx500_last_results = None cls.cam_imx500_labels = None cls.cam_imx500_last_boxes = None cls.cam_imx500_last_scores = None cls.cam_imx500_last_keypoints = None cls.cam_imx500_WINDOW_SIZE_H_W = (480, 640) cls.cam_imx500_last_overlay = None cls.cam_drawer = None cls.camNum = -1 cls.cam2 = None cls.cam2IsUsb = False cls.cam2UsbDev = "" cls.cam2HasAi = False cls.cam2WaitingForFirstFrame = True cls.cam2ProgressCounter = 0 cls.cam2_imx500 = None cls.cam2_imx500_last_detections = [] cls.cam2_imx500_last_results = None cls.cam2_imx500_labels = None cls.cam2_imx500_last_boxes = None cls.cam2_imx500_last_scores = None cls.cam2_imx500_last_keypoints = None cls.cam2_imx500_WINDOW_SIZE_H_W = (480, 640) cls.cam2_imx500_last_overlay = None cls.cam2_drawer = None cls.camNum2 = -1 cls.ctrl: CameraController = None cls.ctrl2: CameraController = None cls.videoOutput = None cls.videoOutput2 = None cls.prgVideoOutput = None cls.prgVideoOutput2 = None cls.photoSeries: Series = None cls.thread = None cls.threadLock = allocate_lock() cls.thread2 = None cls.thread2Lock = allocate_lock() cls.threadUsbVideo = None cls.threadUsbVideoLock = allocate_lock() cls.liveViewDeactivated = False cls.liveView2Deactivated = False cls.videoThread = None cls.videoThread2 = None cls.photoSeriesThread = None cls.frame = None cls.frame2 = None cls.frameRaw = None cls.frame2Raw = None cls.streamOutput = None cls.stream2Output = None cls.last_access = 0 cls.last_access2 = 0 cls.stopRequested = False cls.stopRequested2 = False cls.stopVideoRequested = False cls.stopVideoRequested2 = False cls.stopUsbVideoRequested = False cls.videoDuration = 0 cls.videoDuration2 = 0 cls.stopPhotoSeriesRequested = False cls.resetScalerCropRequested = False cls.event = CameraEvent() cls.event2 = None cls.when_photo_taken = None cls.when_photo_2_taken = None cls.when_series_photo_taken = None cls.when_recording_starts = None cls.when_recording_stops = None cls.when_recording_2_starts = None cls.when_recording_2_stops = None cls.when_streaming_1_starts = None cls.when_streaming_1_stops = None cls.when_streaming_2_starts = None cls.when_streaming_2_stops = None cls.initCamera() else: if CameraCfg().serverConfig.noCamera == False: if cls.cam is None: cls.initCamera() else: CameraCfg().serverConfig.error = None return cls._instance @classmethod def isCamera2Available(cls) -> bool: """Check if the second camera is available Returns True if the second camera is available, False otherwise """ logger.debug("Thread %s: Camera.isCamera2Available", get_ident()) if cls.cam2 is not None: return True logger.debug( "Thread %s: Camera.isCamera2Available - Second camera is available", get_ident(), ) else: logger.debug( "Thread %s: Camera.isCamera2Available - Second camera not available", get_ident(), ) return False @classmethod def initCamera(cls): """Instantiate the camera""" logger.debug( "Thread %s: Camera.initCamera - Instantiating Camera Class", get_ident() ) prgLogger.debug( "from picamera2 import Picamera2, CameraConfiguration, StreamConfiguration, Controls" ) prgLogger.debug("from libcamera import Transform, Size, ColorSpace, controls") prgLogger.debug("from picamera2.encoders import JpegEncoder, MJPEGEncoder") if useSensorConfiguration: prgLogger.debug("from picamera2.configuration import SensorConfiguration") prgLogger.debug("from picamera2.outputs import FileOutput, FfmpegOutput") prgLogger.debug("from picamera2.encoders import H264Encoder") prgLogger.debug("import time") prgLogger.debug("import os") prgLogger.debug("import gc") prgLogger.debug("import logging") prgLogger.debug("Picamera2.set_logging(logging.ERROR)") prgLogger.debug('os.environ["LIBCAMERA_LOG_LEVELS"] = "*:3"') prgLogger.debug("videoDuration = 10") cfg = CameraCfg() sc = cfg.serverConfig sc.error = None # Before all, load the global camera info to get the installed cameras and the active cam activeCam, activeCamIsUsb, activeCamUsbDev, activeCamHasAi = cls.getActiveCamera() if sc.noCamera == True: return if cls.cam is None: logger.debug( "Thread %s: Camera.initCamera: Active camera is None - Needing initialization", get_ident(), ) if activeCamIsUsb == False: logger.debug( "Thread %s: Camera.initCamera: Instantiating Pi camera %s", get_ident(), activeCam, ) cls.camIsUsb = False cls.camUsbDev = activeCamUsbDev cls.camHasAi = activeCamHasAi try: tc = cfg.tuningConfig if tc.loadTuningFile == False: cls.cam = Picamera2(activeCam) prgLogger.debug("picam2 = Picamera2(%s)", activeCam) else: tuning = Picamera2.load_tuning_file( tc.tuningFile, tc.tuningFolder ) cls.cam = Picamera2(activeCam, tuning=tuning) logger.debug( "Thread %s: Camera.initCamera - Initialized camera %s with tuning file %s", get_ident(), activeCam, tc.tuningFilePath, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug( "picam2 = Picamera2(%s, tuning=tuning)", activeCam ) cls.camNum = activeCam cls.ctrl = CameraController(cls.camIsUsb, cls.camUsbDev) except RuntimeError as e: logger.error( "Thread %s: Camera.initCamera - Error %s", get_ident(), e ) if not sc.error: sc.error = "Error while initializing camera: " + str(e) sc.error2 = "Probably another process is using the camera." sc.errorSource = "Picamera2" else: logger.debug( "Thread %s: Camera.initCamera: Instantiating USB camera %s", get_ident(), activeCam, ) cls.camIsUsb = True cls.camUsbDev = activeCamUsbDev cls.camHasAi = activeCamHasAi cls.cam = cv2.VideoCapture(cls.camUsbDev, cv2.CAP_V4L2) if not cls.cam or not cls.cam.isOpened(): logger.error( "Thread %s: Camera.initCamera - Error: USB camera not opened", get_ident(), ) sc.error = "Error while initializing camera: USB camera not opened" sc.errorSource = "CV2" else: logger.debug( "Thread %s: Camera.initCamera - Initialized USB camera %s", get_ident(), activeCam, ) cls.camNum = activeCam cls.ctrl = CameraController(cls.camIsUsb, cls.camUsbDev) else: logger.debug( "Thread %s: Camera.initCamera: Active camera is already set for %s. Checking if switch is needed", get_ident(), Camera.camNum, ) if activeCam != Camera.camNum: try: logger.debug( "Thread %s: Camera.initCamera: About to switch camera from %s to %s", get_ident(), Camera.camNum, activeCam, ) cls.stopCameraSystem() if activeCamIsUsb == False: tc = cfg.tuningConfig if tc.loadTuningFile == False: cls.cam = Picamera2(activeCam) prgLogger.debug("picam2 = Picamera2(%s)", activeCam) else: tuning = Picamera2.load_tuning_file( tc.tuningFile, tc.tuningFolder ) cls.cam = Picamera2(activeCam, tuning=tuning) logger.debug( "Thread %s: Camera.initCamera - Initialized camera %s with tuning file %s", get_ident(), activeCam, tc.tuningFilePath, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug( "picam2 = Picamera2(%s, tuning=tuning)", activeCam ) cls.camNum = activeCam cls.camIsUsb = False cls.camUsbDev = "" cls.camHasAi = activeCamHasAi cls.ctrl = CameraController(cls.camIsUsb, cls.camUsbDev) logger.debug( "Thread %s: Camera.initCamera: Switch camera to %s successful", get_ident(), activeCam, ) # Force refresh of camera properties cfg.cameraProperties.model = None cfg.sensorModes = [] cfg.rawFormats = [] logger.debug( "Thread %s: Camera.initCamera: Camera-specific configs were reset", get_ident(), ) else: cls.cam = cv2.VideoCapture(activeCamUsbDev, cv2.CAP_V4L2) if not cls.cam or not cls.cam.isOpened(): raise RuntimeError("USB camera not opened") cls.camNum = activeCam cls.camIsUsb = True cls.camUsbDev = activeCamUsbDev cls.camHasAi = activeCamHasAi cls.ctrl = CameraController(cls.camIsUsb, cls.camUsbDev) logger.debug( "Thread %s: Camera.initCamera: Switch camera to %s successful", get_ident(), activeCam, ) # Force refresh of camera properties cfg.cameraProperties.model = None cfg.sensorModes = [] cfg.rawFormats = [] logger.debug( "Thread %s: Camera.initCamera: Camera-specific configs were reset", get_ident(), ) except RuntimeError as e: logger.error( "Thread %s: Camera.initCamera - Error %s", get_ident(), e ) if not sc.error: if activeCamIsUsb == False: sc.error = "Error while initializing camera: " + str(e) sc.error2 = "Probably another process is using the camera." sc.errorSource = "Picamera2" else: sc.error = ( "Error while initializing camera: USB camera not opened" ) sc.errorSource = "CV2" except Exception as e: logger.error( "Thread %s: Camera.initCamera - Error %s", get_ident(), e ) if not sc.error: sc.error = "Error while initializing camera: " + str(e) sc.errorSource = "Picamera2" else: logger.debug( "Thread %s: Camera.initCamera: Camera was already instantiated", get_ident(), ) if not sc.error: if cls.camIsUsb == False: cls.loadCameraSpecifics() else: if cls.loadUsbCameraSpecifics() == False: sc.error = "USB Camera not found. Apply Settings/Configuration/Reload Cameras" sc.errorSource = "V4L2" if not sc.error: cls.setSecondCamera() if ( sc.isPhotoSeriesRecording == False and sc.isVideoRecording == False and sc.isLiveStream == False ): Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) if sc.isLiveStream2 == False: if Camera.cam2: Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) @staticmethod def getActiveCamera() -> tuple: """Determine the active camera and return its number, whether it is USB, and USB device path First load the global camera info, if not already done, Which gives us the list of currently connected cameras. Then check the active camera and return it. If a stored configuration had an active camera, camera number (Num) and model are checked. Returns: tuple: (active camera number (int), is USB (bool), USB device path (str), has AI capabilities (bool) ) """ logger.debug("Thread %s: Camera.getActiveCamera", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig trc = cfg.triggerConfig if (len(cfg.cameras) == 0) and (sc.noCamera == False): cfgCams = [] cams = Picamera2.global_camera_info() if len(cams) == 0: sc.noCamera = True logger.debug( "Thread %s: Camera.getActiveCamera - no cameras found", get_ident() ) return 0, False, "", False camNum = 0 for camera in cams: cfgCam = CameraInfo() if "Model" in camera: cfgCam.model = camera["Model"] if cfgCam.model == "imx500": cfgCam.hasAi = True if "Location" in camera: cfgCam.location = camera["Location"] if "Rotation" in camera: cfgCam.rotation = camera["Rotation"] if "Id" in camera: cfgCam.id = camera["Id"] # Check for USB camera if cfgCam.id.find("/usb@") > 0: cfgCam.isUsb = True logger.debug( "Thread %s: Camera.getActiveCamera - USB camera found: %s", get_ident(), cfgCam.id, ) else: cfgCam.usbDev = "" # On Bullseye systems, "Num" is not in the dict if "Num" in camera: cfgCam.num = camera["Num"] else: cfgCam.num = camNum camNum += 1 cfgCam.setUsbDev() cfgCams.append(cfgCam) cfg.cameras = cfgCams logger.debug( "Thread %s: Camera.getActiveCamera - %s cameras found", get_ident(), len(cfg.cameras), ) # Set the list of supported cameras cfg.setSupportedCameras() # Set the list of Pi cameras cfg.setPiCameras() # Check that active camera is within the list of cameras logger.debug( "Thread %s: Camera.getActiveCamera - Checking active camera %s (model: %s, isUsb: %s, usbDev: %s, hasAi: %s) against %s found cameras", get_ident(), sc.activeCamera, sc.activeCameraModel, sc.activeCameraIsUsb, sc.activeCameraUsbDev, sc.activeCameraHasAi, len(cfg.cameras) ) activeCamOK = False if sc.activeCameraModel != "": for cfgCam in sc.supportedCameras: if ( cfgCam.num == sc.activeCamera and cfgCam.model == sc.activeCameraModel and cfgCam.isUsb == sc.activeCameraIsUsb and cfgCam.usbDev == sc.activeCameraUsbDev and cfgCam.hasAi == sc.activeCameraHasAi ): activeCamOK = True break logger.debug( "Thread %s: Camera.getActiveCamera - Active camera:%s - activeCamOK:%s", get_ident(), sc.activeCamera, activeCamOK, ) # If config for active camera is not in the list, # set it to the first camera if activeCamOK == False: logger.debug( "Thread %s: Camera.getActiveCamera - Resetting active camera to first of %s supported cameras", get_ident(), len(sc.supportedCameras), ) for cfgCam in sc.supportedCameras: sc.activeCamera = cfgCam.num sc.activeCameraInfo = ( "Camera " + str(cfgCam.num) + " (" + cfgCam.model + ")" ) sc.activeCameraModel = cfgCam.model sc.activeCameraIsUsb = cfgCam.isUsb sc.activeCameraHasAi = cfgCam.hasAi sc.activeCameraUsbDev = cfgCam.usbDev break logger.debug( "Thread %s: Camera.getActiveCamera - active camera reset to %s", get_ident(), sc.activeCamera, ) # Reset the active camera configuration cfg.resetActiveCameraSettings() cfg.aiConfig=AiConfig() trc.setCameraSettingsToDefault() sc.unsavedChanges = True sc.addChangeLogEntry( f"Camera settings for {sc.activeCameraInfo} were reset due to camera model change" ) # Make sure that folder for photos exists sc.cameraPhotoSubPath = "photos/" + "camera_" + str(sc.activeCamera) fp = sc.photoRoot + "/" + sc.cameraPhotoSubPath if not os.path.exists(fp): os.makedirs(fp) logger.debug( "Thread %s: Camera.getActiveCamera - Photo directory created %s", get_ident(), fp, ) logger.debug( "Thread %s: Camera.getActiveCamera - activeCamera: %s - isUsb: %s - usbDev: %s - hasAi: %s", get_ident(), sc.activeCamera, sc.activeCameraIsUsb, sc.activeCameraUsbDev, sc.activeCameraHasAi ) return sc.activeCamera, sc.activeCameraIsUsb, sc.activeCameraUsbDev, sc.activeCameraHasAi @classmethod def switchCamera(cls): """Switch the camera""" logger.debug("Thread %s: Camera.switchCamera", get_ident()) logger.debug( "Thread %s: Camera.switchCamera - stopping Live Stream", get_ident() ) cls.stopLiveStream() logger.debug( "Thread %s: Camera.switchCamera - Live Stream stopped", get_ident() ) if cls.cam2: cls.stopLiveStream2() logger.debug( "Thread %s: Camera.switchCamera - Live Stream2 stopped", get_ident() ) time.sleep(1) activeCam, activeCamIsUsb, activeCamUsbDev, activeCamHasAi = Camera.getActiveCamera() cfg = CameraCfg() sc = cfg.serverConfig trc = cfg.triggerConfig if sc.noCamera == True: return logger.debug( "Thread %s: Camera.switchCamera - cfg.aiConfig 1: %s", get_ident(), cfg.aiConfig.__dict__ ) if Camera.cam is None: if activeCamIsUsb == False: logger.debug( "Thread %s: Camera.switchCamera: Instantiating Pi camera %s", get_ident(), activeCam, ) cls.camIsUsb = False cls.camUsbDev = "" cls.camHasAi = activeCamHasAi tc = cfg.tuningConfig ai = cfg.aiConfig if tc.loadTuningFile == False: cls.cam = Picamera2(activeCam) prgLogger.debug("picam2 = Picamera2(%s)", activeCam) else: tuning = Picamera2.load_tuning_file(tc.tuningFile, tc.tuningFolder) cls.cam = Picamera2(activeCam, tuning=tuning) logger.debug( "Thread %s: Camera.switchCamera - Initialized camera %s with tuning file %s", get_ident(), activeCam, tc.tuningFilePath, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug("picam2 = Picamera2(%s, tuning=tuning)", activeCam) # Set model for AI Camera if ai.enable: # Try to import IMX500 try: from picamera2.devices import IMX500 logger.debug( "Thread %s: Camera.switchCamera - import IMX500 successful", get_ident(), ) except ImportError: logger.error( "Camera.switchCamera - Could not import IMX500 from picamera2.devices", ) ai.enable = False if ai.enable: modelPath = os.path.join(ai.modelFolder, ai.modelFile) Camera.cam_imx500 = IMX500(modelPath) logger.debug( "Thread %s: Camera.switchCamera - IMX500 instantiated with model: %s", get_ident(), modelPath, ) else: logger.debug( "Thread %s: Camera.switchCamera: Instantiating USB camera %s", get_ident(), activeCam, ) cls.camIsUsb = True cls.camUsbDev = activeCamUsbDev cls.camHasAi = activeCamHasAi cls.cam = cv2.VideoCapture(cls.camUsbDev, cv2.CAP_V4L2) if not cls.cam or not cls.cam.isOpened(): logger.error( "Thread %s: Camera.initCamera - Error: USB camera not opened", get_ident(), ) Camera.camNum = activeCam Camera.camIsUsb = activeCamIsUsb Camera.camUsbDev = activeCamUsbDev Camera.camHasAi = activeCamHasAi Camera.ctrl = CameraController(Camera.camIsUsb, Camera.camUsbDev) else: if activeCam != Camera.camNum: logger.debug( "Thread %s: Camera.switchCamera: About to switch camera from %s to %s", get_ident(), Camera.camNum, activeCam, ) logger.debug( "Thread %s: Camera.switchCamera - cfg.aiConfig 2: %s", get_ident(), cfg.aiConfig.__dict__ ) Camera.stopCameraSystem() logger.debug( "Thread %s: Camera.switchCamera - cfg.aiConfig 3: %s", get_ident(), cfg.aiConfig.__dict__ ) if activeCamIsUsb == False: tc = cfg.tuningConfig ai = cfg.aiConfig logger.debug( "Thread %s: Camera.switchCamera: tc.loadTuningFile=%s", get_ident(), tc.loadTuningFile, ) if tc.loadTuningFile == False: cls.cam = Picamera2(activeCam) prgLogger.debug("picam2 = Picamera2(%s)", activeCam) else: tuning = Picamera2.load_tuning_file( tc.tuningFile, tc.tuningFolder ) cls.cam = Picamera2(activeCam, tuning=tuning) logger.debug( "Thread %s: Camera.switchCamera - Initialized camera %s with tuning file %s", get_ident(), activeCam, tc.tuningFilePath, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug( "picam2 = Picamera2(%s, tuning=tuning)", activeCam ) # Set model for AI Camera logger.debug( "Thread %s: Camera.switchCamera: ai.enable=%s", get_ident(), ai.enable, ) if ai.enable: # Try to import IMX500 try: from picamera2.devices import IMX500 logger.debug( "Thread %s: Camera.switchCamera - import IMX500 successful", get_ident(), ) except ImportError: logger.error( "Camera.switchCamera - Could not import IMX500 from picamera2.devices", ) ai.enable = False if ai.enable: modelPath = os.path.join(ai.modelFolder, ai.modelFile) Camera.cam_imx500 = IMX500(modelPath) logger.debug( "Thread %s: Camera.switchCamera - IMX500 instantiated with model: %s", get_ident(), modelPath, ) Camera.camNum = activeCam Camera.camIsUsb = False Camera.camUsbDev = "" Camera.camHasAi = activeCamHasAi Camera.ctrl = CameraController(Camera.camIsUsb, Camera.camUsbDev) logger.debug( "Thread %s: Camera.switchCamera: Switch camera to %s successful", get_ident(), activeCam, ) # Force refresh of camera properties CameraCfg().cameraProperties.model = None CameraCfg().sensorModes = [] CameraCfg().rawFormats = [] CameraCfg().resetActiveCameraSettings() trc.setCameraSettingsToDefault() logger.debug( "Thread %s: Camera.switchCamera: Camera-specific configs were reset", get_ident(), ) else: cls.cam = cv2.VideoCapture(activeCamUsbDev, cv2.CAP_V4L2) if not cls.cam or not cls.cam.isOpened(): logger.error( "Thread %s: Camera.switchCamera - Error: USB camera not opened", get_ident(), ) Camera.camNum = activeCam Camera.camIsUsb = True Camera.camUsbDev = activeCamUsbDev Camera.camHasAi = activeCamHasAi Camera.ctrl = CameraController(Camera.camIsUsb, Camera.camUsbDev) logger.debug( "Thread %s: Camera.switchCamera: Switch camera to %s successful", get_ident(), activeCam, ) # Force refresh of camera properties CameraCfg().cameraProperties.model = None CameraCfg().sensorModes = [] CameraCfg().rawFormats = [] CameraCfg().resetActiveCameraSettings() trc.setCameraSettingsToDefault() logger.debug( "Thread %s: Camera.switchCamera: Camera-specific configs were reset", get_ident(), ) else: logger.debug( "Thread %s: Camera.switchCamera: Camera was already instantiated", get_ident(), ) time.sleep(1) if cls.camIsUsb == False: cls.loadCameraSpecifics() cls.setSecondCamera() else: if cls.loadUsbCameraSpecifics() == False: sc.error = "USB Camera not found. Apply Settings/Configuration/Reload Cameras" sc.errorSource = "V4L2" else: cls.setSecondCamera() # Restore streaming config, if available cls.restoreConfigFromStreamingConfig() logger.debug( "Thread %s: Camera.switchCamera - starting Live Stream", get_ident() ) cls.startLiveStream() logger.debug( "Thread %s: Camera.switchCamera - Live Stream started", get_ident() ) logger.debug("Thread %s: Camera.switchCamera - second camera set", get_ident()) if cls.cam2: cls.startLiveStream2() logger.debug( "Thread %s: Camera.switchCamera - Live Stream 2 started", get_ident() ) @classmethod def startLiveStream(cls): """Start thread for live stream""" logger.debug("Thread %s: Camera.startLiveStream", get_ident()) if (not CameraCfg().serverConfig.error) and (not CameraCfg().serverConfig.noCamera): if Camera.liveViewDeactivated: logger.debug( "Thread %s: Not starting Live View thread. Live View deactivated", get_ident(), ) CameraCfg().serverConfig.isLiveStream = False else: with Camera.threadLock: Camera.last_access = time.time() logger.debug( "Thread %s: Camera.startLiveStream - last_access set", get_ident() ) if Camera.thread is None: logger.debug( "Thread %s: Camera.startLiveStream: Starting new thread", get_ident(), ) # start background frame thread Camera.thread = threading.Thread(target=cls._thread) Camera.thread.start() logger.debug( "Thread %s: Camera.startLiveStream - Thread started", get_ident(), ) # wait until first frame is available logger.debug( "Thread %s: Camera.startLiveStream - waiting for frame", get_ident(), ) # Waiting not necessary if camera-start animation is shown if cv2Available == False: Camera.event.wait() if not CameraCfg().serverConfig.error: CameraCfg().serverConfig.isLiveStream = True else: logger.debug( "Thread %s: Camera.startLiveStream - Thread exists", get_ident() ) if not Camera.thread.is_alive: logger.debug( "Thread %s: Camera.startLiveStream - Thread is not alive", get_ident(), ) Camera.thread = threading.Thread(target=cls._thread) Camera.thread.start() logger.debug( "Thread %s: Camera.startLiveStream - Thread started", get_ident(), ) @classmethod def startLiveStream2(cls): """Start thread for live stream""" logger.debug("Thread %s: Camera.startLiveStream2", get_ident()) if not CameraCfg().serverConfig.errorc2: if cls.cam2: if Camera.liveView2Deactivated: logger.debug( "Thread %s: Not starting Live View 2 thread. Live View 2 deactivated", get_ident(), ) CameraCfg().serverConfig.isLiveStream2 = False else: # logger.debug("Thread %s: Camera.startLiveStream2 - About to acquire Lock: thread2Lock=%s.", get_ident(), Camera.thread2Lock.locked()) with Camera.thread2Lock: Camera.last_access2 = time.time() # logger.debug("Thread %s: Camera.startLiveStream2 - last_access2 set", get_ident()) if Camera.thread2 is None: logger.debug( "Thread %s: Camera.startLiveStream2: Starting new thread", get_ident(), ) # start background frame thread Camera.thread2 = threading.Thread(target=cls._thread2) Camera.thread2.start() logger.debug( "Thread %s: Camera.startLiveStream2 - Thread started", get_ident(), ) # wait until first frame is available logger.debug( "Thread %s: Camera.startLiveStream2 - waiting for frame", get_ident(), ) # Waiting not necessary if camera-start animation is shown if cv2Available == False: Camera.event2.wait() if not CameraCfg().serverConfig.errorc2: CameraCfg().serverConfig.isLiveStream2 = True else: logger.debug( "Thread %s: Camera.startLiveStream2 - Thread exists", get_ident(), ) if not Camera.thread2.is_alive: logger.debug( "Thread %s: Camera.startLiveStream2 - Thread is not alive", get_ident(), ) Camera.thread2 = threading.Thread(target=cls._thread2) Camera.thread2.start() logger.debug( "Thread %s: Camera.startLiveStream2 - Thread started", get_ident(), ) else: logger.debug( "Thread %s: Camera.startLiveStream2 - Not starting Live View 2 thread. Second camera not available", get_ident(), ) else: logger.debug( "Thread %s: Camera.startLiveStream2 - Not starting Live View 2 thread. Error present: %s", get_ident(), CameraCfg().serverConfig.errorc2, ) @classmethod def stopLiveStream(cls): """Stop thread for live stream""" logger.debug("Thread %s: Camera.stopLiveStream", get_ident()) if not Camera.thread is None: logger.debug( "Thread %s: Camera.stopLiveStream - stopping live stream thread", get_ident(), ) Camera.stopRequested = True cnt = 0 while Camera.thread: time.sleep(0.01) cnt += 1 if cnt > 200: # Assume thread dead Camera.thread = None logger.debug( "Thread %s: Camera.stopLiveStream: Thread assumed dead", get_ident(), ) break # raise TimeoutError("Background thread did not stop within 2 sec") if cnt < 200: logger.debug( "Thread %s: Camera.stopLiveStream: Thread has stopped", get_ident() ) Camera.ctrl.stopEncoder(Camera.cam, Camera.ENCODER_LIVESTREAM) CameraCfg().serverConfig.isLiveStream = False else: logger.debug( "Thread %s: Camera.stopLiveStream: Thread was not started", get_ident() ) CameraCfg().serverConfig.isLiveStream = False @classmethod def stopLiveStream2(cls): """Stop thread for live stream 2""" logger.debug("Thread %s: Camera.stopLiveStream2", get_ident()) if Camera.cam2: if not Camera.thread2 is None: logger.debug( "Thread %s: Camera.stopLiveStream2 - stopping live stream thread", get_ident(), ) Camera.stopRequested2 = True cnt = 0 while Camera.thread2: time.sleep(0.01) cnt += 1 if cnt > 200: # Assume thread dead Camera.thread2 = None logger.debug( "Thread %s: Camera.stopLiveStream2: Thread assumed dead", get_ident(), ) break # raise TimeoutError("Background thread did not stop within 2 sec") if cnt < 200: logger.debug( "Thread %s: Camera.stopLiveStream2: Thread has stopped", get_ident(), ) Camera.ctrl2.stopEncoder(Camera.cam2, Camera.ENCODER_LIVESTREAM) CameraCfg().serverConfig.isLiveStream2 = False else: logger.debug( "Thread %s: Camera.stopLiveStream2: Thread was not started", get_ident(), ) CameraCfg().serverConfig.isLiveStream2 = False @staticmethod def restartLiveStream(): logger.debug("Thread %s: Camera.restartLiveStream", get_ident()) Camera.liveViewDeactivated = True Camera.stopLiveStream() time.sleep(0.5) logger.debug( "Thread %s: Camera.restartLiveStream: Live stream stopped", get_ident() ) Camera.cam, done = Camera.ctrl.requestStop(Camera.cam) logger.debug("Thread %s: Camera.restartLiveStream: Camera stopped", get_ident()) time.sleep(0.5) Camera.ctrl.clearConfig() logger.debug("Thread %s: Camera.restartLiveStream: Config cleared", get_ident()) Camera.liveViewDeactivated = False Camera.startLiveStream() logger.debug( "Thread %s: Camera.restartLiveStream: Live stream started", get_ident() ) @staticmethod def restartLiveStream2(): logger.debug("Thread %s: Camera.restartLiveStream2", get_ident()) Camera.liveView2Deactivated = True Camera.stopLiveStream2() logger.debug( "Thread %s: Camera.restartLiveStream2: Live stream stopped", get_ident() ) Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2) logger.debug( "Thread %s: Camera.restartLiveStream2: Camera stopped", get_ident() ) Camera.ctrl2.clearConfig() logger.debug( "Thread %s: Camera.restartLiveStream2: Config cleared", get_ident() ) Camera.liveView2Deactivated = False Camera.startLiveStream2() logger.debug( "Thread %s: Camera.restartLiveStream2: Live stream started", get_ident() ) def getLiveViewImageForMotionDetection(self): """Capture and return a buffer""" cfg = CameraCfg() if Camera.camIsUsb == False: if cfg.triggerConfig.motionDetectAlgo == 0: buf = Camera.cam.capture_buffer(cfg.liveViewConfig.stream) (w, h) = cfg.liveViewConfig.stream_size buf = buf[: w * h].reshape(h, w) frameRaw = buf else: frameRaw = Camera.cam.capture_array(cfg.liveViewConfig.stream) if cfg.liveViewConfig.format == "YUV420": if cv2Available == True: frameRaw = cv2.cvtColor(frameRaw, cv2.COLOR_YUV2BGR_I420) else: frame, frameRaw = self.get_frame() return copy.copy(frameRaw) def getLeftImageForStereo(self): """Capture and return a buffer""" if Camera.camIsUsb == False: return Camera.cam.capture_array(CameraCfg().liveViewConfig.stream) else: frame, frameRaw = self.get_frame() return frameRaw def getRightImageForStereo(self): """Capture and return a buffer""" if Camera.camIsUsb == False: return Camera.cam2.capture_array( CameraCfg().streamingCfg[str(Camera.camNum2)]["liveconfig"].stream ) else: frame, frameRaw = self.get_frame2() return frameRaw def startAnimation(self): """Create animation while camera is starting""" canvas = np.ones((480, 640, 3), dtype="uint8") * 255 cv2.putText( canvas, "Camera Starting", (65, 300), cv2.FONT_HERSHEY_SIMPLEX, 2, (0, 0, 0), 3, cv2.LINE_AA, ) if Camera.camHasAi: cv2.putText( canvas, "imx500 loading model", (140, 350), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 1, cv2.LINE_AA, ) cv2.putText( canvas, "This may take a while", (150, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 1, cv2.LINE_AA, ) angle = Camera.camProgressCounter * math.pi / 60.0 x = int(320 + 60 * math.cos(angle)) y = int(140 + 60 * math.sin(angle)) cv2.circle(canvas, (x, y), 15, (0, 0, 0), -1) Camera.camProgressCounter += 1 time.sleep(0.05) return canvas def startAnimation2(self): """Create animation while camera 2 is starting""" canvas = np.ones((480, 640, 3), dtype="uint8") * 255 cv2.putText( canvas, "Camera Starting", (65, 300), cv2.FONT_HERSHEY_SIMPLEX, 2, (0, 0, 0), 3, cv2.LINE_AA, ) if Camera.cam2HasAi: cv2.putText( canvas, "imx500 loading model", (140, 350), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 1, cv2.LINE_AA, ) cv2.putText( canvas, "This may take a while", (150, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 1, cv2.LINE_AA, ) angle = Camera.cam2ProgressCounter * math.pi / 60.0 x = int(320 + 60 * math.cos(angle)) y = int(140 + 60 * math.sin(angle)) cv2.circle(canvas, (x, y), 15, (0, 0, 0), -1) Camera.cam2ProgressCounter += 1 time.sleep(0.05) return canvas def get_frame(self): """Return the current camera frame.""" # logger.debug("Thread %s: Camera.get_frame", get_ident()) with Camera.threadLock: Camera.last_access = time.time() if cv2Available == True: if Camera.camWaitingForFirstFrame == True: frame = self.startAnimation() stat, frame_jpg = cv2.imencode(".jpg", frame) if stat: return frame_jpg.tobytes(), None return None, None # wait for a signal from the camera thread # logger.debug("Thread %s: Camera.get_frame - waiting for frame", get_ident()) Camera.event.wait() # logger.debug("Thread %s: Camera.get_frame - continue", get_ident()) Camera.event.clear() # logger.debug("Thread %s: Returning frame", get_ident()) return Camera.frame, Camera.frameRaw def get_frame2(self): """Return the current camera 2 frame.""" # logger.debug("Thread %s: Camera.get_frame2", get_ident()) if Camera.cam2: with Camera.thread2Lock: Camera.last_access2 = time.time() if cv2Available == True: if Camera.cam2WaitingForFirstFrame == True: frame = self.startAnimation2() stat, frame_jpg = cv2.imencode(".jpg", frame) if stat: return frame_jpg.tobytes(), None return None, None # wait for a signal from the camera thread # logger.debug("Thread %s: Camera.get_frame2 - waiting for frame", get_ident()) Camera.event2.wait() # logger.debug("Thread %s: Camera.get_frame2 - continue", get_ident()) Camera.event2.clear() # logger.debug("Thread %s: Returning frame2", get_ident()) return Camera.frame2, Camera.frame2Raw else: return None, None def get_photoFrame(self): """Return the current camera frame.""" logger.debug("Thread %s: Camera.get_photoFrame", get_ident()) with Camera.threadLock: Camera.last_access = time.time() # wait for a signal from the camera thread logger.debug( "Thread %s: Camera.get_photoFrame - waiting for frame", get_ident() ) Camera.event.wait() logger.debug("Thread %s: Camera.get_photoFrame - continue", get_ident()) Camera.event.clear() logger.debug("Thread %s: Camera.get_photoFrame - Returning frame", get_ident()) return Camera.frame def get_photoFrame_hr(self): """Return photo frame, assuming that camera is runnuing """ logger.debug("Thread %s: Camera.get_photoFrame_hr", get_ident()) frame = None cfg = CameraCfg() if Camera.camIsUsb == False: if Camera.cam.started: try: buffer = io.BytesIO() Camera.cam.capture_file(buffer, format="jpeg", name=cfg.photoConfig.stream) frame = buffer.getvalue() except Exception as e: logger.error("Camera.get_photoFrame_hr - Error %s", e) else: err = "Camera not started" logger.error("Camera.get_photoFrame_hr - Error %s", err) else: if Camera.cam.isOpened() == True: frame, frameRaw = Camera().get_frame() else: err = "USB Camera not started" logger.error("Camera.get_photoFrame_hr - Error %s", err) return frame def get_photoFrame2(self): """Return the current camera 2 frame.""" logger.debug("Thread %s: Camera.get_photoFrame2", get_ident()) if Camera.cam2: with Camera.thread2Lock: Camera.last_access2 = time.time() # wait for a signal from the camera thread logger.debug( "Thread %s: Camera.get_photoFrame2 - waiting for frame", get_ident() ) Camera.event2.wait() logger.debug("Thread %s: Camera.get_photoFrame2 - continue", get_ident()) Camera.event2.clear() logger.debug( "Thread %s: Camera.get_photoFrame2 - Returning frame", get_ident() ) return Camera.frame2 else: return None def get_photoFrame2_hr(self): """Return photo frame, assuming that camera is runnuing """ logger.debug("Thread %s: Camera.get_photoFrame2_hr", get_ident()) frame = None cfg = CameraCfg() strc = cfg.streamingCfg if Camera.cam2IsUsb == False: if Camera.cam2.started: camNum2Str = str(Camera.camNum2) if camNum2Str in strc: scfg = strc[camNum2Str] if "photoconfig" in scfg: stream = scfg["photoconfig"].stream try: buffer = io.BytesIO() Camera.cam2.capture_file(buffer, format="jpeg", name=stream) frame = buffer.getvalue() except Exception as e: logger.error("Camera.get_photoFrame2_hr - Error %s", e) else: err = "Camera 2 photo config not found" logger.error("Camera.get_photoFrame2_hr - Error %s", err) else: err = "Camera 2 config not found" logger.error("Camera.get_photoFrame2_hr - Error %s", err) else: err = "Camera 2 not started" logger.error("Camera.get_photoFrame2_hr - Error %s", err) else: if Camera.cam2.isOpened() == True: frame, frameRaw = Camera().get_frame2() else: err = "USB Camera not started" logger.error("Camera.get_photoFrame2_hr - Error %s", err) return frame @staticmethod def loadCameraSpecifics(): """Load camera specific parameters into configuration, if not already done""" logger.debug("Thread %s: Camera.loadCameraSpecifics", get_ident()) cfg = CameraCfg() cfgProps = cfg.cameraProperties cfgCtrls = cfg.controls cfgSensorModes = cfg.sensorModes cfgRawFormats = cfg.rawFormats # Load Camera Properties if cfgProps.model is None: camPprops = Camera.cam.camera_properties cfgProps.model = camPprops["Model"] if "UnitCellSize" in camPprops: cfgProps.unitCellSize = camPprops["UnitCellSize"] cfgProps.location = camPprops["Location"] cfgProps.rotation = camPprops["Rotation"] cfgProps.pixelArraySize = camPprops["PixelArraySize"] cfgProps.pixelArrayActiveAreas = camPprops["PixelArrayActiveAreas"] cfgProps.colorFilterArrangement = camPprops["ColorFilterArrangement"] cfgProps.scalerCropMaximum = camPprops["ScalerCropMaximum"] cfgProps.systemDevices = camPprops["SystemDevices"] if "SensorSensitivity" in camPprops: cfgProps.sensorSensitivity = camPprops["SensorSensitivity"] cfgProps.hasFocus = "AfMode" in Camera.cam.camera_controls cfgProps.hasFlicker = "AeFlickerMode" in Camera.cam.camera_controls cfgProps.hasHdr = "HdrMode" in Camera.cam.camera_controls if cfgCtrls.include_scalerCrop == False: cfgCtrls.scalerCrop = ( 0, 0, camPprops["PixelArraySize"][0], camPprops["PixelArraySize"][1], ) # This must be updated after the camera has been started Camera.resetScalerCropRequested = True logger.debug( "Thread %s: Camera.loadCameraSpecifics loaded to config", get_ident() ) # Load Sensor Modes if len(cfgSensorModes) == 0: sensorModes = Camera.cam.sensor_modes ind = 0 for mode in sensorModes: fmt = str(mode["format"]) if not fmt in cfgRawFormats: cfgRawFormats.append(fmt) fmt = str(mode["unpacked"]) if not fmt in cfgRawFormats: cfgRawFormats.append(fmt) cfgMode = SensorMode() cfgMode.id = str(ind) cfgMode.format = mode["format"] cfgMode.unpacked = mode["unpacked"] cfgMode.bit_depth = mode["bit_depth"] cfgMode.size = mode["size"] cfgMode.fps = mode["fps"] cfgMode.crop_limits = mode["crop_limits"] cfgMode.exposure_limits = mode["exposure_limits"] cfgSensorModes.append(cfgMode) ind = ind + 1 logger.debug( "Thread %s: Camera.loadCameraSpecifics: %s sensor modes found", get_ident(), len(cfg.sensorModes), ) logger.debug( "Thread %s: Camera.loadCameraSpecifics: %s raw formats found", get_ident(), len(cfg.rawFormats), ) # Set some Sensor Mode specific parameters for standard configurations maxModei = len(cfg.sensorModes) - 1 maxMode = str(maxModei) # For Live View # Initially set the stream size to (640, 480). Use Sensor Mode, if possible # If stream_size is set, keep the settings. They have been loeaded from stored config if cfg.liveViewConfig.stream_size is None: sizeWidth = 640 sizeHeight = int( sizeWidth * cfgProps.pixelArraySize[1] / cfgProps.pixelArraySize[0] ) if (sizeHeight % 2) != 0: sizeHeight += 1 cfg.liveViewConfig.stream_size = (sizeWidth, sizeHeight) cfg.liveViewConfig.stream_size_align = False if ( cfgSensorModes[0].size[0] == sizeWidth and cfgSensorModes[0].size[1] == sizeHeight ): cfg.liveViewConfig.sensor_mode = "0" else: cfg.liveViewConfig.sensor_mode = "custom" # For photo if cfg.photoConfig.stream_size is None: cfg.photoConfig.sensor_mode = maxMode cfg.photoConfig.stream_size = cfgSensorModes[maxModei].size # For raw photo if cfg.rawConfig.stream_size is None: cfg.rawConfig.sensor_mode = maxMode cfg.rawConfig.stream_size = cfgSensorModes[maxModei].size cfg.rawConfig.format = str(cfgSensorModes[maxModei].format) # For Video if cfg.videoConfig.stream_size is None: # For Pi < 5 set video and photo resolution to lowest value if ( cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 1") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 2") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 3") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 4") ): cfg.videoConfig.sensor_mode = 0 cfg.videoConfig.stream_size = cfgSensorModes[0].size cfg.photoConfig.sensor_mode = 0 cfg.photoConfig.stream_size = cfgSensorModes[0].size else: cfg.videoConfig.sensor_mode = maxMode cfg.videoConfig.stream_size = cfgSensorModes[maxModei].size # Sync aspect ratio for CSI cameras cfg.serverConfig.syncAspectRatio = True @staticmethod def loadUsbCameraSpecifics() -> bool: """Load USB camera specific parameters into configuration, if not already done Returns: bool: True if USB camera specifics were loaded, False otherwise """ logger.debug("Thread %s: Camera.loadUsbCameraSpecifics", get_ident()) cfg = CameraCfg() # Load Camera Properties if cfg.cameraProperties.model is None: if cfg.setUsbCameraProperties() == False: return False if cfg.controls.include_scalerCrop == False: cfg.controls.scalerCrop = cfg.cameraProperties.scalerCropMaximum # This must be updated after the camera has been started Camera.resetScalerCropRequested = True logger.debug( "Thread %s: Camera.loadUsbCameraSpecifics loaded to config", get_ident() ) # Load Sensor Modes if len(cfg.sensorModes) == 0: if cfg.setUsbSensorModes() == False: return False logger.debug( "Thread %s: Camera.loadUsbCameraSpecifics: %s sensor modes found", get_ident(), len(cfg.sensorModes), ) logger.debug( "Thread %s: Camera.loadUsbCameraSpecifics: %s raw formats found", get_ident(), len(cfg.rawFormats), ) # Set some Sensor Mode specific parameters for standard configurations maxModei = len(cfg.sensorModes) - 1 maxMode = str(maxModei) # For Live View # Initially set the stream size to the size of the first Sensor Mode if cfg.liveViewConfig.stream_size is None: cfg.liveViewConfig.sensor_mode = "0" sizeWidth = cfg.sensorModes[0].size[0] sizeHeight = cfg.sensorModes[0].size[1] cfg.liveViewConfig.stream_size = (sizeWidth, sizeHeight) cfg.liveViewConfig.colour_space = cfg.cameraProperties.colorSpace cfg.liveViewConfig.buffer_count = 1 cfg.liveViewConfig.queue = False cfg.liveViewConfig.stream = "main" cfg.liveViewConfig.stream_size_align = False cfg.liveViewConfig.format = cfg.sensorModes[0].format cfg.liveViewConfig.display = None cfg.liveViewConfig.encode = None # For photo if cfg.photoConfig.stream_size is None: cfg.photoConfig.sensor_mode = maxMode cfg.photoConfig.stream_size = cfg.sensorModes[maxModei].size cfg.photoConfig.colour_space = cfg.cameraProperties.colorSpace cfg.photoConfig.buffer_count = 1 cfg.photoConfig.queue = False cfg.photoConfig.stream = "main" cfg.photoConfig.stream_size_align = False cfg.photoConfig.format = cfg.sensorModes[maxModei].format cfg.photoConfig.display = None cfg.photoConfig.encode = None # For raw photo if cfg.rawConfig.stream_size is None: cfg.rawConfig.sensor_mode = maxMode cfg.rawConfig.stream_size = cfg.sensorModes[maxModei].size cfg.rawConfig.format = "tiff" cfg.rawConfig.colour_space = cfg.cameraProperties.colorSpace cfg.rawConfig.buffer_count = 1 cfg.rawConfig.queue = False cfg.rawConfig.stream = "main" cfg.rawConfig.stream_size_align = False # For Video if cfg.videoConfig.stream_size is None: cfg.videoConfig.sensor_mode = maxMode cfg.videoConfig.stream_size = cfg.sensorModes[maxModei].size cfg.videoConfig.colour_space = cfg.cameraProperties.colorSpace cfg.videoConfig.buffer_count = 1 cfg.videoConfig.queue = False cfg.videoConfig.stream = "main" cfg.videoConfig.stream_size_align = False cfg.videoConfig.format = cfg.sensorModes[maxModei].format cfg.videoConfig.display = None cfg.videoConfig.encode = None # Do not sync aspect ratio for USB cameras cfg.serverConfig.syncAspectRatio = False # Load USB Camera Controls if len(cfg.controls.usbCamControls) == 0: cfg.setUsbCamControls() cfg.cameraProperties.hasFocus = "AfMode" in cfg.controls.usbCamControls return True @classmethod def setSecondCamera(cls): """Set the second camera""" logger.debug("Thread %s: Camera.setSecondCamera", get_ident()) cls.camNum2 = None cls.cam2 = None cfg = CameraCfg() sc = cfg.serverConfig sc.errorc2 = None camNum2 = None secondCamIsUsb = False secondCamUsbDev = "" secondCamHasAi = False secondCamModel = "" # Check camera list for registered second camera if not sc.secondCamera is None: secondCam = None for cfgCam in cfg.cameras: if cfgCam.num == sc.secondCamera \ and cfgCam.model == sc.secondCameraModel: secondCam = cfgCam.num camNum2 = cfgCam.num secondCamIsUsb = cfgCam.isUsb secondCamUsbDev = cfgCam.usbDev secondCamHasAi = cfgCam.hasAi secondCamModel = cfgCam.model break if secondCam is None: logger.debug( "Thread %s: Camera.setSecondCamera - Registered second camera %s not found", get_ident(), sc.secondCamera, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Second camera was reset Camera {sc.secondCamera}: {sc.secondCameraModel} - not found" ) sc.secondCamera = None # If no registered second camera, take the first available which is not the active camera if sc.secondCamera is None: for cfgCam in cfg.cameras: if cfgCam.num != cls.camNum and camNum2 is None: # Take the first camera which is not the active camera if USB is OK if not cfgCam.isUsb or sc.supportsUsbCamera == True: camNum2 = cfgCam.num secondCamIsUsb = cfgCam.isUsb secondCamUsbDev = cfgCam.usbDev secondCamHasAi = cfgCam.hasAi secondCamModel = cfgCam.model break logger.debug( "Thread %s: Camera.setSecondCamera - found second camera: %s model: %s", get_ident(), camNum2, secondCamModel, ) if not camNum2 is None: try: cls.camNum2 = camNum2 cls.cam2IsUsb = secondCamIsUsb cls.cam2UsbDev = secondCamUsbDev cls.cam2HasAi = secondCamHasAi sc.secondCamera = camNum2 sc.secondCameraIsUsb = secondCamIsUsb sc.secondCameraUsbDev = secondCamUsbDev sc.secondCameraHasAi = secondCamHasAi sc.secondCameraModel = secondCamModel sc.secondCameraInfo = ( "Camera " + str(camNum2) + " (" + secondCamModel + ")" ) strc = cfg.streamingCfg camNum2Str = str(camNum2) if secondCamIsUsb == False: if camNum2Str in strc: scfg = strc[camNum2Str] if "tuningconfig" in scfg: tc = scfg["tuningconfig"] if tc.loadTuningFile == False: cls.cam2 = Picamera2(cls.camNum2) prgLogger.debug("picam2 = Picamera2(%s)", cls.camNum2) else: tuning = Picamera2.load_tuning_file( tc.tuningFile, tc.tuningFolder ) cls.cam2 = Picamera2(cls.camNum2, tuning=tuning) logger.debug( "Thread %s: Camera.setSecondCamera - Initialized camera %s with tuning file %s", get_ident(), cls.camNum2, tc.tuningFilePath, ) prgLogger.debug( "tuning = Picamera2.load_tuning_file(%s, %s)", tc.tuningFile, tc.tuningFolder, ) prgLogger.debug( "picam2 = Picamera2(%s, tuning=tuning)", cls.camNum2 ) else: cls.cam2 = Picamera2(cls.camNum2) prgLogger.debug("picam2 = Picamera2(%s)", cls.camNum2) else: cls.cam2 = Picamera2(cls.camNum2) prgLogger.debug("picam2 = Picamera2(%s)", cls.camNum2) else: cls.cam2 = cv2.VideoCapture(cls.cam2UsbDev, cv2.CAP_V4L2) if not cls.cam2 or not cls.cam2.isOpened(): raise RuntimeError("USB camera not opened") cls.ctrl2 = CameraController(cls.cam2IsUsb, cls.cam2UsbDev, forActiveCamera=False) cls.event2 = CameraEvent() logger.debug( "Thread %s: Camera.setSecondCamera - second camera initialized %s", get_ident(), cls.camNum2, ) cfg.serverConfig.isLiveStream2 = False except RuntimeError as e: logger.error( "Thread %s: Camera.setSecondCamera - Error %s", get_ident(), e ) if not sc.errorc2: sc.errorc2 = "Error while initializing camera: " + str(e) sc.errorc22 = "Probably another process is using the camera." if secondCamIsUsb == False: sc.errorc2Source = "Picamera2" else: sc.errorc2Source = "CV2" except Exception as e: logger.error( "Thread %s: Camera.setSecondCamera - Error %s", get_ident(), e ) if not sc.errorc2: sc.errorc2 = "Error while initializing camera: " + str(e) if secondCamIsUsb == False: sc.errorc2Source = "Picamera2" else: sc.errorc2Source = "CV2" cls.setStreamingConfigs() logger.debug( "Thread %s: Camera.setSecondCamera - second camera set to %s", get_ident(), cls.camNum2, ) cameraPhotoSubPath = "photos/" + "camera_" + str(camNum2) fp = sc.photoRoot + "/" + cameraPhotoSubPath if not os.path.exists(fp): os.makedirs(fp) logger.debug( "Thread %s: Camera.setSecondCamera - Photo directory created %s", get_ident(), fp, ) @classmethod def setStreamingConfigs(cls): """Set the configuration for streaming which will be used when cameras are switched""" logger.debug("Thread %s: Camera.setStreamingConfigs", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig trc = cfg.triggerConfig strc = cfg.streamingCfg logger.debug( "Thread %s: Camera.setStreamingConfigs - current streamingCfg: %s", get_ident(), strc, ) # For active camera cn = str(sc.activeCamera) logger.debug( "Thread %s: Camera.setStreamingConfigs - for active camera %s", get_ident(), cn, ) resetActive = False if cn in strc: scfg = strc[cn] logger.debug( "Thread %s: Camera.setStreamingConfigs - found in strc. scfg: %s", get_ident(), scfg, ) if "camerainfo" in scfg: if scfg["camerainfo"] != sc.activeCameraInfo: resetActive = True logger.debug( "Thread %s: Camera.setStreamingConfigs - Resetting active camera config for camera %s", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was reset due to camera model change" ) else: # Check whether camera properties are available if not "cameraproperties" in scfg: logger.debug( "Thread %s: Camera.setStreamingConfigs - StreamingConfig for active camera %s does not have camera properties", get_ident(), cn, ) scfg["cameraproperties"] = copy.deepcopy(cfg.cameraProperties) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was extended with camera properties" ) # For USB cameras, check the status # The streaming config needs to be reset if it was initially created for the second camera # And has never been updated for the active camera if cls.camIsUsb == True: if "is_ok" in scfg: isOK = scfg["is_ok"] if isOK == False: resetActive = True logger.debug( "Thread %s: Camera.setStreamingConfigs - Resetting active camera config for camera %s due to is_OK=False", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was reset due to camera status change" ) else: resetActive = True logger.debug( "Thread %s: Camera.setStreamingConfigs - Resetting active camera config for camera %s due to missing is_OK", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was reset due to camera status change" ) if not "triggercamera" in scfg: resetActive = True logger.debug( "Thread %s: Camera.setStreamingConfigs - StreamingConfig for active camera %s does not have triggercamera", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was extended with trigger camera settings" ) if not "aiconfig" in scfg: resetActive = True logger.debug( "Thread %s: Camera.setStreamingConfigs - StreamingConfig for active camera %s does not have aiconfig", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {sc.activeCameraInfo} was extended with AI settings" ) else: logger.debug( "Thread %s: Camera.setStreamingConfigs - not found in strc.", get_ident(), ) resetActive = True else: resetActive = True if resetActive == True: logger.debug( "Thread %s: Camera.setStreamingConfigs - Active camera strc must be reset", get_ident(), ) scfg = {} scfg["camnum"] = sc.activeCamera scfg["is_ok"] = True scfg["camerainfo"] = copy.copy(sc.activeCameraInfo) scfg["cameraproperties"] = copy.deepcopy(cfg.cameraProperties) scfg["hasfocus"] = cfg.cameraProperties.hasFocus if cls.camIsUsb == False: scfg["tuningconfig"] = copy.deepcopy(cfg.tuningConfig) scfg["liveconfig"] = copy.deepcopy(cfg.liveViewConfig) scfg["photoconfig"] = copy.deepcopy(cfg.photoConfig) scfg["rawconfig"] = copy.deepcopy(cfg.rawConfig) scfg["videoconfig"] = copy.deepcopy(cfg.videoConfig) scfg["controls"] = copy.deepcopy(cfg.controls) scfg["triggercamera"] = copy.deepcopy(trc.cameraSettings) scfg["aiconfig"] = copy.deepcopy(cfg.aiConfig) strc[cn] = scfg logger.debug( "Thread %s: Camera.setStreamingConfigs - created entry for active camera %s", get_ident(), cn, ) else: if cn in strc: scfg = strc[cn] if not "camnum" in scfg: scfg["camnum"] = sc.activeCamera strc[cn] = scfg # Reset streaming config invalidation flag cfg.streamingCfgInvalid = False # For second camera if cls.cam2: cn = str(cls.camNum2) resetSecond = False if cn in strc: scfg = strc[cn] if "camerainfo" in scfg: model = "" for cfgCam in cfg.cameras: if cfgCam.num == cls.camNum2: model = cfgCam.model break newCamInfo = "Camera " + str(cls.camNum2) + " (" + model + ")" if scfg["camerainfo"] != newCamInfo: resetSecond = True logger.debug( "Thread %s: Camera.setStreamingConfigs - Resetting second camera config for camera %s", get_ident(), cn, ) sc.unsavedChanges = True sc.addChangeLogEntry( f"Streaming configuration for {newCamInfo} was reset due to camera model change" ) else: resetSecond = True else: resetSecond = True if resetSecond == True: scfg = {} model = "" for cfgCam in cfg.cameras: if cfgCam.num == cls.camNum2: model = cfgCam.model break scfg["camnum"] = cls.camNum2 scfg["camerainfo"] = "Camera " + cn + " (" + model + ")" if cls.cam2IsUsb == False: scfg["is_ok"] = True camPprops = cls.cam2.camera_properties hasFocus = "AfMode" in cls.cam2.camera_controls pixelArraySize = copy.copy(camPprops["PixelArraySize"]) sensorModes = copy.copy(cls.cam2.sensor_modes) maxMode = len(sensorModes) - 1 liveViewConfig = CameraConfig() liveViewConfig.id = "LIVE" liveViewConfig.use_case = "Live view" liveViewConfig.stream = "lores" liveViewConfig.buffer_count = 6 liveViewConfig.encode = "main" liveViewConfig.controls["FrameDurationLimits"] = (33333, 33333) if liveViewConfig.stream_size is None: sizeWidth = 640 sizeHeight = int( sizeWidth * pixelArraySize[1] / pixelArraySize[0] ) if (sizeHeight % 2) != 0: sizeHeight += 1 liveViewConfig.stream_size = (sizeWidth, sizeHeight) liveViewConfig.stream_size_align = False if ( sensorModes[0]["size"][0] == sizeWidth and sensorModes[0]["size"][1] == sizeHeight ): liveViewConfig.sensor_mode = "0" else: liveViewConfig.sensor_mode = "custom" videoConfig = CameraConfig() videoConfig.id = "VIDO" videoConfig.use_case = "Video" videoConfig.buffer_count = 6 videoConfig.encode = "main" videoConfig.controls["FrameDurationLimits"] = (33333, 33333) if ( cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 1") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 2") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 3") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 4") ): videoConfig.sensor_mode = 0 videoConfig.stream_size = sensorModes[0]["size"] videoConfig.buffer_count = 2 liveViewConfig.buffer_count = 2 else: videoConfig.sensor_mode = str(maxMode) videoConfig.stream_size = sensorModes[maxMode]["size"] photoConfig = CameraConfig() photoConfig.id = "FOTO" photoConfig.use_case = "Photo" photoConfig.buffer_count = 1 photoConfig.encode = "main" photoConfig.controls["FrameDurationLimits"] = (100, 1000000000) if ( cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 1") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 2") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 3") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 4") ): photoConfig.sensor_mode = 0 photoConfig.stream_size = sensorModes[0]["size"] else: photoConfig.sensor_mode = str(maxMode) photoConfig.stream_size = sensorModes[maxMode]["size"] rawConfig = CameraConfig() rawConfig.id = "PRAW" rawConfig.use_case = "Raw Photo" rawConfig.buffer_count = 1 rawConfig.encode = "raw" rawConfig.controls["FrameDurationLimits"] = (100, 1000000000) rawConfig.sensor_mode = str(maxMode) rawConfig.stream_size = sensorModes[maxMode]["size"] scfg["hasfocus"] = hasFocus scfg["tuningconfig"] = TuningConfig() scfg["liveconfig"] = liveViewConfig scfg["photoconfig"] = photoConfig scfg["rawconfig"] = rawConfig scfg["videoconfig"] = videoConfig scfg["controls"] = copy.deepcopy(cfg.controls) else: scfg["is_ok"] = False hasFocus = False pixelArraySize = None sensorModes = [] maxMode = len(sensorModes) - 1 liveViewConfig = CameraConfig() liveViewConfig.id = "LIVE" liveViewConfig.use_case = "Live view" liveViewConfig.stream = "main" liveViewConfig.colour_space = "sRGB" liveViewConfig.buffer_count = 1 liveViewConfig.queue = False liveViewConfig.encode = "main" liveViewConfig.controls["FrameDurationLimits"] = (33333, 33333) sizeWidth = cfg.sensorModes[0].size[0] sizeHeight = cfg.sensorModes[0].size[1] liveViewConfig.stream_size = (sizeWidth, sizeHeight) liveViewConfig.stream_size_align = False liveViewConfig.sensor_mode = "0" liveViewConfig.format = "YUYV" videoConfig = CameraConfig() videoConfig.id = "VIDO" videoConfig.use_case = "Video" videoConfig.colour_space = "sRGB" videoConfig.buffer_count = 1 videoConfig.queue = False videoConfig.encode = "main" videoConfig.controls["FrameDurationLimits"] = (33333, 33333) videoConfig.stream = "main" videoConfig.sensor_mode = 0 videoConfig.stream_size = (640, 480) videoConfig.stream_size_align = False videoConfig.format = "YUYV" photoConfig = CameraConfig() photoConfig.id = "FOTO" photoConfig.use_case = "Photo" photoConfig.colour_space = "sRGB" photoConfig.buffer_count = 1 photoConfig.queue = False photoConfig.encode = "main" photoConfig.controls["FrameDurationLimits"] = (100, 1000000000) photoConfig.stream = "main" photoConfig.sensor_mode = 0 photoConfig.stream_size = (640, 480) photoConfig.stream_size_align = False photoConfig.format = "YUYV" rawConfig = CameraConfig() rawConfig.id = "PRAW" rawConfig.use_case = "Raw Photo" rawConfig.colour_space = "sRGB" rawConfig.buffer_count = 1 rawConfig.queue = False rawConfig.encode = "raw" rawConfig.controls["FrameDurationLimits"] = (100, 1000000000) rawConfig.stream = "main" rawConfig.sensor_mode = 0 rawConfig.stream_size = (640, 480) rawConfig.stream_size_align = False rawConfig.format = "tiff" scfg["hasfocus"] = hasFocus scfg["liveconfig"] = liveViewConfig scfg["photoconfig"] = photoConfig scfg["rawconfig"] = rawConfig scfg["videoconfig"] = videoConfig scfg["controls"] = copy.deepcopy(cfg.controls) strc[cn] = scfg logger.debug( "Thread %s: Camera.setStreamingConfigs - created entry for second camera %s", get_ident(), cn, ) else: if cn in strc: scfg = strc[cn] if not "camnum" in scfg: scfg["camnum"] = cls.camNum2 strc[cn] = scfg @classmethod def restoreConfigFromStreamingConfig(cls): """Restore active configuration and controls from a previously saved streaming config""" cfg = CameraCfg() sc = cfg.serverConfig trc = cfg.triggerConfig strc = cfg.streamingCfg logger.debug("Thread %s: Camera.restoreConfigFromStreamingConfig for camera %s", get_ident(), sc.activeCamera) # For active camera cn = str(sc.activeCamera) if cn in strc: scfg = strc[cn] if sc.activeCameraIsUsb: if "is_ok" in scfg: isOK = scfg["is_ok"] if isOK == False: logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - Streaming config for active camera %s is not OK, skipping restore", get_ident(), cn, ) return if "liveconfig" in scfg: cfg.liveViewConfig = copy.deepcopy(scfg["liveconfig"]) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored liveViewConfig from streaming config %s", get_ident(), cn, ) if "photoconfig" in scfg: cfg.photoConfig = copy.deepcopy(scfg["photoconfig"]) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored photoconfig from streaming config %s", get_ident(), cn, ) if "rawconfig" in scfg: cfg.rawConfig = copy.deepcopy(scfg["rawconfig"]) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored rawconfig from streaming config %s", get_ident(), cn, ) if "videoconfig" in scfg: cfg.videoConfig = copy.deepcopy(scfg["videoconfig"]) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored videoconfig from streaming config %s", get_ident(), cn, ) if "controls" in scfg: cfg.controls = copy.deepcopy(scfg["controls"]) # Camera.resetScalerCropRequested = False logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored controls from streaming config %s", get_ident(), cn, ) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - cfgCtrls=%s", get_ident(), scfg["controls"].__dict__, ) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - cfg.controls=%s", get_ident(), cfg.controls.__dict__, ) if "triggercamera" in scfg: trc.cameraSettings = copy.deepcopy(scfg["triggercamera"]) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - Trigger camera settings restored from streaming config for camera %s", get_ident(), cn, ) else: trc.setCameraSettingsToDefault() logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - Trigger camera settings set to defaults for camera %s", get_ident(), cn, ) logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - restored config and controls from streaming config %s", get_ident(), cn, ) else: trc.setCameraSettingsToDefault() logger.debug( "Thread %s: Camera.restoreConfigFromStreamingConfig - Trigger camera settings set to defaults for camera %s", get_ident(), cn, ) @staticmethod def configure(cfg: CameraConfig, cfgPhoto: CameraConfig): """The function creates and configures a CameraConfiguration based on given configuration settings cfg. The fully configured configuration is returned """ logger.debug("Thread %s: Camera.configure", get_ident()) # We start configuration with a new blank CameraConfiguration object camCfg = CameraConfiguration() camCfg.use_case = cfg.use_case camCfg.transform = Transform( vflip=cfg.transform_vflip, hflip=cfg.transform_hflip ) camCfg.buffer_count = cfg.buffer_count cosp = cfg.colour_space if cosp == "sYCC": colourSpace = ColorSpace.Sycc() elif cosp == "Smpte170m": colourSpace = ColorSpace.Smpte170m() elif cosp == "Rec709": colourSpace = ColorSpace.Rec709() else: colourSpace = ColorSpace.Sycc() camCfg.colour_space = colourSpace camCfg.queue = cfg.queue camCfg.display = cfg.display camCfg.encode = cfg.encode # The mainStream is configured here from the photo configuration (e.g. jpg) # to allow for a jpeg in addition to a dng from the raw stream mainStream = StreamConfiguration() mainStream.format = cfgPhoto.format # However the size shall be that of the target configuration # so that the formats of both, jpg and dng are the same mainStream.size = cfg.stream_size stream = StreamConfiguration() stream.size = cfg.stream_size stream.format = cfg.format if cfg.stream == "main": camCfg.main = stream camCfg.lores = None camCfg.raw = None if cfg.stream == "lores": camCfg.main = mainStream camCfg.lores = stream camCfg.raw = None if cfg.stream == "raw": camCfg.main = mainStream camCfg.lores = None camCfg.raw = stream ctrls = cfg.controls if len(ctrls) == 0: raise ValueError("controls in camera configuration must not be empty") else: camCfg.controls = ctrls logger.debug( "Thread %s: Camera.configure: configuration completed", get_ident() ) # Automatically align the stream size, if selected if cfg.stream_size_align and cfg.sensor_mode == "custom": logger.debug( "Thread %s: Camera.configure: Aligning camera configuration. Old size: %s", get_ident(), cfg.stream_size, ) camCfg.align() logger.debug( "Thread %s: Camera.configure: Alignment successful. Adjusting stream size", get_ident(), ) cfg.stream_size = camCfg.size logger.debug( "Thread %s: Camera.configure: Stream size adjusted to %s", get_ident(), cfg.stream_size, ) return camCfg @staticmethod def requiresTimeForAutoAlgos() -> bool: """Check if the camera requires time for auto algorithms to settle Returns True, if the camera is a Pi 4 or Pi 5 """ logger.debug("Thread %s: Camera.requiresTimeForAutoAlgos", get_ident()) cfgCtrls = CameraCfg().controls res = False if cfgCtrls.include_aeEnable and cfgCtrls.aeEnable == True: res = True if cfgCtrls.include_awbEnable and cfgCtrls.awbEnable == True: res = True if CameraCfg().cameraProperties.hasFocus == True: if cfgCtrls.include_afMode and cfgCtrls.afMode != 0: res = True return res @staticmethod def applyMappedControlToUsbCamera( ctrl: str, ctrls: dict, isBool: bool, usbCc: dict, camDev: str, ): """Apply a mapped control to a USB camera Values of the raspiCamSrv Control are mapped to USB camera control values. ctrl : The control to be applied ctrls : The controls to be applied isBool : Indicates if the control value is boolean usbCc : The USB camera controls mapping camDev : The camera device identifier """ logger.debug( "Thread %s: Camera.applyMappedControlToUsbCamera - ctrl: %s", get_ident(), ctrl ) if ctrl in ctrls and ctrls[ctrl] is not None: logger.debug("Thread %s: Camera.applyMappedControlToUsbCamera - applying: %s ", get_ident(), ctrl) if isBool == True: if ctrls[ctrl] == True: cfgVal = "1" else: cfgVal = "0" else: cfgVal = str(ctrls[ctrl]) if "mapping" in usbCc[ctrl]: mapping = usbCc[ctrl]["mapping"] if cfgVal in mapping: camVal = mapping[cfgVal] ctrlName = usbCc[ctrl]["ctrlName"] try: subprocess.run(["v4l2-ctl", "-d", camDev, f"--set-ctrl={ctrlName}={camVal}"]) logger.debug( "Thread %s: Camera.applyMappedControlToUsbCamera - camDev: %s set ctrl %s to %s", get_ident(), camDev, ctrlName, camVal, ) except Exception as e: logger.error( "Camera.applyMappedControlToUsbCamera - camDev: %s Error setting %s to %s: %s", camDev, ctrlName, camVal, e, ) else: logger.debug( "Thread %s: Camera.applyMappedControlToUsbCamera - ctrl: %s not applied (not in ctrls)", get_ident(), ctrl, ) @staticmethod def applyDirectControlToUsbCamera( ctrl: str, ctrls: dict, usbCc: dict, camDev: str, ): """Apply a control directly to a USB camera Values of the raspiCamSrv Control are scaled to USB camera control values. ctrl : The control to be applied ctrls : The controls to be applied usbCc : The USB camera controls mapping camDev : The camera device identifier """ logger.debug( "Thread %s: Camera.applyDirectControlToUsbCamera - ctrl: %s", get_ident(), ctrl ) if ctrl in ctrls and ctrls[ctrl] is not None: logger.debug("Thread %s: Camera.applyDirectControlToUsbCamera - applying: %s ", get_ident(), ctrl) camVal = ctrls[ctrl] if ctrl == "LensPosition": camVal = 1.0 / camVal if usbCc[ctrl]["type"] == "int": camVal = int(camVal) ctrlName = usbCc[ctrl]["ctrlName"] try: subprocess.run(["v4l2-ctl", "-d", camDev, f"--set-ctrl={ctrlName}={camVal}"]) logger.debug( "Thread %s: Camera.applyScaledControlToUsbCamera - camDev: %s set ctrl %s to %s", get_ident(), camDev, ctrlName, camVal, ) except Exception as e: logger.error( "Camera.applyScaledControlToUsbCamera - camDev: %s Error setting %s to %s: %s", camDev, ctrlName, camVal, e, ) else: logger.debug( "Thread %s: Camera.applyScaledControlToUsbCamera - ctrl: %s not applied (not in ctrls)", get_ident(), ctrl, ) @staticmethod def applyControlsToUsbCamera( ctrls: dict, toCam2: bool = False ): """Apply controls to a USB camera This method is called before images are captured from a USB camera. It can be used to set camera properties, e.g. via v4l2-ctl commands. ctrls : The controls to be applied toCam2 : If true, controls are set for the second camera """ logger.debug( "Thread %s: Camera.applyControlsToUsbCamera - toCam2: %s ctrls: %s", get_ident(), toCam2, ctrls ) cfg = CameraCfg() cc = cfg.controls usbCc = cc.usbCamControls if toCam2 == False: camNum = Camera.camNum camDev = Camera.camUsbDev else: camNum = Camera.camNum2 camDev = Camera.cam2UsbDev logger.debug("Thread %s: Camera.applyControlsToUsbCamera - camNum: %s camDev: %s", get_ident(), camNum, camDev) # Auto White Balance Camera.applyMappedControlToUsbCamera("AwbEnable", ctrls, True, usbCc, camDev) # Auto White Balance Mode Camera.applyMappedControlToUsbCamera("AwbMode", ctrls, False, usbCc, camDev) # Sharpness Camera.applyDirectControlToUsbCamera("Sharpness", ctrls, usbCc, camDev) # Brightness Camera.applyDirectControlToUsbCamera("Brightness", ctrls, usbCc, camDev) # Contrast Camera.applyDirectControlToUsbCamera("Contrast", ctrls, usbCc, camDev) # Saturation Camera.applyDirectControlToUsbCamera("Saturation", ctrls, usbCc, camDev) # AfMode Camera.applyMappedControlToUsbCamera("AfMode", ctrls, False, usbCc, camDev) # LensPosition Camera.applyDirectControlToUsbCamera("LensPosition", ctrls, usbCc, camDev) @staticmethod def usbFrameApplyControls( frame, log = False, exceptCtrl=None, exceptValue=None, toCam2=None ): """Apply the currently selected camera controls to a frame captured from a USB camera frame : Frame captured from the USB camera log : If true, log debug information (to prevent logging for each frame in video mode) exceptCtrl : Exception control. Optionally, one exceptional control can be specified If specified, the exceptValue will replace the value fom CameraCfg().controls Currently supported: - ExposureTime - AnalogueGain - FocalDistance -> LensPosition = 1 / FocalDistance toCam2 : If true, controls are set for the second camera with control data from streamingCfg Returns : The frame with applied controls """ if toCam2 is None: toCam2 = False if log: logger.debug( "Thread %s: Camera.usbFrameApplyControls - toCam2: %s", get_ident(), toCam2 ) cfg = CameraCfg() if toCam2 is False: cfgCtrls = cfg.controls else: cfgCtrls = cfg.streamingCfg[str(Camera.camNum2)]["controls"] if log: logger.debug( "Thread %s: Camera.usbFrameApplyControls - cfgCtrls=%s", get_ident(), cfgCtrls.__dict__, ) newFrame = frame ctrls = {} cnt = 0 # Apply selected controls # Scaler crop if cfgCtrls.include_scalerCrop: ctrls["ScalerCrop"] = cfgCtrls.scalerCrop cnt += 1 hFrame, wFrame = frame.shape[:2] if log: logger.debug( "Thread %s: Camera.usbFrameApplyControls - Frame size: width=%s height=%s", get_ident(), wFrame, hFrame, ) X, Y, W, H = Camera.getUsbScalerCrop(wFrame, hFrame, log=log, forCam2=toCam2) x, y, w, h = cfgCtrls.scalerCrop if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - ScalerCrop Frame is %s", get_ident(), (X, Y, W, H)) logger.debug("Thread %s: Camera.usbFrameApplyControls - Cropping to %s", get_ident(), (x, y, w, h)) xc = x + int(w/2) yc = y + int(h/2) if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - Crop center is %s", get_ident(), (xc, yc)) aspectRatioFrame = W / H aspectRatioCrop = w / h if aspectRatioFrame > aspectRatioCrop: # Frame is wider than crop aspect ratio -> increase width wNew = int(h * aspectRatioFrame) hNew = h if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - Frame is wider than crop aspect ratio. New size is %s", get_ident(), (wNew, hNew)) else: # Frame is taller than crop aspect ratio -> increase height wNew = w hNew = int(w / aspectRatioFrame) if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - Frame is taller than crop aspect ratio. New size is %s", get_ident(), (wNew, hNew)) if wNew > W: wNew = W if hNew > H: hNew = H scaleToFrame = W / wFrame wNew = int(wNew / scaleToFrame) hNew = int(hNew / scaleToFrame) xc = int((xc - X) / scaleToFrame) yc = int((yc - Y) / scaleToFrame) x1 = xc - int(wNew / 2) if x1 < 0: x1 = 0 y1 = yc - int(hNew / 2) if y1 < 0: y1 = 0 x2 = x1 + wNew if x2 > wFrame: x2 = wFrame y2 = y1 + hNew if y2 > hFrame: y2 = hFrame if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - Cropping coordinates are x1=%s y1=%s x2=%s y2=%s", get_ident(), x1, y1, x2, y2) cropped = frame[y1:y2, x1:x2] newFrame = cv2.resize(cropped, (wFrame, hFrame), interpolation=cv2.INTER_LINEAR) if log: logger.debug("Thread %s: Camera.usbFrameApplyControls - Cropping and resizing done", get_ident()) return newFrame @staticmethod def applyControls( camCfg: CameraConfig, exceptCtrl=None, exceptValue=None, toCam2=None ): """Apply the currently selected camera controls camCfg : Configuration from which controls shall be taken with priority exceptCtrl : Exception control. Optionally, one exceptional control can be specified If specified, the exceptValue will replace the value fom CameraCfg().controls Currently supported: - ExposureTime - AnalogueGain - FocalDistance -> LensPosition = 1 / FocalDistance toCam2 : If true, controls are set for the second camera with control data from streamingCfg """ logger.debug( "Thread %s: Camera.applyControls - toCam2: %s", get_ident(), toCam2 ) logger.debug( "Thread %s: Camera.applyControls - camCfg.controls=%s", get_ident(), camCfg.controls, ) cfg = CameraCfg() if toCam2 is None: cfgCtrls = cfg.controls else: cfgCtrls = cfg.streamingCfg[str(Camera.camNum2)]["controls"] logger.debug( "Thread %s: Camera.applyControls - cfgCtrls=%s", get_ident(), cfgCtrls.__dict__, ) # Initialize controls dict with controls included in configuration # ctrls = copy.deepcopy(camCfg.controls) ctrls = {} logger.debug("Thread %s: Camera.applyControls - ctrls=%s", get_ident(), ctrls) cnt = 0 # Apply selected controls with precedence of controls from configuration # Auto exposure controls if cfgCtrls.include_aeEnable and "AeEnable" not in camCfg.controls: ctrls["AeEnable"] = cfgCtrls.aeEnable cnt += 1 if cfgCtrls.include_aeMeteringMode and "AeMeteringMode" not in camCfg.controls: ctrls["AeMeteringMode"] = cfgCtrls.aeMeteringMode cnt += 1 if cfgCtrls.include_aeExposureMode and "AeExposureMode" not in camCfg.controls: ctrls["AeExposureMode"] = cfgCtrls.aeExposureMode cnt += 1 if ( cfgCtrls.include_aeConstraintMode and "AeConstraintMode" not in camCfg.controls ): ctrls["AeConstraintMode"] = cfgCtrls.aeConstraintMode cnt += 1 if cfgCtrls.include_aeFlickerMode and "AeFlickerMode" not in camCfg.controls: ctrls["AeFlickerMode"] = cfgCtrls.aeFlickerMode cnt += 1 if ( cfgCtrls.include_aeFlickerPeriod and "AeFlickerPeriod" not in camCfg.controls ): ctrls["AeFlickerPeriod"] = cfgCtrls.aeFlickerPeriod cnt += 1 # Exposure controls if cfgCtrls.include_exposureTime and "ExposureTime" not in camCfg.controls: ctrls["ExposureTime"] = cfgCtrls.exposureTime cnt += 1 if cfgCtrls.include_exposureValue and "ExposureValue" not in camCfg.controls: ctrls["ExposureValue"] = cfgCtrls.exposureValue cnt += 1 if cfgCtrls.include_analogueGain and "AnalogueGain" not in camCfg.controls: ctrls["AnalogueGain"] = cfgCtrls.analogueGain cnt += 1 if cfgCtrls.include_colourGains and "ColourGains" not in camCfg.controls: ctrls["ColourGains"] = (cfgCtrls.colourGainRed, cfgCtrls.colourGainBlue) cnt += 1 if ( cfgCtrls.include_frameDurationLimits and "FrameDurationLimits" not in camCfg.controls ): ctrls["FrameDurationLimits"] = ( cfgCtrls.frameDurationLimitMax, cfgCtrls.frameDurationLimitMin, ) cnt += 1 if cfgCtrls.include_hdrMode and "HdrMode" not in camCfg.controls: ctrls["HdrMode"] = cfgCtrls.hdrMode cnt += 1 # Image controls if cfgCtrls.include_awbEnable and "AwbEnable" not in camCfg.controls: ctrls["AwbEnable"] = cfgCtrls.awbEnable cnt += 1 if cfgCtrls.include_awbMode and "AwbMode" not in camCfg.controls: ctrls["AwbMode"] = cfgCtrls.awbMode cnt += 1 if ( cfgCtrls.include_noiseReductionMode and "NoiseReductionMode" not in camCfg.controls ): ctrls["NoiseReductionMode"] = cfgCtrls.noiseReductionMode cnt += 1 if cfgCtrls.include_sharpness and "Sharpness" not in camCfg.controls: ctrls["Sharpness"] = cfgCtrls.sharpness cnt += 1 if cfgCtrls.include_contrast and "Contrast" not in camCfg.controls: ctrls["Contrast"] = cfgCtrls.contrast cnt += 1 if cfgCtrls.include_saturation and "Saturation" not in camCfg.controls: ctrls["Saturation"] = cfgCtrls.saturation cnt += 1 if cfgCtrls.include_brightness and "Brightness" not in camCfg.controls: ctrls["Brightness"] = cfgCtrls.brightness cnt += 1 # Scaler crop logger.debug( "Thread %s: Camera.applyControls - cfg.liveViewConfig.controls=%s", get_ident(), cfg.liveViewConfig.controls, ) logger.debug( "Thread %s: Camera.applyControls - include_scalerCrop=%s", get_ident(), cfgCtrls.include_scalerCrop, ) if cfgCtrls.include_scalerCrop and "ScalerCrop" not in camCfg.controls: ctrls["ScalerCrop"] = cfgCtrls.scalerCrop cnt += 1 logger.debug( "Thread %s: Camera.applyControls - cfg.liveViewConfig.controls=%s", get_ident(), cfg.liveViewConfig.controls, ) # Focus if toCam2 is None: hasFocus = cfg.cameraProperties.hasFocus else: hasFocus = cfg.streamingCfg[str(Camera.camNum2)]["hasfocus"] if hasFocus: if cfgCtrls.include_afMode and "AfMode" not in camCfg.controls: ctrls["AfMode"] = cfgCtrls.afMode cnt += 1 if cfgCtrls.include_lensPosition and "LensPosition" not in camCfg.controls: ctrls["LensPosition"] = cfgCtrls.lensPosition cnt += 1 if cfgCtrls.include_afMetering and "AfMetering" not in camCfg.controls: ctrls["AfMetering"] = cfgCtrls.afMetering cnt += 1 if cfgCtrls.include_afPause and "AfPause" not in camCfg.controls: ctrls["AfPause"] = cfgCtrls.afPause cnt += 1 if cfgCtrls.include_afRange and "AfRange" not in camCfg.controls: ctrls["AfRange"] = cfgCtrls.afRange cnt += 1 if cfgCtrls.include_afSpeed and "AfSpeed" not in camCfg.controls: ctrls["AfSpeed"] = cfgCtrls.afSpeed cnt += 1 if cfgCtrls.include_afTrigger and "AfTrigger" not in camCfg.controls: ctrls["AfTrigger"] = cfgCtrls.afTrigger cnt += 1 if cfgCtrls.include_afWindows and "AfWindows" not in camCfg.controls: ctrls["AfWindows"] = cfgCtrls.afWindows cnt += 1 # Consider exception control if exceptCtrl: if exceptCtrl == "FocalDistance": if not "LensPosition" in camCfg.controls: cnt += 1 ctrls["LensPosition"] = 1.0 / exceptValue # Consider exception control if exceptCtrl: if exceptCtrl != "FocalDistance": if not exceptCtrl in camCfg.controls: cnt += 1 if exceptCtrl == "ExposureTime": ctrls[exceptCtrl] = int(exceptValue) else: ctrls[exceptCtrl] = exceptValue logger.debug( "Thread %s: Camera.applyControls - Applying %s controls", get_ident(), cnt ) logger.debug("Thread %s: Camera.applyControls - ctrls=%s", get_ident(), ctrls) if toCam2 is None: if Camera.camIsUsb == False: camCtrls = Controls(Camera.cam) prgLogger.debug("camCtrls = Controls(picam2)") prgLogger.debug("ctrls = %s", ctrls) camCtrls.set_controls(ctrls) prgLogger.debug("camCtrls.set_controls(ctrls)") Camera.cam.controls = camCtrls # Camera.cam.controls.set_controls(ctrls) prgLogger.debug("picam2.controls = camCtrls") logger.debug( "Thread %s: Camera.applyControls - id(Camera)=%s id(Camera.cam)=%s id(Camera.cam.controls)=%s", get_ident(), id(Camera), id(Camera.cam), id(Camera.cam.controls), ) logger.debug( "Thread %s: Camera.applyControls - Camera.cam.controls=%s", get_ident(), Camera.cam.controls, ) ai = cfg.aiConfig if ai.enable == True: # Register the callback to parse and draw classification results for AI camera if ai.task == "classification": if Camera.cam_imx500.network_intrinsics.preserve_aspect_ratio: Camera.cam_imx500.set_auto_aspect_ratio() Camera.cam.pre_callback = Camera.parse_and_draw_classification_results elif ai.task == "object detection": if Camera.cam_imx500.network_intrinsics.preserve_aspect_ratio: Camera.cam_imx500.set_auto_aspect_ratio() Camera.cam.pre_callback = Camera.draw_detections elif ai.task == "pose estimation": Camera.set_drawer() Camera.cam_imx500.set_auto_aspect_ratio() Camera.cam.pre_callback = Camera.picamera2_pre_callback elif ai.task == "segmentation": Camera.cam.pre_callback = Camera.create_and_draw_masks logger.debug( "Thread %s: Camera.applyControls - Registered pre_callback for AI camera", get_ident(), ) else: camCtrls = ctrls Camera.applyControlsToUsbCamera(ctrls) else: if Camera.cam2IsUsb == False: camCtrls = Controls(Camera.cam2) camCtrls.set_controls(ctrls) Camera.cam2.controls = camCtrls logger.debug( "Thread %s: Camera.applyControls - Camera.cam2.controls=%s", get_ident(), Camera.cam2.controls, ) scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] if ai.enable == True: # Register the callback to parse and draw classification results for AI camera if ai.task == "classification": if Camera.cam2_imx500.network_intrinsics.preserve_aspect_ratio: Camera.cam2_imx500.set_auto_aspect_ratio() Camera.cam2.pre_callback = Camera.cam2_parse_and_draw_classification_results elif ai.task == "object detection": if Camera.cam2_imx500.network_intrinsics.preserve_aspect_ratio: Camera.cam2_imx500.set_auto_aspect_ratio() Camera.cam2.pre_callback = Camera.cam2_draw_detections elif ai.task == "pose estimation": Camera.cam2_set_drawer() Camera.cam2_imx500.set_auto_aspect_ratio() Camera.cam2.pre_callback = Camera.cam2_picamera2_pre_callback elif ai.task == "segmentation": Camera.cam2.pre_callback = Camera.cam2_create_and_draw_masks logger.debug( "Thread %s: Camera.applyControls - Registered pre_callback for AI camera", get_ident(), ) else: camCtrls = ctrls Camera.applyControlsToUsbCamera(ctrls, toCam2=True) return camCtrls @staticmethod def applyControlsForAfCycle(camCfg: CameraConfig): """Apply camera controls required for AF cycle""" logger.debug("Thread %s: Camera.applyControlsForAfCycle", get_ident()) cfg = CameraCfg() cfgCtrls = cfg.controls # Initialize controls dict with controls included in configuration # ctrls = copy.deepcopy(camCfg.controls) ctrls = {} cnt = 0 # Focus if cfg.cameraProperties.hasFocus: if cfgCtrls.include_afMode and "AfMode" not in camCfg.controls: ctrls["AfMode"] = cfgCtrls.afMode cnt += 1 if cfgCtrls.include_afMetering and "AfMetering" not in camCfg.controls: ctrls["AfMetering"] = cfgCtrls.afMetering cnt += 1 if cfgCtrls.include_afPause and "AfPause" not in camCfg.controls: ctrls["AfPause"] = cfgCtrls.afPause cnt += 1 if cfgCtrls.include_afRange and "AfRange" not in camCfg.controls: ctrls["AfRange"] = cfgCtrls.afRange cnt += 1 if cfgCtrls.include_afSpeed and "AfSpeed" not in camCfg.controls: ctrls["AfSpeed"] = cfgCtrls.afSpeed cnt += 1 if cfgCtrls.include_afWindows and "AfWindows" not in camCfg.controls: ctrls["AfWindows"] = cfgCtrls.afWindows cnt += 1 logger.debug( "Thread %s: Camera.applyControlsForAfCycle - Applying %s controls", get_ident(), cnt, ) camCtrls = Controls(Camera.cam) prgLogger.debug("camCtrls = Controls(picam2)") prgLogger.debug("ctrls = %s", ctrls) camCtrls.set_controls(ctrls) prgLogger.debug("camCtrls.set_controls(ctrls)") Camera.cam.controls = camCtrls prgLogger.debug("picam2.controls = camCtrls") logger.debug( "Thread %s: Camera.applyControlsForAfCycle - Camera.cam.controls=%s", get_ident(), Camera.cam.controls, ) @staticmethod def applyControlsForLivestream(wait: float = None): """Apply active controls if livestream is active""" logger.debug("Thread %s: Camera.applyControlsForLivestream", get_ident()) if Camera.thread: if wait: time.sleep(wait) Camera.applyControls(Camera.ctrl.configuration) if Camera.camIsUsb: Camera.logUsbFrameApplyControls = True logger.debug( "Thread %s: Camera.applyControlsForLivestream - Controlls applied", get_ident(), ) @staticmethod def stopCameraSystem(): logger.debug("Thread %s: Camera.stopCameraSystem", get_ident()) logger.debug( "Thread %s: Camera.stopCameraSystem: Stopping Live view thread", get_ident() ) Camera.stopRequested = True if Camera.thread: cnt = 0 while Camera.thread: time.sleep(0.01) cnt += 1 if cnt > 200: break if Camera.thread: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread did not stop within 2 sec", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread successfully stopped", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread was not active", get_ident(), ) Camera.stopRequested = False logger.debug( "Thread %s: Camera.stopCameraSystem: Stopping Video thread", get_ident() ) Camera.stopVideoRequested = True if Camera.videoThread: cnt = 0 while Camera.videoThread: time.sleep(0.01) cnt += 1 if cnt > 200: break if Camera.videoThread: logger.debug( "Thread %s: Camera.stopCameraSystem: Video thread did not stop within 2 sec", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Video thread successfully stopped", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Video thread was not active", get_ident(), ) Camera.stopVideoRequested = False Camera.videoDuration = 0 logger.debug( "Thread %s: Camera.stopCameraSystem: Stopping Photoseries thread", get_ident(), ) Camera.stopPhotoSeriesRequested = True if Camera.photoSeriesThread: cnt = 0 while Camera.photoSeriesThread: time.sleep(0.01) cnt += 1 if cnt > 500: break if Camera.photoSeriesThread: logger.debug( "Thread %s: Camera.stopCameraSystem: Photoseries thread did not stop within 5 sec", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Photoseries thread successfully stopped", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Photoseries thread was not active", get_ident(), ) Camera.stopPhotoSeriesRequested = False if Camera.ctrl: Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) if Camera.cam2: Camera.stopRequested2 = True if Camera.thread2: cnt = 0 while Camera.thread2: time.sleep(0.01) cnt += 1 if cnt > 200: break if Camera.thread2: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread 2 did not stop within 2 sec", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread 2 successfully stopped", get_ident(), ) else: logger.debug( "Thread %s: Camera.stopCameraSystem: Live view thread 2 was not active", get_ident(), ) Camera.stopRequested2 = False if Camera.ctrl2: Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) @classmethod def _thread(cls): """Camera background thread.""" logger.debug("Thread %s: Camera._thread", get_ident()) frames_iterator = None if Camera().when_streaming_1_starts: Camera().when_streaming_1_starts() ai = CameraCfg().aiConfig if ai.enable == True: if ai.task == "object detection": Camera.cam_imx500_last_results = None try: if Camera.camIsUsb == False: frames_iterator = cls.frames() else: frames_iterator = cls.framesUsb() logger.debug( "Thread %s: Camera._thread - frames_iterator instantiated", get_ident() ) for frame, frameRaw in frames_iterator: Camera.frame = frame Camera.frameRaw = frameRaw # logger.debug("Thread %s: Camera._thread - received frame from camera -> notifying clients", get_ident()) Camera.event.set() # send signal to clients Camera.camWaitingForFirstFrame = False time.sleep(0) if ai.enable == True: if ai.task == "object detection": Camera.cam_imx500_last_results = Camera.parse_detections(Camera.cam.capture_metadata()) Camera.threadLock.acquire() stop = False # Check whether stop is requested if Camera.stopRequested: frames_iterator.close() Camera.stopRequested = False stop = True logger.debug( "Thread %s: Camera._thread - Thread is requested to stop.", get_ident(), ) break # if there hasn't been any clients asking for frames in # the last 10 seconds then stop the thread if time.time() - Camera.last_access > 10: frames_iterator.close() stop = True logger.debug( "Thread %s: Camera._thread - Stopping camera thread due to inactivity.", get_ident(), ) break # Release lock if not stopping if stop == False: Camera.threadLock.release() except UsbCameraNoFrameReceivedError as fe: Camera.threadLock.acquire() if frames_iterator: frames_iterator.close() Camera.event.set() Camera.camWaitingForFirstFrame = False Camera.event.clear() except UsbCameraOpenError as ue: Camera.threadLock.acquire() if frames_iterator: frames_iterator.close() Camera.event.set() Camera.camWaitingForFirstFrame = False Camera.event.clear() except Exception as e: Camera.threadLock.acquire() logger.error("Thread %s: Camera._thread - Exception: %s", get_ident(), e) if frames_iterator: frames_iterator.close() Camera.event.set() Camera.camWaitingForFirstFrame = False Camera.event.clear() CameraCfg().serverConfig.error = "Error in live view: " + str(e) CameraCfg().serverConfig.error2 = ( "Probably, a different camera configuration can solve the problem." ) CameraCfg().serverConfig.errorSource = "Camera._thread" sc = CameraCfg().serverConfig closeCam = True if sc.isVideoRecording == True or cls.isVideoRecording() == True: closeCam = False logger.debug( "Thread %s: Camera._thread - isVideoRecording -> Camera not closing", get_ident(), ) if sc.isPhotoSeriesRecording == True: ser = Camera.photoSeries if ser: if ser.isExposureSeries == True or ser.isFocusStackingSeries == True: closeCam = False logger.debug( "Thread %s: Camera._thread - Exposure- or PhotoStack series -> Camera not closing", get_ident(), ) else: nextTime = ser.nextTime() curTime = datetime.datetime.now() timedif = nextTime - curTime timedifSec = timedif.total_seconds() if timedifSec < 60: logger.debug( "Thread %s: Camera._thread - Photo series next shot within 60 sec -> Camera not closing", get_ident(), ) closeCam = False if closeCam == True: logger.debug("Thread %s: Camera._thread - Closing camera", get_ident()) Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) sc.isLiveStream = False if Camera().when_streaming_1_stops: Camera().when_streaming_1_stops() if Camera.threadLock.locked(): Camera.threadLock.release() Camera.thread = None logger.debug("Thread %s: Camera._thread - Terminated", get_ident()) @classmethod def _thread2(cls): """Camera background thread 2.""" logger.debug("Thread %s: Camera._thread2", get_ident()) frames_iterator = None if Camera().when_streaming_2_starts: Camera().when_streaming_2_starts() cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() if ai.enable == True: if ai.task == "object detection": Camera.cam2_imx500_last_results = None try: if Camera.cam2IsUsb == False: frames_iterator = cls.frames2() else: frames_iterator = cls.frames2Usb() logger.debug( "Thread %s: Camera._thread2 - frames_iterator instantiated", get_ident() ) for frame, frameRaw in frames_iterator: Camera.frame2 = frame Camera.frame2Raw = frameRaw # logger.debug("Thread %s: Camera._thread2 - received frame from camera -> notifying clients", get_ident()) Camera.event2.set() # send signal to clients Camera.cam2WaitingForFirstFrame = False time.sleep(0) if ai.enable == True: if ai.task == "object detection": Camera.cam2_imx500_last_results = Camera.cam2_parse_detections(Camera.cam2.capture_metadata()) # Acquire lock to avoid clients accessing the stream while it is closing down # logger.debug("Thread %s: Camera._thread2 - About to acquire Lock: thread2Lock=%s.", get_ident(), Camera.thread2Lock.locked()) Camera.thread2Lock.acquire() # logger.debug("Thread %s: Camera._thread2 - Lock acquired: thread2Lock=%s.", get_ident(), Camera.thread2Lock.locked()) stop = False # Check whether stop is requested if Camera.stopRequested2: frames_iterator.close() Camera.stopRequested2 = False stop = True logger.debug( "Thread %s: Camera._thread2 - Thread is requested to stop.", get_ident(), ) break # if there hasn't been any clients asking for frames in # the last 10 seconds then stop the thread if time.time() - Camera.last_access2 > 10: frames_iterator.close() stop = True logger.debug( "Thread %s: Camera._thread2 - Stopping camera thread due to inactivity.", get_ident(), ) break # Release lock if not stopping if stop == False: Camera.thread2Lock.release() # logger.debug("Thread %s: Camera._thread2 - Lock released: thread2Lock=%s.", get_ident(), Camera.thread2Lock.locked()) except Exception as e: Camera.thread2Lock.acquire() logger.error("Thread %s: Camera._thread2 - Exception: %s", get_ident(), e) if frames_iterator: frames_iterator.close() Camera.event2.set() Camera.cam2WaitingForFirstFrame = False Camera.event2.clear() CameraCfg().serverConfig.errorc2 = "Error in camera 2 stream: " + str(e) CameraCfg().serverConfig.errorc22 = ( "Probably, a different camera configuration can solve the problem." ) CameraCfg().serverConfig.errorc2Source = "Camera._thread2" Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) CameraCfg().serverConfig.isLiveStream2 = False if Camera().when_streaming_2_stops: Camera().when_streaming_2_stops() if Camera.thread2Lock.locked(): Camera.thread2Lock.release() logger.debug( "Thread %s: Camera._thread2 - Lock released: thread2Lock=%s.", get_ident(), Camera.thread2Lock.locked(), ) Camera.thread2 = None logger.debug("Thread %s: Camera._thread2 - Exit.", get_ident()) @staticmethod def framesUsb(): logger.debug("Thread %s: Camera.framesUsb", get_ident()) srvCam = CameraCfg() imx500 = None try: cc, cr = Camera.ctrl.requestConfig(srvCam.photoConfig) if cc: # If the request for photoConfig caused a configuration change, restart with a new configuration Camera.ctrl.clearConfig() Camera.ctrl.requestConfig(srvCam.photoConfig) Camera.ctrl.requestConfig(srvCam.rawConfig, cfgPhoto=srvCam.photoConfig) Camera.ctrl.requestConfig(srvCam.liveViewConfig) Camera.cam, started, imx500 = Camera.ctrl.requestStart( Camera.cam, Camera.camNum, Camera.camIsUsb, Camera.camUsbDev, forActiveCamera=True, ) if not started: Camera.cam, excl, imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg=None, forLiveStream=True ) else: if Camera.cam.isOpened(): logger.debug( "Thread %s: Camera.framesUsb - camera started", get_ident() ) else: logger.error( "Thread %s: Camera.framesUsb - camera not opened", get_ident() ) raise RuntimeError("USB camera could not be opened") Camera.cam_imx500 = imx500 # Camera.applyControls(Camera.ctrl.configuration) # logger.debug("Thread %s: Camera.framesUsb - controls applied", get_ident()) # time.sleep(0.5) except Exception as e: logger.error("Thread %s: Camera.framesUsb - Exception: %s", get_ident(), e) raise cfg = Camera.ctrl.configuration hflip = cfg.transform.hflip vflip = cfg.transform.vflip logger.debug( "Thread %s: Camera.framesUsb - hflip=%s vflip=%s", get_ident(), hflip, vflip, ) gotScalerCropLiveView = False try: cnt = 0 Camera.logUsbFrameApplyControls = True while True: if Camera.cam.isOpened() == False: raise UsbCameraOpenError("USB camera not open during live view") success, frame = Camera.cam.read() if not success: time.sleep(0.01) cnt += 1 if cnt > 100: raise UsbCameraNoFrameReceivedError("No frame received from USB camera for live view") else: if gotScalerCropLiveView == False: # Get the live view scaler crop if Camera.resetScalerCropRequested == True: Camera.resetScalerCropUsb() metadata = Camera.getUsbCamMetadata(Camera.cam) srvCam.scalerCropLiveView = metadata["ScalerCrop"] gotScalerCropLiveView = True # logger.debug("Thread %s: Camera.framesUsb - Received frame from camera", get_ident()) # Apply controls for each frame to allow dynamic changes if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=Camera.logUsbFrameApplyControls) Camera.logUsbFrameApplyControls = False # Encode frame as JPEG ret, buffer = cv2.imencode(".jpg", frame) frameEncoded = buffer.tobytes() yield frameEncoded, frame except UsbCameraNoFrameReceivedError as ue: logger.debug( "Thread %s: Camera.framesUsb - No frame received after 1 sec", get_ident(), ) raise except UsbCameraOpenError as ue: logger.debug( "Thread %s: Camera.framesUsb - camera not opened during streaming", get_ident(), ) raise except Exception as e: logger.error("Thread %s: Camera.framesUsb - Exception: %s", get_ident(), e) raise @staticmethod def frames(): logger.debug("Thread %s: Camera.frames", get_ident()) srvCam = CameraCfg() piModelLower5 = srvCam.serverConfig.raspiModelLower5 imx500 = None try: cc, cr = Camera.ctrl.requestConfig(srvCam.photoConfig) if cc: # If the request for photoConfig caused a configuration change, restart with a new configuration Camera.ctrl.clearConfig() Camera.ctrl.requestConfig(srvCam.photoConfig) if piModelLower5 == False: Camera.ctrl.requestConfig(srvCam.rawConfig, cfgPhoto=srvCam.photoConfig) Camera.ctrl.requestConfig(srvCam.liveViewConfig) Camera.cam, started, imx500 = Camera.ctrl.requestStart( Camera.cam, Camera.camNum, Camera.camIsUsb, Camera.camUsbDev, forActiveCamera=True, ) if not started: Camera.cam, excl, imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg=None, forLiveStream=True ) else: logger.debug("Thread %s: Camera.frames - camera started", get_ident()) if Camera.resetScalerCropRequested == True: Camera.resetScalerCrop() Camera.cam_imx500 = imx500 Camera.applyControls(Camera.ctrl.configuration) logger.debug("Thread %s: Camera.frames - controls applied", get_ident()) time.sleep(0.5) except Exception as e: logger.error("Thread %s: Camera.frames - Exception: %s", get_ident(), e) raise try: Camera.streamOutput = StreamingOutput() prgLogger.debug("output = None") encoder = MJPEGEncoder() prgLogger.debug("encoder = MJPEGEncoder()") Camera.cam.start_encoder( encoder, FileOutput(Camera.streamOutput), name=srvCam.liveViewConfig.stream, ) prgLogger.debug( 'picam2.start_encoder(encoder, FileOutput(output), name="%s")', srvCam.liveViewConfig.stream, ) prgLogger.debug("time.sleep(videoDuration)") Camera.ctrl.registerEncoder(Camera.ENCODER_LIVESTREAM, encoder) logger.debug("Thread %s: Camera.frames - encoder started", get_ident()) # Get the live view scaler crop metadata = Camera.cam.capture_metadata() srvCam.serverConfig.scalerCropLiveView = metadata["ScalerCrop"] while True: # logger.debug("Thread %s: Camera.frames - Receiving camera stream", get_ident()) with Camera.streamOutput.condition: # logger.debug("Thread %s: Camera.frames - waiting", get_ident()) Camera.streamOutput.condition.wait() # logger.debug("Thread %s: Camera.frames - waiting done", get_ident()) frame = Camera.streamOutput.frame l = len(frame) # logger.debug("Thread %s: Camera.frames - got frame with length %s", get_ident(), l) yield frame, None except Exception as e: logger.error("Thread %s: Camera.frames - Exception: %s", get_ident(), e) raise @staticmethod def frames2Usb(): logger.debug("Thread %s: Camera.frames2Usb", get_ident()) srvCam = CameraCfg() imx500 = None Camera.ctrl2.requestConfig( srvCam.streamingCfg[str(Camera.camNum2)]["videoconfig"] ) Camera.ctrl2.requestConfig( srvCam.streamingCfg[str(Camera.camNum2)]["liveconfig"] ) Camera.cam2, started, imx500 = Camera.ctrl2.requestStart( Camera.cam2, Camera.camNum2, Camera.cam2IsUsb, Camera.cam2UsbDev, forActiveCamera=False, ) if not started: logger.error("Second camera did not start") raise RuntimeError("Second camera did not start") else: logger.debug("Thread %s: Camera.frames2Usb - camera started", get_ident()) Camera.cam2_imx500 = imx500 cfg = Camera.ctrl2.configuration hflip = cfg.transform.hflip vflip = cfg.transform.vflip Camera.logUsbFrame2ApplyControls = True try: while True: # logger.debug("Thread %s: Camera.frames2Usb - Receiving camera stream", get_ident()) success, frame = Camera.cam2.read() if not success: break else: # Apply controls for each frame to allow dynamic changes if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=Camera.logUsbFrame2ApplyControls, toCam2=True) Camera.logUsbFrame2ApplyControls = False # Encode frame as JPEG ret, buffer = cv2.imencode(".jpg", frame) frameEncoded = buffer.tobytes() yield frameEncoded, frame except Exception as e: logger.error("Thread %s: Camera.frames2Usb - Exception: %s", get_ident(), e) raise @staticmethod def frames2(): logger.debug("Thread %s: Camera.frames2", get_ident()) srvCam = CameraCfg() imx500 = None Camera.ctrl2.requestConfig( srvCam.streamingCfg[str(Camera.camNum2)]["videoconfig"] ) Camera.ctrl2.requestConfig( srvCam.streamingCfg[str(Camera.camNum2)]["liveconfig"] ) Camera.cam2, started, imx500 = Camera.ctrl2.requestStart( Camera.cam2, Camera.camNum2, Camera.cam2IsUsb, Camera.cam2UsbDev, forActiveCamera=False, ) if not started: logger.error("Second camera did not start") raise RuntimeError("Second camera did not start") else: logger.debug("Thread %s: Camera.frames2 - camera started", get_ident()) Camera.cam2_imx500 = imx500 Camera.applyControls(Camera.ctrl2.configuration, toCam2=True) logger.debug("Thread %s: Camera.frames2 - controls applied", get_ident()) time.sleep(0.5) try: Camera.stream2Output = StreamingOutput() encoder = MJPEGEncoder() Camera.cam2.start_encoder( encoder, FileOutput(Camera.stream2Output), name=srvCam.streamingCfg[str(Camera.camNum2)]["liveconfig"].stream, ) Camera.ctrl2.registerEncoder(Camera.ENCODER_LIVESTREAM, encoder) logger.debug("Thread %s: Camera.frames2 - encoder started", get_ident()) while True: # logger.debug("Thread %s: Camera.frames2 - Receiving camera stream", get_ident()) with Camera.stream2Output.condition: # logger.debug("Thread %s: Camera.frames2 - waiting", get_ident()) Camera.stream2Output.condition.wait() # logger.debug("Thread %s: Camera.frames2 - waiting done", get_ident()) frame = Camera.stream2Output.frame l = len(frame) # logger.debug("Thread %s: Camera.frames2 - got frame with length %s", get_ident(), l) yield frame, None except Exception as e: logger.error("Thread %s: Camera.frames2 - Exception: %s", get_ident(), e) raise @staticmethod def getUsbScalerCrop(width: int, height: int, log=True, forCam2=None) -> tuple: """Get ScalerCrop for a given size for USB camera Determine ScalerCrop assuming that the camera will first crop to the requested aspect ratio and then scale to the requested resolution """ if forCam2 is None: forCam2 = False if log: logger.debug("Thread %s: Camera.getUsbScalerCrop - width: %d, height: %d forCam2: %s", get_ident(), width, height, forCam2) aspectRatio = width / height cfg = CameraCfg() if forCam2 == False: sensorWidth = cfg.cameraProperties.pixelArraySize[0] sensorHeight = cfg.cameraProperties.pixelArraySize[1] else: cam2Str = str(Camera.camNum2) strCfg = cfg.streamingCfg[cam2Str] if "cameraproperties" in strCfg: cam2Props = strCfg["cameraproperties"] sensorWidth = cam2Props.pixelArraySize[0] sensorHeight = cam2Props.pixelArraySize[1] else: sensorWidth = cfg.cameraProperties.pixelArraySize[0] sensorHeight = cfg.cameraProperties.pixelArraySize[1] if log: logger.debug("Thread %s: Camera.getUsbScalerCrop - sensorWidth: %d, sensorHeight: %d", get_ident(), sensorWidth, sensorHeight) sensorAspectRatio = sensorWidth / sensorHeight if aspectRatio > sensorAspectRatio: # Crop height cropHeight = sensorWidth / aspectRatio cropY = (sensorHeight - cropHeight) / 2 scalerCrop = ( 0, int(cropY), sensorWidth, int(cropHeight), ) else: # Crop width cropWidth = sensorHeight * aspectRatio cropX = (sensorWidth - cropWidth) / 2 scalerCrop = ( int(cropX), 0, int(cropWidth), sensorHeight, ) if log: logger.debug("Thread %s: Camera.getUsbScalerCrop - scalerCrop: %s", get_ident(), scalerCrop) return scalerCrop @staticmethod def getUsbCamMetadata(cam, log=True) -> dict: """Get metadata from USB camera using OpenCV""" logger.debug("Thread %s: Camera.getUsbCamMetadata", get_ident()) width = int(cam.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) cfg = CameraCfg() sc = cfg.serverConfig cc = cfg.controls if width > 0 and height > 0: if cc.include_scalerCrop == True: scalerCrop = cc.scalerCrop # Map scalerCrop for LiveView to current resolution if width != cfg.liveViewConfig.stream_size[0] or height != cfg.liveViewConfig.stream_size[1]: aspectRatioLiveView = cfg.liveViewConfig.stream_size[0] / cfg.liveViewConfig.stream_size[1] aspectRatioCurrent = width / height if aspectRatioLiveView != aspectRatioCurrent: x1, y1, w, h = scalerCrop if aspectRatioCurrent > aspectRatioLiveView: # Extend width newW = h * aspectRatioCurrent newX1 = x1 - (newW - w) / 2 scalerCrop = ( int(newX1), y1, int(newW), h, ) else: # Extend height newH = w / aspectRatioCurrent newY1 = y1 - (newH - h) / 2 scalerCrop = ( x1, int(newY1), w, int(newH), ) else: scalerCrop = Camera.getUsbScalerCrop(width, height) else: scalerCrop = sc.scalerCropMax metadata = { "Width": width, "Height": height, "ScalerCrop": scalerCrop, "FPS": cam.get(cv2.CAP_PROP_FPS), "Format (FOURCC)": int(cam.get(cv2.CAP_PROP_FOURCC)), "Format": "".join( [chr((int(cam.get(cv2.CAP_PROP_FOURCC)) >> 8 * i) & 0xFF) for i in range(4)] ), "Brightness": cam.get(cv2.CAP_PROP_BRIGHTNESS), "Contrast": cam.get(cv2.CAP_PROP_CONTRAST), "Saturation": cam.get(cv2.CAP_PROP_SATURATION), "Hue": cam.get(cv2.CAP_PROP_HUE), "Gain": cam.get(cv2.CAP_PROP_GAIN), "Exposure": cam.get(cv2.CAP_PROP_EXPOSURE), "Exposure": cam.get(cv2.CAP_PROP_EXPOSURE), "White Balance Temperature": cam.get(cv2.CAP_PROP_WB_TEMPERATURE), "White Balance Auto WB": cam.get(cv2.CAP_PROP_AUTO_WB), "Focus": cam.get(cv2.CAP_PROP_FOCUS), "Autofocus": cam.get(cv2.CAP_PROP_AUTOFOCUS), } return metadata @staticmethod def takeImage( filename: str, keepExclusive: bool = False, noEvents: bool = False, alternatePath: str = "", ) -> str: """Takes a photo with the specified file name and returns the path filename: file name for the photo keepExclusive: If True, keep the exclusive mode This can be used for example if a jpg photo shall be taken before a video is recorded noEvents: If True, no events are triggered alternatePath: If not empty, the file path of the photo, otherwise the standard photo path is taken and the display buffer is not updated """ logger.debug( "Thread %s: Camera.takeImage - filename: %s keepExclusive: %s", get_ident(), filename, keepExclusive, ) fp = "" cfg = CameraCfg() sc = cfg.serverConfig if noEvents == False: logger.debug( "Thread %s: Camera.takeImage Checking for callback: when_photo_taken=%s", get_ident(), Camera().when_photo_taken, ) if Camera().when_photo_taken: Camera().when_photo_taken() try: forceExclusive = False if Camera.camIsUsb == True: forceExclusive = True logger.debug( "Thread %s: Camera.takeImage Requesting camera for photoConfig", get_ident(), ) Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.photoConfig, forceExclusive=forceExclusive ) logger.debug( "Thread %s: Camera.takeImage Got camera for photoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl.configuration) logger.debug("Thread %s: Camera.takeImage - controls applied", get_ident()) if Camera.camIsUsb == False: logger.debug( "Thread %s: Camera.takeImage - Camera.cam.controls=%s", get_ident(), Camera.cam.controls, ) request = Camera.cam.capture_request() prgLogger.debug("request = picam2.capture_request()") logger.debug("Thread %s: Camera.takeImage: Request started", get_ident()) path = sc.photoRoot + "/" + sc.cameraPhotoSubPath if alternatePath != "": path = alternatePath fp = path + "/" + filename if Camera.camIsUsb == False: request.save(cfg.photoConfig.stream, fp) prgLogger.debug( 'request.save("%s", "%s")', cfg.photoConfig.stream, sc.prgOutputPath + "/" + filename, ) else: # For USB cameras, save the image using OpenCV if Camera.cam.isOpened() == False: raise RuntimeError("USB camera is not opened") success, frame = Camera.cam.read() if success: conf = Camera.ctrl.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=True) cv2.imwrite(fp, frame) else: raise RuntimeError("Failed to capture image from USB camera") logger.debug( "Thread %s: Camera.takeImage: Image saved as %s", get_ident(), fp ) if alternatePath == "": sc.displayFile = filename sc.displayPhoto = sc.cameraPhotoSubPath + "/" + filename sc.isDisplayHidden = False if Camera.camIsUsb == False: metadata = request.get_metadata() prgLogger.debug("metadata = request.get_metadata()") else: metadata = Camera.getUsbCamMetadata(Camera.cam) sc.displayMeta = {"Camera": sc.activeCameraInfo} sc.displayMeta.update(metadata) sc.displayMetaFirst = 0 if len(metadata) < 11: sc._displayMetaLast = 999 else: sc.displayMetaLast = 10 sc.displayHistogram = None logger.debug( "Thread %s: Camera.takeImage: Image metedata captured", get_ident() ) if Camera.camIsUsb == False: request.release() prgLogger.debug("request.release()") logger.debug("Thread %s: Camera.takeImage: Request released", get_ident()) if not keepExclusive: Camera.cam = Camera.ctrl.restoreLivestream(Camera.cam, exclusive) if ( sc.isPhotoSeriesRecording == False and sc.isVideoRecording == False and sc.isLiveStream == False ): Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) except Exception as e: logger.error("Thread %s: Camera.takeImage: Error %s", get_ident(), e) if not sc.error: sc.error = "Phototaking caused error: " + str(e) sc.errorSource = "Camera.takeImage" Camera.liveViewDeactivated = False return fp @staticmethod def takeImage2( filename: str, keepExclusive: bool = False, noEvents: bool = False, alternatePath: str = "", ) -> str: """Takes a photo with second camera with the specified file name and returns the path filename: file name for the photo keepExclusive: If True, keep the exclusive mode This can be used for example if a jpg photo shall be taken before a video is recorded noEvents: If True, no events are triggered alternatePath: If not empty, the file path of the photo, otherwise the standard photo path is taken and the display buffer is not updated """ logger.debug( "Thread %s: Camera.takeImage2 - filename: %s keepExclusive: %s", get_ident(), filename, keepExclusive, ) fp = "" cfg = CameraCfg() sc = cfg.serverConfig if noEvents == False: logger.debug( "Thread %s: Camera.takeImage2 Checking for callback: when_photo_taken=%s", get_ident(), Camera().when_photo_taken, ) if Camera().when_photo_2_taken: Camera().when_photo_2_taken() try: photoConfig = cfg.streamingCfg[str(Camera.camNum2)]["photoconfig"] forceExclusive = False if Camera.cam2IsUsb == True: forceExclusive = True logger.debug( "Thread %s: Camera.takeImage2 Requesting camera for photoConfig", get_ident(), ) Camera.cam2, exclusive, Camera.cam2_imx500 = Camera.ctrl2.requestCameraForConfig( Camera.cam2, Camera.camNum2, photoConfig, forActiveCamera=False, forceExclusive=forceExclusive ) logger.debug( "Thread %s: Camera.takeImage2 Got camera for photoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl2.configuration, toCam2=True) logger.debug("Thread %s: Camera.takeImage2 - controls applied", get_ident()) if Camera.cam2IsUsb == False: logger.debug( "Thread %s: Camera.takeImage2 - Camera.cam2.controls=%s", get_ident(), Camera.cam2.controls, ) request = Camera.cam2.capture_request() prgLogger.debug("request = picam2.capture_request()") logger.debug("Thread %s: Camera.takeImage2: Request started", get_ident()) cameraPhotoSubPath = "photos/" + "camera_" + str(Camera.camNum2) path = sc.photoRoot + "/" + cameraPhotoSubPath if alternatePath != "": path = alternatePath fp = path + "/" + filename if Camera.cam2IsUsb == False: request.save(photoConfig.stream, fp) prgLogger.debug( 'request.save("%s", "%s")', photoConfig.stream, sc.prgOutputPath + "/" + filename, ) else: # For USB cameras, save the image using OpenCV if Camera.cam2.isOpened() == False: raise RuntimeError("USB camera 2 is not opened") success, frame = Camera.cam2.read() if success: conf = Camera.ctrl2.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=True, toCam2=True) cv2.imwrite(fp, frame) else: raise RuntimeError("Failed to capture image from USB camera") logger.debug( "Thread %s: Camera.takeImage2: Image saved as %s", get_ident(), fp ) if Camera.cam2IsUsb == False: request.release() prgLogger.debug("request.release()") logger.debug("Thread %s: Camera.takeImage2: Request released", get_ident()) if not keepExclusive: Camera.cam2 = Camera.ctrl2.restoreLivestream2(Camera.cam2, exclusive) if sc.isVideoRecording2 == False and sc.isLiveStream2 == False: Camera.cam2, done = Camera.ctrl2.requestStop( Camera.cam2, close=True ) except Exception as e: logger.error("Thread %s: Camera.takeImage2: Error %s", get_ident(), e) if not sc.errorc2: sc.errorc2 = "Phototaking caused error: " + str(e) sc.errorc2Source = "Camera.takeImage2" Camera.liveView2Deactivated = False return fp @staticmethod def quickPhoto(fp: str, saveImage: bool = True) -> tuple: """Take a photo assuming that the camera is started Parameters: fp: File path where the photo shall be saved saveImage: True: save image to file False: do not save image but return frame Returns: done: True if photo was saved to file err: Error message if any img: Image frame if saveImage is False """ logger.debug("Thread %s: Camera.quickPhoto - filename: %s", get_ident(), fp) done = False err = "" frameRaw = None cfg = CameraCfg() if Camera.camIsUsb == False: if Camera.cam.started: try: if saveImage == True: request = Camera.cam.capture_request() request.save(cfg.photoConfig.stream, fp) request.release() done = True else: request = Camera.cam.capture_request() frameRaw = Camera.cam.capture_array(cfg.liveViewConfig.stream) if cfg.liveViewConfig.format == "YUV420": if cv2Available == True: frameRaw = cv2.cvtColor(frameRaw, cv2.COLOR_YUV2BGR_I420) request.release() except Exception as e: err = str(e) else: err = "Camera not started" else: if Camera.cam.isOpened() == True: frame, frameRaw = Camera().get_frame() if saveImage == True: cv2.imwrite(fp, frameRaw) done = True else: err = "USB Camera not started" return (done, err, copy.copy(frameRaw)) @staticmethod def quickUsbVideoThread(out): """Record a video from a USB camera""" logger.debug( "Thread %s: Camera.quickUsbVideoThread - starting recording", get_ident() ) done = False if Camera.cam.isOpened() == False: logger.error( "Thread %s: Camera.quickUsbVideoThread - USB camera not opened", get_ident() ) done = True if out.isOpened() == False: logger.error( "Thread %s: Camera.quickUsbVideoThread - VideoWriter not opened", get_ident() ) done = True while not done: logger.debug("Thread %s: Camera.quickUsbVideoThread - acquiring lock - locked: %s", get_ident(), Camera.threadUsbVideoLock.locked()) Camera.threadUsbVideoLock.acquire() logger.debug("Thread %s: Camera.quickUsbVideoThread - getting frame", get_ident()) frame, frameRaw = Camera().get_frame() logger.debug("Thread %s: Camera.quickUsbVideoThread - got frame", get_ident()) out.write(frameRaw) logger.debug("Thread %s: Camera.quickUsbVideoThread - wrote frame", get_ident()) if Camera.stopUsbVideoRequested == True: done = True Camera.threadUsbVideoLock.release() logger.debug( "Thread %s: Camera.quickUsbVideoThread - stopping recording", get_ident() ) if Camera.threadUsbVideoLock.locked(): Camera.threadUsbVideoLock.release() Camera.threadUsbVideo = None @staticmethod def quickVideoStart(fp: str) -> tuple: """Record a video assuming that the camera is started""" logger.debug( "Thread %s: Camera.quickVideoStart - filename: %s", get_ident(), fp ) encoder = None done = False err = "" cfg = CameraCfg() sc = cfg.serverConfig if Camera.camIsUsb == False: if Camera.cam.started: try: encoder = H264Encoder() output = fp if output.lower().endswith(".mp4"): if sc.recordAudio == False: encoder.output = FfmpegOutput(output, audio=False) else: encoder.output = FfmpegOutput( output, audio=True, audio_sync=sc.audioSync ) else: encoder.output = FileOutput(output) stream = cfg.videoConfig.stream # For Pi Zero take video with liveView (lowres stream) # The lower buffer size of these devices is too small for full size video # and we do not want to switch mode if ( cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi Zero") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 4") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 3") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 2") or cfg.serverConfig.raspiModelFull.startswith("Raspberry Pi 1") ): stream = cfg.liveViewConfig.stream Camera.cam.start_encoder(encoder, name=stream) done = True except Exception as e: logger.error( "Thread %s: Camera.quickVideoStart - error when starting encoder: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: if Camera.cam.isOpened() == True: frameRate = 30 Camera.cam.set(cv2.CAP_PROP_FPS, frameRate) fourcc = cv2.VideoWriter_fourcc(*"avc1") width = int(Camera.cam.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(Camera.cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) out = cv2.VideoWriter(fp, fourcc, frameRate, (width, height)) logger.debug( "Thread %s: Camera.quickVideoStart - starting quickUsbVideoThread", get_ident() ) Camera.stopUsbVideoRequested = False Camera.threadUsbVideo = threading.Thread( target=Camera.quickUsbVideoThread, args=(out,) ) Camera.threadUsbVideo.start() encoder = out done = True else: err = "USB Camera not started" return (done, encoder, err) @staticmethod def quickVideoStop(encoder) -> tuple: """Stop a video recording that the camera is started""" logger.debug("Thread %s: Camera.quickVideoStop", get_ident()) done = False err = "" if Camera.camIsUsb == False: if Camera.cam.started: try: Camera.cam.stop_encoder(encoder) done = True except Exception as e: logger.error( "Thread %s: Camera.quickVideoStop - error when stopping encoder: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: if Camera.threadUsbVideo: logger.debug( "Thread %s: Camera.quickVideoStop - stopping quickUsbVideoThread", get_ident() ) with Camera.threadUsbVideoLock: Camera.stopUsbVideoRequested = True while Camera.threadUsbVideo: time.sleep(0.1) encoder.release() logger.debug( "Thread %s: Camera.quickVideoStop - quickUsbVideoThread stopped", get_ident(), ) Camera.stopUsbVideoRequested = False done = True else: err = "USB video thread not running" return (done, err) @staticmethod def startCircular(buffersizeSec=5) -> tuple: """Start encoder for circular output""" logger.debug("Thread %s: Camera.startCircular", get_ident()) encoder = None circ = None done = False err = "" cfg = CameraCfg() if Camera.camIsUsb == False: if Camera.cam.started: try: encoder = H264Encoder() sm = cfg.videoConfig.sensor_mode if sm == "custom": buffersize = 150 else: buffersize = cfg.sensorModes[sm].fps * buffersizeSec circ = CircularOutput(buffersize=buffersize) encoder.output = [circ] Camera.cam.encoders = encoder Camera.cam.start_encoder(encoder, name=cfg.videoConfig.stream) done = True except Exception as e: logger.error( "Thread %s: Camera.startCircular - error when starting encoder: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: err = "USB camera does not support circular recording" return (done, circ, encoder, err) @staticmethod def stopCircular(encoder) -> tuple: """Stop encoder for circular output""" logger.debug("Thread %s: Camera.stopCircular", get_ident()) done = False err = "" if Camera.camIsUsb == False: if Camera.cam.started: try: Camera.cam.stop_encoder(encoder) done = True except Exception as e: logger.error( "Thread %s: Camera.stopCircular - error when stopping encoder: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: err = "USB camera does not support circular recording" return (done, err) @staticmethod def recordCircular(circ: CircularOutput, fp: str) -> tuple: """Start recording circular output""" logger.debug("Thread %s: Camera.recordCircular - file: %s", get_ident(), fp) done = False err = "" if Camera.camIsUsb == False: if Camera.cam.started: try: circ.fileoutput = fp circ.start() done = True except Exception as e: logger.error( "Thread %s: Camera.recordCircular - error when starting circular: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: err = "USB camera does not support circular recording" return (done, err) @staticmethod def stopRecordingCircular(circ: CircularOutput) -> tuple: """Start recording circular output""" logger.debug("Thread %s: Camera.stopRecordingCircular", get_ident()) done = False err = "" if Camera.camIsUsb == False: if Camera.cam.started: try: circ.stop() done = True except Exception as e: logger.error( "Thread %s: Camera.stopRecordingCircular - error when stopping circular: %s", get_ident(), e, ) err = str(e) else: err = "Camera not started" else: err = "USB camera does not support circular recording" return (done, err) @staticmethod def takeRawImage( filenameRaw: str, filename: str, noEvents: bool = False, alternatePath: str = "" ): """Takes a photo as well as a raw image with the specified file names and returns the path for the raw photo filenameRaw: file name for the raw image filename: file name for the photo noEvents: If True, no events are triggered alternatePath: If not empty, the file path of the photo, otherwise the standard photo path is taken and the display buffer is not updated """ logger.debug("Thread %s: Camera.takeRawImage", get_ident()) fpr = "" cfg = CameraCfg() sc = cfg.serverConfig piModelLower5 = sc.raspiModelLower5 if noEvents == False: logger.debug( "Thread %s: Camera.takeImage Checking for callback: when_photo_taken=%s", get_ident(), Camera().when_photo_taken, ) if Camera().when_photo_taken: Camera().when_photo_taken() try: forceExclusive = False if Camera.camIsUsb == True: forceExclusive = True logger.debug( "Thread %s: Camera.takeRawImage Requesting camera for rawConfig", get_ident(), ) Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.rawConfig, cfg.photoConfig, forceExclusive=forceExclusive ) logger.debug( "Thread %s: Camera.takeRawImage Got camera for rawConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl.configuration) logger.debug( "Thread %s: Camera.takeRawImage: controls applied", get_ident() ) if Camera.camIsUsb == False: request = Camera.cam.capture_request() prgLogger.debug("request = picam2.capture_request()") logger.debug("Thread %s: Camera.takeRawImage: Request started", get_ident()) path = sc.photoRoot + "/" + sc.cameraPhotoSubPath if alternatePath != "": path = alternatePath fp = path + "/" + filename fpr = path + "/" + filenameRaw if Camera.camIsUsb == False: request.save("main", fp) prgLogger.debug( 'request.save("main", "%s")', sc.prgOutputPath + "/" + filename ) request.save_dng(fpr) prgLogger.debug('request.save_dng("%s")', fpr) else: # For USB cameras, save the image using OpenCV if Camera.cam.isOpened() == False: raise RuntimeError("USB camera is not opened") success, frame = Camera.cam.read() if success: conf = Camera.ctrl.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=True) cv2.imwrite(fp, frame) cv2.imwrite(fpr, frame, [cv2.IMWRITE_TIFF_COMPRESSION, 1]) else: raise RuntimeError("Failed to capture image from USB camera") logger.debug( "Thread %s: Camera.takeRawImage: Raw Image saved as %s", get_ident(), fpr, ) if alternatePath == "": sc.displayFile = filenameRaw sc.displayPhoto = sc.cameraPhotoSubPath + "/" + filename sc.isDisplayHidden = False if Camera.camIsUsb == False: metadata = request.get_metadata() prgLogger.debug("metadata = request.get_metadata()") else: metadata = Camera.getUsbCamMetadata(Camera.cam) sc.displayMeta = {"Camera": sc.activeCameraInfo} sc.displayMeta.update(metadata) sc.displayMetaFirst = 0 if len(metadata) < 11: sc._displayMetaLast = 999 else: sc.displayMetaLast = 10 sc.displayHistogram = None logger.debug( "Thread %s: Camera.takeRawImage: Raw Image metedata captured", get_ident(), ) if Camera.camIsUsb == False: request.release() prgLogger.debug("request.release()") logger.debug( "Thread %s: Camera.takeRawImage: Request released", get_ident() ) if piModelLower5 == True: Camera.ctrl.clearConfig() Camera.cam = Camera.ctrl.restoreLivestream(Camera.cam, exclusive) if ( sc.isPhotoSeriesRecording == False and sc.isVideoRecording == False and sc.isLiveStream == False ): Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) except Exception as e: logger.error("Thread %s: Camera.takeRawImage: Error %s", get_ident(), e) if not sc.error: sc.error = "Taking raw photo caused error: " + str(e) sc.errorSource = "Camera.takeRawImage" Camera.liveViewDeactivated = False return fpr @staticmethod def takeRawImage2( filenameRaw: str, filename: str, noEvents: bool = False, alternatePath: str = "" ): """Takes a photo as well as a raw image with the specified file names and returns the path for the raw photo filenameRaw: file name for the raw image filename: file name for the photo noEvents: If True, no events are triggered alternatePath: If not empty, the file path of the photo, otherwise the standard photo path is taken and the display buffer is not updated """ logger.debug("Thread %s: Camera.takeRawImage2", get_ident()) fpr = "" cfg = CameraCfg() sc = cfg.serverConfig if noEvents == False: logger.debug( "Thread %s: Camera.takeRawImage2 Checking for callback: when_photo_2_taken=%s", get_ident(), Camera().when_photo_2_taken, ) if Camera().when_photo_2_taken: Camera().when_photo_2_taken() try: forceExclusive = False if Camera.cam2IsUsb == True: forceExclusive = True rawConfig = cfg.streamingCfg[str(Camera.camNum2)]["rawconfig"] photoConfig = cfg.streamingCfg[str(Camera.camNum2)]["photoconfig"] logger.debug( "Thread %s: Camera.takeRawImage2 Requesting camera for rawConfig", get_ident(), ) Camera.cam2, exclusive, Camera.cam2_imx500 = Camera.ctrl2.requestCameraForConfig( Camera.cam2, Camera.camNum2, rawConfig, photoConfig, forActiveCamera=False, forceExclusive=forceExclusive, ) logger.debug( "Thread %s: Camera.takeRawImage2 Got camera for rawConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl2.configuration, toCam2=True) logger.debug( "Thread %s: Camera.takeRawImage2: controls applied", get_ident() ) if Camera.cam2IsUsb == False: request = Camera.cam2.capture_request() prgLogger.debug("request = picam2.capture_request()") logger.debug( "Thread %s: Camera.takeRawImage2: Request started", get_ident() ) cameraPhotoSubPath = "photos/" + "camera_" + str(Camera.camNum2) path = sc.photoRoot + "/" + cameraPhotoSubPath if alternatePath != "": path = alternatePath fp = path + "/" + filename fpr = path + "/" + filenameRaw if Camera.cam2IsUsb == False: request.save(photoConfig.stream, fp) prgLogger.debug( 'request.save("%s", "%s")', photoConfig.stream, sc.prgOutputPath + "/" + filename, ) request.save_dng(fpr) prgLogger.debug('request.save_dng("%s")', fpr) else: # For USB cameras, save the image using OpenCV if Camera.cam2.isOpened() == False: raise RuntimeError("USB camera is not opened") success, frame = Camera.cam2.read() if success: conf = Camera.ctrl2.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, log=True, toCam2=True) cv2.imwrite(fp, frame) cv2.imwrite(fpr, frame, [cv2.IMWRITE_TIFF_COMPRESSION, 1]) else: raise RuntimeError("Failed to capture image from USB camera") logger.debug( "Thread %s: Camera.takeRawImage2: Raw Image saved as %s", get_ident(), fpr, ) if Camera.cam2IsUsb == False: request.release() prgLogger.debug("request.release()") logger.debug( "Thread %s: Camera.takeRawImage2: Request released", get_ident() ) Camera.cam2 = Camera.ctrl2.restoreLivestream2(Camera.cam2, exclusive) if sc.isVideoRecording2 == False and sc.isLiveStream2 == False: Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) except Exception as e: logger.error("Thread %s: Camera.takeRawImage2: Error %s", get_ident(), e) if not sc.errorc2: sc.errorc2 = "Taking raw photo caused error: " + str(e) sc.errorc2Source = "Camera.takeRawImage2" Camera.liveView2Deactivated = False return fpr @staticmethod def _videoThreadUsb(): logger.debug("Thread %s: Camera._videoThreadUsb", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig logger.debug( "Thread %s: Camera._videoThreadUsb - Requesting camera for videoConfig", get_ident(), ) Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.videoConfig, forceExclusive=True ) logger.debug( "Thread %s: Camera._videoThreadUsb - Got camera for videoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl.configuration) logger.debug("Thread %s: Camera._videoThreadUsb - controls applied", get_ident()) # frameRate = Camera.cam.get(cv2.CAP_PROP_FPS) frameRate = 14.5 Camera.cam.set(cv2.CAP_PROP_FPS, frameRate) logger.debug("Thread %s: Camera._videoThreadUsb - frameRate is %s", get_ident(), frameRate) # Codec for MP4 (most compatible) fourcc = cv2.VideoWriter_fourcc(*"avc1") width = int(Camera.cam.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(Camera.cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) logger.debug("Thread %s: Camera._videoThreadUsb - width:%s, height:%s", get_ident(), width, height) logger.debug("Thread %s: Camera._videoThreadUsb - videoOutput:%s", get_ident(), Camera.videoOutput) out = cv2.VideoWriter(Camera.videoOutput, fourcc, frameRate, (width, height)) logger.debug("Thread %s: Camera._videoThreadUsb - VideoWriter created", get_ident()) try: videoStart = time.time() duration = float(Camera.videoDuration) logger.debug( "Thread %s: Camera._videoThreadUsb - video started at %s, duration is %s", get_ident(), videoStart, duration, ) if duration > 0.0: elapsed = time.time() - videoStart while elapsed <= duration: ret, frame = Camera.cam.read() if not ret: break conf = Camera.ctrl.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame) out.write(frame) if Camera.stopVideoRequested == True: logger.debug( "Thread %s: Camera._videoThreadUsb - stop video requested", get_ident() ) break elapsed = time.time() - videoStart sc.isVideoRecording = False sc.isAudioRecording = False else: while Camera.stopVideoRequested == False: ret, frame = Camera.cam.read() if not ret: break conf = Camera.ctrl.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame) out.write(frame) if Camera.stopVideoRequested == True: logger.debug( "Thread %s: Camera._videoThreadUsb - stop video requested", get_ident() ) break out.release() Camera.stopVideoRequested = False Camera.videoDuration = 0 except Exception as e: logger.error( "Thread %s: Camera._videoThreadUsb - Exception: %s", get_ident(), e ) Camera.liveViewDeactivated = False if not sc.error: sc.error = "Error in video recording: " + str(e) sc.errorSource = "Camera._videoThreadUsb" Camera.videoThread = None logger.debug( "Thread %s: Camera._videoThread - _videoThreadUsb terminated", get_ident() ) Camera.cam = Camera.ctrl.restoreLivestream(Camera.cam, exclusive) logger.debug( "Thread %s: Camera._videoThreadUsb - sc.error: %s)", get_ident(), sc.error ) if sc.isPhotoSeriesRecording == False and sc.isLiveStream == False: Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) @staticmethod def _videoThread(): logger.debug("Thread %s: Camera._videoThread", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig logger.debug( "Thread %s: Camera._videoThread - Requesting camera for videoConfig", get_ident(), ) Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.videoConfig ) logger.debug( "Thread %s: Camera._videoThread - Got camera for videoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl.configuration) logger.debug("Thread %s: Camera._videoThread - controls applied", get_ident()) sc.checkMicrophone() encoder = H264Encoder() prgLogger.debug("encoder = H264Encoder()") output = Camera.videoOutput prgLogger.debug('output="%s"', Camera.prgVideoOutput) if output.lower().endswith(".mp4"): if sc.recordAudio == False: encoder.output = FfmpegOutput(output, audio=False) prgLogger.debug("encoder.output = FfmpegOutput(output, audio=False)") else: encoder.output = FfmpegOutput( output, audio=True, audio_sync=sc.audioSync ) prgLogger.debug( "encoder.output = FfmpegOutput(output, audio=True, audio_sync=%s)", sc.audioSync, ) logger.debug( "Thread %s: Camera._videoThread - mp4 Video output to %s", get_ident(), output, ) else: encoder.output = FileOutput(output) prgLogger.debug("encoder.output = FileOutput(output)") logger.debug( "Thread %s: Camera._videoThread - h264 Video output to %s", get_ident(), output, ) try: videoStart = time.time() duration = float(Camera.videoDuration) logger.debug( "Thread %s: Camera._videoThread - video started at %s, duration is %s", get_ident(), videoStart, duration, ) Camera.cam.start_encoder(encoder, name=cfg.videoConfig.stream) prgLogger.debug( 'picam2.start_encoder(encoder, name="%s")', cfg.videoConfig.stream ) prgLogger.debug("time.sleep(videoDuration)") Camera.ctrl.registerEncoder(Camera.ENCODER_VIDEO, encoder) logger.debug( "Thread %s: Camera._videoThread - Encoder started", get_ident() ) if duration > 0.0: elapsed = time.time() - videoStart while elapsed <= duration: if Camera.stopVideoRequested == True: break time.sleep(0.1) elapsed = time.time() - videoStart sc.isVideoRecording = False sc.isAudioRecording = False else: while Camera.stopVideoRequested == False: time.sleep(0.1) logger.debug( "Thread %s: Camera._videoThread - stop video requested", get_ident() ) Camera.ctrl.stopEncoder(Camera.cam, Camera.ENCODER_VIDEO) logger.debug( "Thread %s: Camera._videoThread - encoder stopped", get_ident() ) Camera.stopVideoRequested = False Camera.videoDuration = 0 except ProcessLookupError as e: logger.error("Thread %s: Camera._videoThread - Error: %s", get_ident(), e) Camera.liveViewDeactivated = False if not sc.error: sc.error = "Error in encoder: " + str(e) sc.error2 = "Probably, the requested resolution is too high." sc.errorSource = "Camera._videoThread" except RuntimeError as e: logger.error("Thread %s: Camera._videoThread - Error: %s)", get_ident(), e) Camera.liveViewDeactivated = False if not sc.error: sc.error = "Error in encoder: " + str(e) sc.error2 = "Probably, there is not sufficient memory for the requested resolution." sc.errorSource = "Camera._videoThread" logger.debug( "Thread %s: Camera._videoThread - sc.error: %s)", get_ident(), sc.error ) except Exception as e: logger.error( "Thread %s: Camera._videoThread - Exception: %s", get_ident(), e ) Camera.liveViewDeactivated = False if not sc.error: sc.error = "Error in video recording: " + str(e) sc.errorSource = "Camera._videoThread" Camera.videoThread = None logger.debug( "Thread %s: Camera._videoThread - videoThread terminated", get_ident() ) Camera.cam = Camera.ctrl.restoreLivestream(Camera.cam, exclusive) logger.debug( "Thread %s: Camera._videoThread - sc.error: %s)", get_ident(), sc.error ) if sc.isPhotoSeriesRecording == False and sc.isLiveStream == False: Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) @staticmethod def _videoThread2Usb(): logger.debug("Thread %s: Camera._videoThread2Usb", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig logger.debug( "Thread %s: Camera._videoThread2Usb - Requesting camera for videoConfig", get_ident(), ) videoConfig = cfg.streamingCfg[str(Camera.camNum2)]["videoconfig"] Camera.cam2, exclusive, Camera.cam2_imx500 = Camera.ctrl2.requestCameraForConfig( Camera.cam2, Camera.camNum2, videoConfig, forActiveCamera=False, forceExclusive=True ) logger.debug( "Thread %s: Camera._videoThread2Usb - Got camera for videoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl2.configuration, toCam2=True) logger.debug( "Thread %s: Camera._videoThread2Usb - controls applied", get_ident() ) # frameRate = Camera.cam.get(cv2.CAP_PROP_FPS) frameRate = 14.5 Camera.cam2.set(cv2.CAP_PROP_FPS, frameRate) logger.debug("Thread %s: Camera._videoThread2Usb - frameRate is %s", get_ident(), frameRate) # Codec for MP4 (most compatible) fourcc = cv2.VideoWriter_fourcc(*"avc1") width = int(Camera.cam2.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(Camera.cam2.get(cv2.CAP_PROP_FRAME_HEIGHT)) logger.debug("Thread %s: Camera._videoThread2Usb - width:%s, height:%s", get_ident(), width, height) logger.debug("Thread %s: Camera._videoThread2Usb - videoOutput2:%s", get_ident(), Camera.videoOutput2) out = cv2.VideoWriter(Camera.videoOutput2, fourcc, frameRate, (width, height)) logger.debug( "Thread %s: Camera._videoThread2Usb - VideoWriter created", get_ident() ) try: videoStart = time.time() duration = float(Camera.videoDuration2) logger.debug( "Thread %s: Camera._videoThread2Usb - video started at %s, duration is %s", get_ident(), videoStart, duration, ) if duration > 0.0: elapsed = time.time() - videoStart while elapsed <= duration: ret, frame = Camera.cam2.read() if not ret: break conf = Camera.ctrl2.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, toCam2=True) out.write(frame) if Camera.stopVideoRequested2 == True: logger.debug( "Thread %s: Camera._videoThread2Usb - stop video requested", get_ident(), ) break elapsed = time.time() - videoStart sc.isVideoRecording2 = False sc.isAudioRecording = False else: while Camera.stopVideoRequested2 == False: ret, frame = Camera.cam2.read() if not ret: break conf = Camera.ctrl2.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame, toCam2=True) out.write(frame) if Camera.stopVideoRequested2 == True: logger.debug( "Thread %s: Camera._videoThread2Usb - stop video requested", get_ident(), ) break out.release() Camera.stopVideoRequested2 = False Camera.videoDuration2 = 0 except Exception as e: logger.error( "Thread %s: Camera._videoThread2Usb - Exception: %s", get_ident(), e ) Camera.liveView2Deactivated = False if not sc.errorc2: sc.errorc2 = "Error in video recording: " + str(e) sc.errorc2Source = "Camera._videoThread2Usb" Camera.videoThread2 = None logger.debug( "Thread %s: Camera._videoThread2Usb - _videoThread2Usb terminated", get_ident(), ) Camera.cam2 = Camera.ctrl2.restoreLivestream2(Camera.cam2, exclusive) logger.debug( "Thread %s: Camera._videoThread2Usb - sc.errorc2: %s)", get_ident(), sc.errorc2 ) if sc.isLiveStream2 == False: Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) @staticmethod def _videoThread2(): logger.debug("Thread %s: Camera._videoThread2", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig logger.debug( "Thread %s: Camera._videoThread2 - Requesting camera for videoConfig", get_ident(), ) videoConfig = cfg.streamingCfg[str(Camera.camNum2)]["videoconfig"] Camera.cam2, exclusive, Camera.cam2_imx500 = Camera.ctrl2.requestCameraForConfig( Camera.cam2, Camera.camNum2, videoConfig ) logger.debug( "Thread %s: Camera._videoThread2 - Got camera for videoConfig exclusive: %s", get_ident(), exclusive, ) Camera.applyControls(Camera.ctrl2.configuration, toCam2=True) logger.debug("Thread %s: Camera._videoThread2 - controls applied", get_ident()) time.sleep(0.5) encoder = H264Encoder() prgLogger.debug("encoder = H264Encoder()") output = Camera.videoOutput2 prgLogger.debug('output="%s"', Camera.prgVideoOutput) if output.lower().endswith(".mp4"): encoder.output = FfmpegOutput(output, audio=False) prgLogger.debug("encoder.output = FfmpegOutput(output, audio=False)") logger.debug( "Thread %s: Camera._videoThread2 - mp4 Video output to %s", get_ident(), output, ) else: encoder.output = FileOutput(output) prgLogger.debug("encoder.output = FileOutput(output)") logger.debug( "Thread %s: Camera._videoThread2 - h264 Video output to %s", get_ident(), output, ) try: videoStart = time.time() duration = float(Camera.videoDuration2) logger.debug( "Thread %s: Camera._videoThread2 - video started at %s, duration is %s", get_ident(), videoStart, duration, ) Camera.cam2.start_encoder(encoder, name=videoConfig.stream) prgLogger.debug( 'picam2.start_encoder(encoder, name="%s")', videoConfig.stream ) prgLogger.debug("time.sleep(videoDuration)") Camera.ctrl2.registerEncoder(Camera.ENCODER_VIDEO, encoder) logger.debug( "Thread %s: Camera._videoThread2 - Encoder started", get_ident() ) if duration > 0.0: elapsed = time.time() - videoStart while elapsed <= duration: if Camera.stopVideoRequested2 == True: break time.sleep(0.1) elapsed = time.time() - videoStart sc.isVideoRecording2 = False else: while Camera.stopVideoRequested2 == False: time.sleep(0.1) logger.debug( "Thread %s: Camera._videoThread2 - stop video requested", get_ident() ) Camera.ctrl2.stopEncoder(Camera.cam2, Camera.ENCODER_VIDEO) logger.debug( "Thread %s: Camera._videoThread2 - encoder stopped", get_ident() ) Camera.stopVideoRequested2 = False Camera.videoDuration2 = 0 except ProcessLookupError as e: logger.error("Thread %s: Camera._videoThread2 - Error: %s", get_ident(), e) Camera.liveView2Deactivated = False if not sc.errorc2: sc.errorc2 = "Error in encoder: " + str(e) sc.errorc22 = "Probably, the requested resolution is too high." sc.errorc2Source = "Camera._videoThread2" except RuntimeError as e: logger.error("Thread %s: Camera._videoThread2 - Error: %s)", get_ident(), e) Camera.liveView2Deactivated = False if not sc.errorc2: sc.errorc2 = "Error in encoder: " + str(e) sc.errorc22 = "Probably, there is not sufficient memory for the requested resolution." sc.errorc2Source = "Camera._videoThread2" logger.debug( "Thread %s: Camera._videoThread2 - sc.errorc2: %s)", get_ident(), sc.errorc2, ) except Exception as e: logger.error( "Thread %s: Camera._videoThread2 - Exception: %s", get_ident(), e ) Camera.liveView2Deactivated = False if not sc.errorc2: sc.errorc2 = "Error in video recording: " + str(e) sc.errorc2Source = "Camera._videoThread2" Camera.videoThread2 = None logger.debug( "Thread %s: Camera._videoThread2 - videoThread2 terminated", get_ident() ) Camera.cam2 = Camera.ctrl2.restoreLivestream2(Camera.cam2, exclusive) logger.debug( "Thread %s: Camera._videoThread2 - sc.errorc2: %s)", get_ident(), sc.errorc2 ) if sc.isLiveStream2 == False: Camera.cam2, done = Camera.ctrl2.requestStop(Camera.cam2, close=True) @staticmethod def recordVideo( filenameVid: str, filename: str, duration: int = 0, noEvents: bool = False, alternatePath: str = "", ): """Start recrding video in an own thread Args: filenameVid (str): File name for video filename (str): filename for placeholder image If empty, no placeholder image is created duration (int, optional): Video duration. Defaults to 0. noEvents (bool, optional): Dont fire events. Defaults to False. alternatePath (str, optional): Alternate path. If set, display buffer will not be upfated Defaults to "". """ logger.debug( "Thread %s: Camera.recordVideo. filename=%s, duration=%s", get_ident(), filename, duration, ) cfg = CameraCfg() sc = cfg.serverConfig # First take a normal photo as placeholder if filename != "": Camera.takeImage( filename, keepExclusive=True, noEvents=True, alternatePath=alternatePath ) if alternatePath == "": sc.displayFile = filenameVid # Configure output for video file path = sc.photoRoot + "/" + sc.cameraPhotoSubPath if alternatePath != "": path = alternatePath output = path + "/" + filenameVid prgoutput = sc.prgOutputPath + "/" + filenameVid if Camera.videoThread is None: Camera.videoOutput = output Camera.prgVideoOutput = prgoutput Camera.videoDuration = duration logger.debug( "Thread %s: Camera.recordVideo - Starting new videoThread", get_ident() ) if Camera.camIsUsb == False: Camera.videoThread = threading.Thread( target=Camera._videoThread, daemon=True ) else: Camera.videoThread = threading.Thread( target=Camera._videoThreadUsb, daemon=True ) Camera.videoThread.start() logger.debug( "Thread %s: Camera.recordVideo - videoThread started", get_ident() ) if noEvents == False: if Camera().when_recording_starts: Camera().when_recording_starts() return output @staticmethod def recordVideo2( filenameVid: str, filename: str, duration: int = 0, noEvents: bool = False, alternatePath: str = "", ): """Start recording video with second camera in an own thread Args: filenameVid (str): File name for video filename (str): filename for placeholder image If empty, no placeholder image is created duration (int, optional): Video duration. Defaults to 0. noEvents (bool, optional): Dont fire events. Defaults to False. alternatePath (str, optional): Alternate path. If set, display buffer will not be upfated Defaults to "". """ logger.debug( "Thread %s: Camera.recordVideo2. filename=%s, duration=%s", get_ident(), filename, duration, ) cfg = CameraCfg() sc = cfg.serverConfig # First take a normal photo as placeholder if filename != "": Camera.takeImage2( filename, keepExclusive=True, noEvents=True, alternatePath=alternatePath ) # Configure output for video file cameraPhotoSubPath = "photos/" + "camera_" + str(Camera.camNum2) path = sc.photoRoot + "/" + cameraPhotoSubPath if alternatePath != "": path = alternatePath output = path + "/" + filenameVid prgoutput = sc.prgOutputPath + "/" + filenameVid if Camera.videoThread2 is None: Camera.videoOutput2 = output Camera.prgVideoOutput2 = prgoutput Camera.videoDuration2 = duration logger.debug( "Thread %s: Camera.recordVideo2 - Starting new videoThread with output=%s", get_ident(), Camera.prgVideoOutput2, ) if Camera.cam2IsUsb == False: Camera.videoThread2 = threading.Thread( target=Camera._videoThread2, daemon=True ) else: Camera.videoThread2 = threading.Thread( target=Camera._videoThread2Usb, daemon=True ) Camera.videoThread2.start() logger.debug( "Thread %s: Camera.recordVideo2 - videoThread2 started", get_ident() ) if noEvents == False: if Camera().when_recording_2_starts: Camera().when_recording_2_starts() return output @staticmethod def stopVideoRecording(noEvents: bool = False): """stops the video recording""" logger.debug("Thread %s: Camera.stopVideoRecording", get_ident()) Camera.stopVideoRequested = True Camera.videoDuration = 0 cnt = 0 while Camera.videoThread: time.sleep(0.01) cnt += 1 if cnt > 500: raise TimeoutError("Video thread did not stop within 5 sec") logger.debug( "Thread %s: Camera.stopVideoRecording: Thread has stopped", get_ident() ) if noEvents == False: if Camera().when_recording_stops: Camera().when_recording_stops() Camera.liveViewDeactivated = False time.sleep(0.1) Camera.startLiveStream() @staticmethod def stopVideoRecording2(noEvents: bool = False): """stops the video recording for second camera""" logger.debug("Thread %s: Camera.stopVideoRecording2", get_ident()) Camera.stopVideoRequested2 = True Camera.videoDurations = 0 cnt = 0 while Camera.videoThread2: time.sleep(0.01) cnt += 1 if cnt > 500: raise TimeoutError("Video thread 2 did not stop within 5 sec") logger.debug( "Thread %s: Camera.stopVideoRecording2: Thread has stopped", get_ident() ) if noEvents == False: if Camera().when_recording_2_stops: Camera().when_recording_2_stops() Camera.liveView2Deactivated = False @staticmethod def isVideoRecording() -> bool: return Camera.videoThread is not None @staticmethod def isVideoRecording2() -> bool: return Camera.videoThread2 is not None @staticmethod def getLensPosition() -> float: metadata = Camera.cam.capture_metadata() if "LensPosition" in metadata: return metadata["LensPosition"] else: return 0.0 @staticmethod def getMetaData() -> dict: logger.debug("Thread %s: Camera.getMetaData", get_ident()) if Camera.camIsUsb == False: return Camera.cam.capture_metadata() else: return Camera.getUsbCamMetadata(Camera.cam) @staticmethod def _photoSeriesThread(): logger.debug("Thread %s: Camera._photoSeriesThread", get_ident()) ser = Camera.photoSeries cfg = CameraCfg() sc = cfg.serverConfig logger.debug( "Thread %s: Camera._photoSeriesThread Requesting camera for photo series of type %s", get_ident(), ser.type, ) exclusive = False try: if Camera.camIsUsb == False: forceExclusive = False else: forceExclusive = True if ser.type == "jpg": Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.photoConfig, forceExclusive=forceExclusive ) else: Camera.cam, exclusive, Camera.cam_imx500 = Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.rawConfig, cfg.photoConfig, forceExclusive=forceExclusive ) logger.debug( "Thread %s: Camera._photoSeriesThread Got camera for photo series exclusive: %s", get_ident(), exclusive, ) except Exception as e: logger.error( "Thread %s: Camera._photoSeriesThread error: %s", get_ident(), e ) if not sc.error: sc.error = "Error while requesting camera: " + str(e) sc.errorSource = "Camera._photoSeriesThread" if not sc.error: sc.isPhotoSeriesRecording = True exceptCtrl = None exceptValue = None exceptValueRaw = None # Special handling for exposure series if ser.isExposureSeries: if sc.useHistograms: import numpy as np from matplotlib import pyplot as plt if ser.isExpGainFix: exceptCtrl = "ExposureTime" exceptValue = ser.expTimeStart if ser.expTimeStep == 0: expFact = 2 elif ser.expTimeStep == 1: expFact = 2 ** (1.0 / 3) elif ser.expTimeStep == 2: expFact = 4 else: expFact = 2 else: exceptCtrl = "AnalogueGain" exceptValue = ser.expGainStart if ser.expGainStep == 0: expFact = 2 elif ser.expGainStep == 1: expFact = 2 ** (1.0 / 3) elif ser.expGainStep == 2: expFact = 4 else: expFact = 2 if ser.curShots: if ser.curShots > 1: n = 0 while n < ser.curShots: n += 1 exceptValue = exceptValue * expFact logger.debug( "Thread %s: Camera._photoSeriesThread - Exposure Series for %s: Restart after %s shots", get_ident(), exceptCtrl, ser.curShots, ) logger.debug( "Thread %s: Camera._photoSeriesThread - Exposure Series for %s: %s Factor: %s", get_ident(), exceptCtrl, exceptValue, expFact, ) # Special handling for focus series if ser.isFocusStackingSeries: exceptCtrl = "LensPosition" exceptValueRaw = ser.focalDistStart exceptValue = 1.0 / exceptValueRaw if ser.curShots: if ser.curShots > 1: exceptValueRaw = ( ser.focalDistStart + (ser.curShots - 1) * ser.focalDistStep ) exceptValue = 1.0 / exceptValueRaw logger.debug( "Thread %s: Camera._photoSeriesThread - Focus Series: Restart after %s shots", get_ident(), ser.curShots, ) logger.debug( "Thread %s: Camera._photoSeriesThread - Focus Series for %s: %s (focal dist: %s, interval: %s)", get_ident(), exceptCtrl, exceptValue, exceptValueRaw, ser.focalDistStep, ) photoseriesCtrls = Camera.applyControls( Camera.ctrl.configuration, exceptCtrl, exceptValue ) logger.debug( "Thread %s: Camera._photoSeriesThread - selected controls applied", get_ident(), ) lastTime = None stop = False while not stop: nextTime = ser.nextTime(lastTime) curShots, nextPhoto, serMetaData = ser.nextPhoto() logger.debug( "Thread %s: Camera._photoSeriesThread - nextPhoto: %s nextTime %s", get_ident(), nextPhoto, str(nextTime), ) if nextPhoto == "" or nextTime is None or ser.status == "FINISHED": logger.debug( "Thread %s: Camera._photoSeriesThread - Series done: nextPhoto=%s, nextTime=%s, status=%s", get_ident(), nextPhoto, str(nextTime), ser.status, ) stop = True else: curTime = datetime.datetime.now() timedif = nextTime - curTime timedifSec = timedif.total_seconds() logger.debug( "Thread %s: Camera._photoSeriesThread - Seconds to wait: %s", get_ident(), timedifSec, ) camClosed = False if ( ser.isFocusStackingSeries == False and ser.isExposureSeries == False ): if sc.isVideoRecording == False and sc.isLiveStream == False: if timedifSec > 60: Camera.cam, camClosed = Camera.ctrl.requestStop( Camera.cam, close=True ) while timedifSec > 2.0: time.sleep(2.0) curTime = datetime.datetime.now() timedif = nextTime - curTime timedifSec = timedif.total_seconds() if camClosed: timedifSec -= 2.0 if Camera.stopPhotoSeriesRequested: stop = True break if stop == False and timedifSec > 0.0: time.sleep(timedifSec) if Camera.stopPhotoSeriesRequested: logger.debug( "Thread %s: Camera._photoSeriesThread - Stop requested", get_ident(), ) stop = True if not stop: try: logger.debug( "Thread %s: Camera._photoSeriesThread - Starting next shot", get_ident(), ) if Camera.cam is None: camClosed = True else: if Camera.camIsUsb == False: if Camera.cam.started == False: camClosed = True else: camClosed = Camera.cam.isOpened() == False if camClosed: logger.debug( "Thread %s: Camera._photoSeriesThread - Preparing closed camera", get_ident(), ) if ser.type == "jpg": Camera.cam, exclusive, Camera.cam_imx500 = ( Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.photoConfig, forceExclusive=forceExclusive ) ) else: Camera.cam, exclusive, Camera.cam_imx500 = ( Camera.ctrl.requestCameraForConfig( Camera.cam, Camera.camNum, cfg.rawConfig, cfg.photoConfig, forceExclusive=forceExclusive ) ) logger.debug( "Thread %s: Camera._photoSeriesThread Got camera for photo series exclusive: %s", get_ident(), exclusive, ) photoseriesCtrls = Camera.applyControls( Camera.ctrl.configuration, exceptCtrl, exceptValue ) logger.debug( "Thread %s: Camera._photoSeriesThread - selected controls applied", get_ident(), ) time.sleep(1.5) curTime = datetime.datetime.now() timedif = nextTime - curTime timedifSec = timedif.total_seconds() if timedifSec > 0: time.sleep(timedifSec) lastTime = datetime.datetime.now() if Camera.camIsUsb == False: logger.debug( "Thread %s: Camera._photoSeriesThread - Preparing request", get_ident(), ) logger.debug( "Thread %s: Camera._photoSeriesThread - id(Camera)=%s id(Camera.cam)=%s id(Camera.cam.controls)=%s", get_ident(), id(Camera), id(Camera.cam), id(Camera.cam.controls), ) logger.debug( "Thread %s: Camera._photoSeriesThread - Camera.cam.controls=%s", get_ident(), Camera.cam.controls, ) request = Camera.cam.capture_request() logger.debug( "Thread %s: Camera._photoSeriesThread - capture_request completed", get_ident(), ) logger.debug( "Thread %s: Camera._photoSeriesThread - id(Camera)=%s id(Camera.cam)=%s id(Camera.cam.controls)=%s", get_ident(), id(Camera), id(Camera.cam), id(Camera.cam.controls), ) logger.debug( "Thread %s: Camera._photoSeriesThread - Camera.cam.controls=%s", get_ident(), Camera.cam.controls, ) prgLogger.debug("request = picam2.capture_request()") fpjpg = ser.path + "/" + nextPhoto + ".jpg" fpraw = ser.path + "/" + nextPhoto + ".dng" request.save("main", fpjpg) prgLogger.debug( 'request.save("main", "%s")', sc.prgOutputPath + "/" + nextPhoto + ".jpg", ) if ser.type == "raw+jpg": request.save_dng(fpraw) prgLogger.debug( 'request.save_dng("%s")', sc.prgOutputPath + "/" + nextPhoto + ".dng", ) metadata = request.get_metadata() prgLogger.debug("metadata = request.get_metadata()") request.release() prgLogger.debug("request.release()") logger.debug( "Thread %s: Camera._photoSeriesThread - Request released", get_ident(), ) else: # For USB cameras, save the image using OpenCV fpjpg = ser.path + "/" + nextPhoto + ".jpg" fpraw = ser.path + "/" + nextPhoto + ".tiff" logger.debug( "Thread %s: Camera._photoSeriesThread - USB camera capture image %s", get_ident(), fpjpg, ) if Camera.cam.isOpened() == False: raise RuntimeError("USB camera is not opened") success, frame = Camera.cam.read() if success: metadata = Camera.getUsbCamMetadata(Camera.cam) conf = Camera.ctrl.configuration hflip = conf.transform.hflip vflip = conf.transform.vflip if hflip == True: frame = cv2.flip(frame, 1) if vflip == True: frame = cv2.flip(frame, 0) # Apply controls frame = Camera.usbFrameApplyControls(frame) cv2.imwrite(fpjpg, frame) if ser.type == "raw+jpg": cv2.imwrite(fpraw, frame, [cv2.IMWRITE_TIFF_COMPRESSION, 1]) logger.debug( "Thread %s: Camera._photoSeriesThread - USB camera capture done", get_ident() ) else: raise RuntimeError("Failed to capture image from USB camera") ser.curShots = curShots ser.logPhoto(nextPhoto, lastTime, metadata, serMetaData) if ( ser.isFocusStackingSeries == False and ser.isExposureSeries == False ): if Camera().when_series_photo_taken: Camera().when_series_photo_taken() except Exception as e: ser.nextStatus("pause") stop = True logger.error( "Thread %s: Camera._photoSeriesThread - Error: %s", get_ident(), e, ) ser.error = "Error in photoseries: " + str(e) ser.errorSource = "Camera._photoSeriesThread" if not sc.error and not ser.error: # Draw histogram if ser.isExposureSeries and sc.useHistograms: dest = ser.histogramPath + "/" + nextPhoto + ".jpg" plt.figure() img = cv2.imread(fpjpg) color = ("b", "g", "r") for i, col in enumerate(color): histr = cv2.calcHist([img], [i], None, [256], [0, 256]) plt.plot(histr, color=col) plt.xlim([0, 256]) plt.savefig(dest) logger.debug( "Thread %s: Camera._photoSeriesThread - histogram created: %s", get_ident(), dest, ) plt.close() # For exposure series apply controls if ser.isExposureSeries: ser.logCamCfgCtrl( nextPhoto, Camera.ctrl.configuration.make_dict(), photoseriesCtrls.make_dict(), ) if not stop: exceptValue = expFact * exceptValue logger.debug( "Thread %s: Camera._photoSeriesThread - Exposure Series for %s: %s", get_ident(), exceptCtrl, exceptValue, ) photoseriesCtrls = Camera.applyControls( Camera.ctrl.configuration, exceptCtrl, exceptValue ) logger.debug( "Thread %s: Camera._photoSeriesThread - selected controls applied", get_ident(), ) # For focus series apply controls if ser.isFocusStackingSeries: ser.logCamCfgCtrl( nextPhoto, Camera.ctrl.configuration.make_dict(), photoseriesCtrls.make_dict(), ) if not stop: exceptValueRaw = exceptValueRaw + ser.focalDistStep exceptValue = 1.0 / exceptValueRaw logger.debug( "Thread %s: Camera._photoSeriesThread - Focus Series for %s: %s (focal dist: %s)", get_ident(), exceptCtrl, exceptValue, exceptValueRaw, ) photoseriesCtrls = Camera.applyControls( Camera.ctrl.configuration, exceptCtrl, exceptValue ) logger.debug( "Thread %s: Camera._photoSeriesThread - selected controls applied", get_ident(), ) Camera.photoSeriesThread = None Camera.stopPhotoSeriesRequested = False sc.isPhotoSeriesRecording = False Camera.cam = Camera.ctrl.restoreLivestream(Camera.cam, exclusive) if sc.isVideoRecording == False and sc.isLiveStream == False: Camera.cam, done = Camera.ctrl.requestStop(Camera.cam, close=True) logger.debug( "Thread %s: Camera._photoSeriesThread - photoSeriesThread terminated", get_ident(), ) @staticmethod def startPhotoSeries(ser: Series): """Run photoseries in an own thread""" logger.debug("Thread %s: startPhotoSeries - series=%s", get_ident(), ser.name) if Camera.photoSeriesThread is None: logger.debug( "Thread %s: startPhotoSeries - Starting new photoSeriesThread", get_ident(), ) Camera.photoSeries = ser Camera.photoSeriesThread = threading.Thread( target=Camera._photoSeriesThread, daemon=True ) Camera.photoSeriesThread.start() logger.debug( "Thread %s: startPhotoSeries - photoSeriesThread started", get_ident() ) @staticmethod def stopPhotoSeries(): """stops the photo series""" logger.debug("Thread %s: stopPhotoSeries", get_ident()) Camera.stopPhotoSeriesRequested = True cnt = 0 while Camera.photoSeriesThread: time.sleep(0.01) cnt += 1 if cnt > 500: Camera.photoSeriesThread = None CameraCfg().serverConfig.isPhotoSeriesRecording = False # raise TimeoutError("Photoseries thread did not stop within 5 sec") logger.debug( "Thread %s: stopPhotoSeries: Thread seams to be dead", get_ident() ) break logger.debug("Thread %s: stopPhotoSeries: Thread has stopped", get_ident()) Camera.stopPhotoSeriesRequested = False @classmethod def cameraStatus(cls, camNum) -> str: status = "" sc = CameraCfg().serverConfig if camNum == cls.camNum: if cls.camIsUsb == False: if cls.cam.is_open == True: status = "open" if cls.cam.started == True: status = status + " - started" mode = "unknown" if useSensorConfiguration: sc = cls.cam.camera_config["sensor"] for sm in CameraCfg().sensorModes: if ( sc["output_size"] == sm.size and sc["bit_depth"] == sm.bit_depth ): mode = str(sm.id) status = status + " - current Sensor Mode: " + mode else: status = status + " - stopped" else: status = "closed" else: if cls.cam.isOpened() == True: status = "open" else: status = "closed" elif camNum == cls.camNum2: if cls.cam2IsUsb == False: if cls.cam2.is_open == True: status = "open" if cls.cam2.started == True: status = status + " - started" else: status = status + " - stopped" else: status = "closed" else: if cls.cam2.isOpened() == True: status = "open" else: status = "closed" else: if sc.supportsUsbCamera == True: if sc.useUsbCameras == True: status = "inactive" else: status = "excluded" else: status = "not supported (OpenCV missing)" return status @classmethod def resetScalerCrop(cls): logger.debug("Thread %s: Camera.resetScalerCrop", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig cc = cfg.controls cp = cfg.cameraProperties scInf = cls.cam.camera_controls["ScalerCrop"] sc.scalerCropMin = scInf[0] sc.scalerCropMax = scInf[1] sc.scalerCropDef = scInf[2] sc.zoomFactor = 100 sc.scalerCropLiveView = sc.scalerCropDef if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True sc.zoomFactor = sc.zoomFactorStep * math.floor( (100 * cc.scalerCrop[2] / cp.pixelArraySize[0]) / sc.zoomFactorStep) else: cc.include_scalerCrop = False cls.resetScalerCropRequested = False @classmethod def resetScalerCropUsb(cls): logger.debug("Thread %s: Camera.resetScalerCropUsb", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig cc = cfg.controls cp = cfg.cameraProperties ref = cfg.liveViewConfig.stream_size sc.scalerCropMax = Camera.getUsbScalerCrop(ref[0], ref[1]) sc.scalerCropMin = (0, 0, sc.scalerCropMax[2] / 100, sc.scalerCropMax[3] / 100) sc.scalerCropDef = sc.scalerCropMax sc.zoomFactor = 100 sc.scalerCropLiveView = sc.scalerCropDef if cc.scalerCrop == cfg.cameraProperties.scalerCropMaximum: cc.scalerCrop = sc.scalerCropDef if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True sc.zoomFactor = sc.zoomFactorStep * math.floor( (100 * cc.scalerCrop[2] / cp.pixelArraySize[0]) / sc.zoomFactorStep) else: cc.include_scalerCrop = False cls.resetScalerCropRequested = False @staticmethod def resetAiCache(): logger.debug("Thread %s: Camera.resetAiCache", get_ident()) Camera.cam_imx500 = None Camera.cam_imx500_last_detections = [] Camera.cam_imx500_last_results = None Camera.cam_imx500_labels = None Camera.cam_imx500_last_boxes = None Camera.cam_imx500_last_scores = None Camera.cam_imx500_last_keypoints = None Camera.cam_imx500_WINDOW_SIZE_H_W = (480, 640) Camera.cam_drawer = None @staticmethod def resetAiCache2(): logger.debug("Thread %s: Camera.resetAiCache2", get_ident()) Camera.cam2_imx500 = None Camera.cam2_imx500_last_detections = [] Camera.cam2_imx500_last_results = None Camera.cam2_imx500_labels = None Camera.cam2_imx500_last_boxes = None Camera.cam2_imx500_last_scores = None Camera.cam2_imx500_last_keypoints = None Camera.cam2_imx500_WINDOW_SIZE_H_W = (480, 640) Camera.cam2_drawer = None @staticmethod def get_label(request: CompletedRequest, idx: int) -> str: """Classification: Retrieve the label corresponding to the classification index.""" if Camera.cam_imx500_labels is None: Camera.cam_imx500_labels = Camera.cam_imx500.network_intrinsics.labels output_tensor_size = Camera.cam_imx500.get_output_shapes(request.get_metadata())[0][0] if output_tensor_size == 1000: Camera.cam_imx500_labels = Camera.cam_imx500_labels[1:] # Ignore the background label if present return Camera.cam_imx500_labels[idx] @staticmethod def parse_and_draw_classification_results(request: CompletedRequest): """Classification: Analyse and draw the classification results in the output tensor.""" results = Camera.parse_classification_results(request) Camera.draw_classification_results(request, results) @staticmethod def parse_classification_results(request: CompletedRequest) -> List[Classification]: """Classification: Parse the output tensor into the classification results above the threshold.""" cfg = CameraCfg() ai = cfg.aiConfig np_outputs = Camera.cam_imx500.get_outputs(request.get_metadata()) if np_outputs is None: return Camera.cam_imx500_last_detections np_output = np_outputs[0] if Camera.cam_imx500.network_intrinsics.softmax: np_output = softmax(np_output) top_indices = np.argpartition(-np_output, ai.topK)[:ai.topK] # Get top K indices with the highest scores top_indices = top_indices[np.argsort(-np_output[top_indices])] # Sort the top K indices by their scores Camera.cam_imx500_last_detections = [Classification(index, np_output[index]) for index in top_indices] return Camera.cam_imx500_last_detections @staticmethod def draw_classification_results(request: CompletedRequest, results: List[Classification], stream: str = "lores"): """Classification: Draw the classification results for this request onto the ISP output.""" cfg = CameraCfg() ai = cfg.aiConfig for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: if Camera.cam_imx500.network_intrinsics.preserve_aspect_ratio: # Drawing ROI box b_x, b_y, b_w, b_h = Camera.cam_imx500.get_roi_scaled(request, stream) color = (255, 0, 0) # red cv2.putText(m.array, "ROI", (b_x + 5, b_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.rectangle(m.array, (b_x, b_y), (b_x + b_w, b_y + b_h), (255, 0, 0, 0)) text_left, text_top = b_x, b_y + 20 else: text_left, text_top = 0, 0 # Drawing labels (in the ROI box if it exists) for index, result in enumerate(results): label = Camera.get_label(request, idx=result.idx) text = f"{label}: {result.score:.3f}" # Calculate text size and position (text_width, text_height), baseline = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) text_x = text_left + 5 text_y = text_top + 15 + index * 20 # Create a copy of the array to draw the background with opacity overlay = m.array.copy() # Draw the background rectangle on the overlay cv2.rectangle(overlay, (text_x, text_y - text_height), (text_x + text_width, text_y + baseline), (255, 255, 255), # Background color (white) cv2.FILLED) alpha = 0.3 cv2.addWeighted(overlay, alpha, m.array, 1 - alpha, 0, m.array) # Draw text on top of the background cv2.putText(m.array, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) @staticmethod def ai_output_tensor_parse(metadata: dict): """Pose Estimation: Parse the output tensor into a number of detected objects, scaled to the ISP output.""" np_outputs = Camera.cam_imx500.get_outputs(metadata=metadata, add_batch=True) if np_outputs is not None: keypoints, scores, boxes = postprocess_higherhrnet(outputs=np_outputs, img_size=Camera.cam_imx500_WINDOW_SIZE_H_W, img_w_pad=(0, 0), img_h_pad=(0, 0), detection_threshold=CameraCfg().aiConfig.detectionThreshold, network_postprocess=True) if scores is not None and len(scores) > 0: Camera.cam_imx500_last_keypoints = np.reshape(np.stack(keypoints, axis=0), (len(scores), 17, 3)) Camera.cam_imx500_last_boxes = [np.array(b) for b in boxes] Camera.cam_imx500_last_scores = np.array(scores) return Camera.cam_imx500_last_boxes, Camera.cam_imx500_last_scores, Camera.cam_imx500_last_keypoints @staticmethod def ai_output_tensor_draw(request: CompletedRequest, boxes, scores, keypoints, stream='lores'): """Pose Estimation: Draw the detections for this request onto the ISP output.""" cfg = CameraCfg() ai = cfg.aiConfig detection_threshold = ai.detectionThreshold for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: if boxes is not None and len(boxes) > 0: Camera.cam_imx500_drawer.annotate_image(m.array, boxes, scores, np.zeros(scores.shape), keypoints, detection_threshold, detection_threshold, request.get_metadata(), Camera.cam, stream) @staticmethod def picamera2_pre_callback(request: CompletedRequest): """Pose Estimation: Analyse the detected objects in the output tensor and draw them on the main output image.""" boxes, scores, keypoints = Camera.ai_output_tensor_parse(request.get_metadata()) Camera.ai_output_tensor_draw(request, boxes, scores, keypoints) @staticmethod def set_drawer(): """Pose Estimation: Set up the drawer for IMX500 pose estimation.""" categories = Camera.cam_imx500.network_intrinsics.labels categories = [c for c in categories if c and c != "-"] Camera.cam_imx500_drawer = COCODrawer(categories, Camera.cam_imx500, needs_rescale_coords=False) @staticmethod def parse_detections(metadata: dict): # logger.debug("Thread %s: Camera.parse_detections", get_ident()) """Object Detection: Parse the output tensor into a number of detected objects, scaled to the ISP output.""" cfg = CameraCfg() ai = cfg.aiConfig bbox_normalization = Camera.cam_imx500.network_intrinsics.bbox_normalization bbox_order = Camera.cam_imx500.network_intrinsics.bbox_order threshold = ai.detectionThreshold iou = ai.iouThreshold max_detections = ai.maxDetections np_outputs = Camera.cam_imx500.get_outputs(metadata, add_batch=True) input_w, input_h = Camera.cam_imx500.get_input_size() # logger.debug("Thread %s: Camera.parse_detections - got input_size", get_ident()) if np_outputs is None: # logger.debug("Thread %s: Camera.parse_detections - np_outputs is None", get_ident()) return Camera.cam_imx500_last_detections if Camera.cam_imx500.network_intrinsics.postprocess == "nanodet": # logger.debug("Thread %s: Camera.parse_detections - postprocess == nanodet", get_ident()) boxes, scores, classes = \ postprocess_nanodet_detection(outputs=np_outputs[0], conf=threshold, iou_thres=iou, max_out_dets=max_detections)[0] from picamera2.devices.imx500.postprocess import scale_boxes boxes = scale_boxes(boxes, 1, 1, input_h, input_w, False, False) else: # logger.debug("Thread %s: Camera.parse_detections - postprocess != nanodet", get_ident()) boxes, scores, classes = np_outputs[0][0], np_outputs[1][0], np_outputs[2][0] if bbox_normalization: boxes = boxes / input_h if bbox_order == "xy": boxes = boxes[:, [1, 0, 3, 2]] boxes = np.array_split(boxes, 4, axis=1) boxes = zip(*boxes) # logger.debug("Thread %s: Camera.parse_detections - Registring last detections", get_ident()) Camera.cam_imx500_last_detections = [ Detection(box, category, score, metadata) for box, score, category in zip(boxes, scores, classes) if score > threshold ] # logger.debug("Thread %s: Camera.parse_detections - found %s detections", get_ident(), len(Camera.cam_imx500_last_detections)) return Camera.cam_imx500_last_detections @staticmethod @lru_cache def get_labels(): """Object Detection: get labels.""" labels = Camera.cam_imx500.network_intrinsics.labels if Camera.cam_imx500.network_intrinsics.ignore_dash_labels: labels = [label for label in labels if label and label != "-"] return labels @staticmethod def draw_detections(request, stream="main"): """Object Detection: Draw the detections for this request onto the ISP output.""" # logger.debug("Thread %s: Camera.draw_detections", get_ident()) detections = Camera.cam_imx500_last_results if detections is None: return if len(detections) == 0: return labels = Camera.get_labels() cfg = CameraCfg() ai = cfg.aiConfig for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): # logger.debug("Thread %s: Camera.draw_detections - drawing on %s", get_ident(), stream) with MappedArray(request, stream) as m: for detection in detections: detectionOK = False if stream == "lores": if detection.box is not None: detectionOK = True x, y, w, h = detection.box else: if detection.box_main is not None: detectionOK = True x, y, w, h = detection.box_main if detectionOK: label = f"{labels[int(detection.category)]} ({detection.conf:.2f})" # Calculate text size and position (text_width, text_height), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) text_x = x + 5 text_y = y + 15 # Create a copy of the array to draw the background with opacity overlay = m.array.copy() # Draw the background rectangle on the overlay cv2.rectangle(overlay, (text_x, text_y - text_height), (text_x + text_width, text_y + baseline), (255, 255, 255), # Background color (white) cv2.FILLED) alpha = 0.30 cv2.addWeighted(overlay, alpha, m.array, 1 - alpha, 0, m.array) # Draw text on top of the background cv2.putText(m.array, label, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) # Draw detection box cv2.rectangle(m.array, (x, y), (x + w, y + h), (0, 255, 0, 0), thickness=2) if Camera.cam_imx500.network_intrinsics.preserve_aspect_ratio: b_x, b_y, b_w, b_h = Camera.cam_imx500.get_roi_scaled(request, stream) color = (255, 0, 0) # red cv2.putText(m.array, "ROI", (b_x + 5, b_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.rectangle(m.array, (b_x, b_y), (b_x + b_w, b_y + b_h), (255, 0, 0, 0)) @staticmethod def create_and_draw_masks(request: CompletedRequest): """Segmentation: Create masks from the output tensor and draw them on the main output image.""" masks = Camera.create_masks(request) if masks: Camera.cam_imx500_last_overlay = Camera.compose_overlay(masks) if Camera.cam_imx500_last_overlay is not None: Camera.draw_masks(request, Camera.cam_imx500_last_overlay) @staticmethod def create_masks(request: CompletedRequest) -> Dict[int, np.ndarray]: """Segmentation: Create masks from the output tensor, scaled to the ISP output.""" res = {} np_outputs = Camera.cam_imx500.get_outputs(metadata=request.get_metadata()) input_w, input_h = Camera.cam_imx500.get_input_size() if np_outputs is None: return res mask = np_outputs[0] found_indices = np.unique(mask) for i in found_indices: if i == 0: continue output_shape = [input_h, input_w, 4] colour = [(0, 0, 0, 0), Camera.COLOURS[int(i)]] colour[1][3] = 150 # update the alpha value here, to save setting it later overlay = np.array(mask == i, dtype=np.uint8) overlay = np.array(colour)[overlay].reshape(output_shape).astype(np.uint8) # No need to resize the overlay, it will be stretched to the output window. res[i] = overlay return res @staticmethod def compose_overlay(masks): """Segmentation: Compose overlay from masks.""" input_w, input_h = Camera.cam_imx500.get_input_size() overlay = np.zeros((input_h, input_w, 4), dtype=np.uint8) for v in masks.values(): overlay += v return overlay @staticmethod def draw_masks(request: CompletedRequest, overlay: np.ndarray): """Segmentation: Draw masks.""" alpha = overlay[:, :, 3:4] / 255.0 overlay_rgb = overlay[:, :, :3] cfg = CameraCfg() ai = cfg.aiConfig for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: frame = m.array # HxWx3 RGB h, w, _ = frame.shape ov = cv2.resize(overlay_rgb, (w, h), interpolation=cv2.INTER_NEAREST) a = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_NEAREST)[:, :, np.newaxis] frame[:] = (a * ov + (1.0 - a) * frame).astype(np.uint8) @staticmethod def cam2_get_label(request: CompletedRequest, idx: int) -> str: """Classification: Retrieve the label corresponding to the classification index.""" if Camera.cam2_imx500_labels is None: Camera.cam2_imx500_labels = Camera.cam2_imx500.network_intrinsics.labels output_tensor_size = Camera.cam2_imx500.get_output_shapes(request.get_metadata())[0][0] if output_tensor_size == 1000: Camera.cam2_imx500_labels = Camera.cam2_imx500_labels[1:] # Ignore the background label if present return Camera.cam2_imx500_labels[idx] @staticmethod def cam2_parse_and_draw_classification_results(request: CompletedRequest): """Classification: Analyse and draw the classification results in the output tensor.""" results = Camera.cam2_parse_classification_results(request) Camera.cam2_draw_classification_results(request, results) @staticmethod def cam2_parse_classification_results(request: CompletedRequest) -> List[Classification]: """Classification: Parse the output tensor into the classification results above the threshold.""" cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() np_outputs = Camera.cam2_imx500.get_outputs(request.get_metadata()) if np_outputs is None: return Camera.cam2_imx500_last_detections np_output = np_outputs[0] if Camera.cam2_imx500.network_intrinsics.softmax: np_output = softmax(np_output) top_indices = np.argpartition(-np_output, ai.topK)[:ai.topK] # Get top 3 indices with the highest scores top_indices = top_indices[np.argsort(-np_output[top_indices])] # Sort the top 3 indices by their scores Camera.cam2_imx500_last_detections = [Classification(index, np_output[index]) for index in top_indices] return Camera.cam2_imx500_last_detections @staticmethod def cam2_draw_classification_results(request: CompletedRequest, results: List[Classification], stream: str = "lores"): """Classification: Draw the classification results for this request onto the ISP output.""" cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: if Camera.cam2_imx500.network_intrinsics.preserve_aspect_ratio: # Drawing ROI box b_x, b_y, b_w, b_h = Camera.cam2_imx500.get_roi_scaled(request, stream) color = (255, 0, 0) # red cv2.putText(m.array, "ROI", (b_x + 5, b_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.rectangle(m.array, (b_x, b_y), (b_x + b_w, b_y + b_h), (255, 0, 0, 0)) text_left, text_top = b_x, b_y + 20 else: text_left, text_top = 0, 0 # Drawing labels (in the ROI box if it exists) for index, result in enumerate(results): label = Camera.cam2_get_label(request, idx=result.idx) text = f"{label}: {result.score:.3f}" # Calculate text size and position (text_width, text_height), baseline = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) text_x = text_left + 5 text_y = text_top + 15 + index * 20 # Create a copy of the array to draw the background with opacity overlay = m.array.copy() # Draw the background rectangle on the overlay cv2.rectangle(overlay, (text_x, text_y - text_height), (text_x + text_width, text_y + baseline), (255, 255, 255), # Background color (white) cv2.FILLED) alpha = 0.3 cv2.addWeighted(overlay, alpha, m.array, 1 - alpha, 0, m.array) # Draw text on top of the background cv2.putText(m.array, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) @staticmethod def cam2_ai_output_tensor_parse(metadata: dict): """Pose Estimation: Parse the output tensor into a number of detected objects, scaled to the ISP output.""" np_outputs = Camera.cam2_imx500.get_outputs(metadata=metadata, add_batch=True) if np_outputs is not None: cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() keypoints, scores, boxes = postprocess_higherhrnet(outputs=np_outputs, img_size=Camera.cam2_imx500_WINDOW_SIZE_H_W, img_w_pad=(0, 0), img_h_pad=(0, 0), detection_threshold=ai.detectionThreshold, network_postprocess=True) if scores is not None and len(scores) > 0: Camera.cam2_imx500_last_keypoints = np.reshape(np.stack(keypoints, axis=0), (len(scores), 17, 3)) Camera.cam2_imx500_last_boxes = [np.array(b) for b in boxes] Camera.cam2_imx500_last_scores = np.array(scores) return Camera.cam2_imx500_last_boxes, Camera.cam2_imx500_last_scores, Camera.cam2_imx500_last_keypoints @staticmethod def cam2_ai_output_tensor_draw(request: CompletedRequest, boxes, scores, keypoints, stream='lores'): """Pose Estimation: Draw the detections for this request onto the ISP output.""" cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() detection_threshold = ai.detectionThreshold for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: if boxes is not None and len(boxes) > 0: Camera.cam2_imx500_drawer.annotate_image(m.array, boxes, scores, np.zeros(scores.shape), keypoints, detection_threshold, detection_threshold, request.get_metadata(), Camera.cam2, stream) @staticmethod def cam2_picamera2_pre_callback(request: CompletedRequest): """Pose Estimation: Analyse the detected objects in the output tensor and draw them on the main output image.""" boxes, scores, keypoints = Camera.cam2_ai_output_tensor_parse(request.get_metadata()) Camera.cam2_ai_output_tensor_draw(request, boxes, scores, keypoints) @staticmethod def cam2_set_drawer(): """Pose Estimation: Set up the drawer for IMX500 pose estimation.""" categories = Camera.cam2_imx500.network_intrinsics.labels categories = [c for c in categories if c and c != "-"] Camera.cam2_imx500_drawer = COCODrawer(categories, Camera.cam2_imx500, needs_rescale_coords=False) @staticmethod def cam2_parse_detections(metadata: dict): """Object Detection: Parse the output tensor into a number of detected objects, scaled to the ISP output.""" cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() bbox_normalization = Camera.cam2_imx500.network_intrinsics.bbox_normalization bbox_order = Camera.cam2_imx500.network_intrinsics.bbox_order threshold = ai.detectionThreshold iou = ai.iouThreshold max_detections = ai.maxDetections np_outputs = Camera.cam2_imx500.get_outputs(metadata, add_batch=True) input_w, input_h = Camera.cam2_imx500.get_input_size() if np_outputs is None: return Camera.cam2_imx500_last_detections if Camera.cam2_imx500.network_intrinsics.postprocess == "nanodet": boxes, scores, classes = \ postprocess_nanodet_detection(outputs=np_outputs[0], conf=threshold, iou_thres=iou, max_out_dets=max_detections)[0] from picamera2.devices.imx500.postprocess import scale_boxes boxes = scale_boxes(boxes, 1, 1, input_h, input_w, False, False) else: boxes, scores, classes = np_outputs[0][0], np_outputs[1][0], np_outputs[2][0] if bbox_normalization: boxes = boxes / input_h if bbox_order == "xy": boxes = boxes[:, [1, 0, 3, 2]] boxes = np.array_split(boxes, 4, axis=1) boxes = zip(*boxes) Camera.cam2_imx500_last_detections = [ Cam2Detection(box, category, score, metadata) for box, score, category in zip(boxes, scores, classes) if score > threshold ] return Camera.cam2_imx500_last_detections @staticmethod @lru_cache def cam2_get_labels(): """Object Detection: get labels.""" labels = Camera.cam2_imx500.network_intrinsics.labels if Camera.cam2_imx500.network_intrinsics.ignore_dash_labels: labels = [label for label in labels if label and label != "-"] return labels @staticmethod def cam2_draw_detections(request, stream="main"): """Object Detection: Draw the detections for this request onto the ISP output.""" detections = Camera.cam2_imx500_last_results if detections is None: return if len(detections) == 0: return labels = Camera.cam2_get_labels() cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: for detection in detections: if stream == "lores": x, y, w, h = detection.box else: x, y, w, h = detection.box_main label = f"{labels[int(detection.category)]} ({detection.conf:.2f})" # Calculate text size and position (text_width, text_height), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) text_x = x + 5 text_y = y + 15 # Create a copy of the array to draw the background with opacity overlay = m.array.copy() # Draw the background rectangle on the overlay cv2.rectangle(overlay, (text_x, text_y - text_height), (text_x + text_width, text_y + baseline), (255, 255, 255), # Background color (white) cv2.FILLED) alpha = 0.30 cv2.addWeighted(overlay, alpha, m.array, 1 - alpha, 0, m.array) # Draw text on top of the background cv2.putText(m.array, label, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) # Draw detection box cv2.rectangle(m.array, (x, y), (x + w, y + h), (0, 255, 0, 0), thickness=2) if Camera.cam2_imx500.network_intrinsics.preserve_aspect_ratio: b_x, b_y, b_w, b_h = Camera.cam2_imx500.get_roi_scaled(request, stream) color = (255, 0, 0) # red cv2.putText(m.array, "ROI", (b_x + 5, b_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.rectangle(m.array, (b_x, b_y), (b_x + b_w, b_y + b_h), (255, 0, 0, 0)) @staticmethod def cam2_create_and_draw_masks(request: CompletedRequest): """Segmentation: Create masks from the output tensor and draw them on the main output image.""" masks = Camera.cam2_create_masks(request) if masks: Camera.cam2_imx500_last_overlay = Camera.cam2_compose_overlay(masks) if Camera.cam2_imx500_last_overlay is not None: Camera.cam2_draw_masks(request, Camera.cam2_imx500_last_overlay) @staticmethod def cam2_create_masks(request: CompletedRequest) -> Dict[int, np.ndarray]: """Segmentation: Create masks from the output tensor, scaled to the ISP output.""" res = {} np_outputs = Camera.cam2_imx500.get_outputs(metadata=request.get_metadata()) input_w, input_h = Camera.cam2_imx500.get_input_size() if np_outputs is None: return res mask = np_outputs[0] found_indices = np.unique(mask) for i in found_indices: if i == 0: continue output_shape = [input_h, input_w, 4] colour = [(0, 0, 0, 0), Camera.COLOURS[int(i)]] colour[1][3] = 150 # update the alpha value here, to save setting it later overlay = np.array(mask == i, dtype=np.uint8) overlay = np.array(colour)[overlay].reshape(output_shape).astype(np.uint8) # No need to resize the overlay, it will be stretched to the output window. res[i] = overlay return res @staticmethod def cam2_compose_overlay(masks): """Segmentation: Compose overlay from masks.""" input_w, input_h = Camera.cam2_imx500.get_input_size() overlay = np.zeros((input_h, input_w, 4), dtype=np.uint8) for v in masks.values(): overlay += v return overlay @staticmethod def cam2_draw_masks(request: CompletedRequest, overlay: np.ndarray): """Segmentation: Draw masks.""" alpha = overlay[:, :, 3:4] / 255.0 overlay_rgb = overlay[:, :, :3] cfg = CameraCfg() scfg = cfg.streamingCfg[str(Camera.camNum2)] if "aiconfig" in scfg: ai = scfg["aiconfig"] else: ai = AiConfig() for stream in ["lores", "main"]: if (stream == "lores" and ai.drawOnLores == True) or (stream == "main" and ai.drawOnMain == True): with MappedArray(request, stream) as m: frame = m.array # HxWx3 RGB h, w, _ = frame.shape ov = cv2.resize(overlay_rgb, (w, h), interpolation=cv2.INTER_NEAREST) a = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_NEAREST)[:, :, np.newaxis] frame[:] = (a * ov + (1.0 - a) * frame).astype(np.uint8) ================================================ FILE: raspiCamSrv/config.py ================================================ from flask import ( Blueprint, Response, flash, g, redirect, render_template, request, url_for, current_app, ) from flask import send_file from werkzeug.exceptions import abort from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.camera_pi import Camera from raspiCamSrv.version import version from picamera2 import Picamera2 import os import shutil import json import time from raspiCamSrv.auth import login_required import logging # Try to import platform, which does not exist in Bullseye Picamera2 distributions try: import picamera2.platform as Platform usePlatform = True except ImportError: usePlatform = False bp = Blueprint("config", __name__) logger = logging.getLogger(__name__) @bp.route("/config") @login_required def main(): g.hostname = request.host g.version = version # Although not directly needed here, the camara needs to be initialized # in order to load the camera-specific parameters into configuration cam = Camera().cam cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.curMenu = "config" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) def doSyncTransform(hflip: bool, vflip: bool, tgt: list) -> bool: """Synchronize the transform settings of target configurations with reference Parameters: hflip: horizontal flip vflip: vertical flip tgt : list of configurations for which to adjust the aspect ratio Return: True if transform settings for Live View was changed """ logger.debug("In doSyncTransform") ret = False cfg = CameraCfg() cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig for conf in tgt: if conf == "Live View": if cfglive.transform_hflip != hflip or cfglive.transform_vflip != vflip: ret = True cfglive.transform_hflip = hflip cfglive.transform_vflip = vflip elif conf == "Photo": cfgphoto.transform_hflip = hflip cfgphoto.transform_vflip = vflip elif conf == "Raw Photo": cfgraw.transform_hflip = hflip cfgraw.transform_vflip = vflip elif conf == "Video": cfgvideo.transform_hflip = hflip cfgvideo.transform_vflip = vflip logger.debug("doSyncTransform %s", ret) return ret def doSyncAspectRatio(ref: tuple, tgt: list) -> bool: """Synchronize the aspect ratio of target configurations with reference Parameters: ref: reference size (width, height) tgt: list of configurations for which to adjust the aspect ratio Return: True if Stream Size for Live View was changed """ logger.debug("In doSyncAspectRatio") ret = False cfg = CameraCfg() aspRatioRef = ref[0] / ref[1] for conf in tgt: if conf == "Live View": size = cfg.liveViewConfig.stream_size elif conf == "Photo": size = cfg.photoConfig.stream_size elif conf == "Raw Photo": size = cfg.rawConfig.stream_size elif conf == "Video": size = cfg.videoConfig.stream_size else: size = None if not size is None: log = f"Changed Stream Size for {conf} from {size} to " aspRatio = size[0] / size[1] if aspRatio != aspRatioRef: width = size[0] height = round(size[0] / aspRatioRef) if not (height % 2) == 0: height += 1 if height > cfg.cameraProperties.pixelArraySize[1]: height = cfg.cameraProperties.pixelArraySize[1] width = round(height * aspRatioRef) if not (width % 2) == 0: width += 1 size = (width, height) sm = "custom" for mode in cfg.sensorModes: if mode.size[0] == width and mode.size[1] == height: sm = str(mode.id) break logger.debug(log + str(size)) if conf == "Live View": cfg.liveViewConfig.stream_size = size cfg.liveViewConfig.sensor_mode = sm ret = True elif conf == "Photo": cfg.photoConfig.stream_size = size cfg.photoConfig.sensor_mode = sm elif conf == "Raw Photo": cfg.rawConfig.stream_size = size cfg.rawConfig.sensor_mode = sm elif conf == "Video": cfg.videoConfig.stream_size = size cfg.videoConfig.sensor_mode = sm else: pass logger.debug("doSyncAspectRatio %s", ret) return ret @bp.route("/syncAspectRatio", methods=("GET", "POST")) @login_required def syncAspectRatio(): logger.debug("In syncAspectRatio") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": lastTab = sc.lastConfigTab selTab = request.form.get("activecfgtab") if selTab != "-": lastTab = selTab sc.lastConfigTab = lastTab syncAspectRatio = not request.form.get("syncaspectratio") is None sc.syncAspectRatio = syncAspectRatio logger.debug("syncAspectRatio - lastTab: %s", lastTab) if syncAspectRatio == True: if lastTab == "cfglive": aspRef = cfglive.stream_size aspTgt = ["Photo", "Raw Photo", "Video"] doSyncAspectRatio(aspRef, aspTgt) elif lastTab == "cfgphoto": aspRef = cfgphoto.stream_size aspTgt = ["Live View", "Raw Photo", "Video"] doSyncAspectRatio(aspRef, aspTgt) elif lastTab == "cfgraw": aspRef = cfgraw.stream_size aspTgt = ["Live View", "Photo", "Video"] doSyncAspectRatio(aspRef, aspTgt) elif lastTab == "cfgvideo": aspRef = cfgvideo.stream_size aspTgt = ["Live View", "Photo", "Raw Photo"] doSyncAspectRatio(aspRef, aspTgt) else: pass Camera.resetScalerCropRequested = True Camera().restartLiveStream() sc.unsavedChanges = True sc.addChangeLogEntry(f"Sync Aspect Ratio set to {sc.syncAspectRatio}") cfg.streamingCfgInvalid = True return render_template( "config/main.html", sc=sc, tc=tc, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) def findTuningFile(tuning_file: str, dir=None) -> str: """Find the given tuning file and return its path Code has been copied from Picamera2.load_tuning_file(...) Args: - tuning_file (str): filename of tuning file - dir (str, optional): Directory to search. If None, search standard installation dirs Returns: - str: Path of tuning file; None, if not found """ tfPath = None if dir is not None: dirs = [dir] else: if usePlatform: platform_dir = ( "vc4" if Picamera2.platform == Platform.Platform.VC4 else "pisp" ) dirs = [ os.path.expanduser("~/libcamera/src/ipa/rpi/" + platform_dir + "/data"), "/usr/local/share/libcamera/ipa/rpi/" + platform_dir, "/usr/share/libcamera/ipa/rpi/" + platform_dir, ] else: dirs = [ os.path.expanduser("~/libcamera/src/ipa/rpi/vc4/data"), "/usr/local/share/libcamera/ipa/rpi/vc4", "/usr/share/libcamera/ipa/rpi/vc4", ] for directory in dirs: file = os.path.join(directory, tuning_file) if os.path.isfile(file): tfPath = file return tfPath def isTuningFile(file: str, folder: str) -> bool: logger.debug("In isTuningFile") logger.debug("isTuningFile - file=%s", file) logger.debug("isTuningFile - folder=%s", folder) res = False try: tf = Picamera2.load_tuning_file(file, folder) res = True except RuntimeError as e: res = False logger.debug("isTuningFile - res=%s", res) return res def getTuningFiles(folder, defFile) -> list: """Create a list of all .json files in the given folder Args: - folder (str): Folder to search - defFile (str): Name of default file Returns: - list: list with filenames of .json files """ tfl = [] defFileFound = False if folder is not None: if os.path.exists(folder): for f in os.listdir(folder): if os.path.isfile(os.path.join(folder, f)): if f == defFile: defFileFound = True nam, ext = os.path.splitext(f) if ext.lower() == ".json": tfl.append(f) if defFile: if defFileFound == False: tfl.append(defFile) return tfl @bp.route("/tuningCfg", methods=("GET", "POST")) @login_required def tuningCfg(): logger.debug("In tuningCfg") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None msg = "" restart = False if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the tuning configuration" msg = err if sc.isPhotoSeriesRecording: err = "Please go to 'Photo Series' and stop the active process before changing the tuning configuration" msg = err if sc.isVideoRecording == True: err = "Please stop video recording before changing the tuning configuration" msg = err if not err: loadTuningFile = not request.form.get("loadtuningfile") is None fd = request.form["tuningfolder"] if fd == "": fd = None fn = request.form["tuningfile"] if loadTuningFile: if isTuningFile(fn, fd) == True: tc.tuningFolder = fd tc.tuningFile = fn tc.loadTuningFile = loadTuningFile restart = True else: msg = "Specify an existing tuning file before activating to load it" else: tc.tuningFolder = fd tc.tuningFile = fn if tc.loadTuningFile != loadTuningFile: restart = True tc.loadTuningFile = loadTuningFile sc.unsavedChanges = True sc.addChangeLogEntry(f"Configuration for tuning changed") cfg.streamingCfgInvalid = True if restart: Camera().restartLiveStream() if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/customTuning", methods=("GET", "POST")) @login_required def customTuning(): logger.debug("In customTuning") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None msg = "" restart = False if tc.loadTuningFile == True: if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the tuning configuration" msg = err if sc.isPhotoSeriesRecording: err = "Please go to 'Photo Series' and stop the active process before changing the tuning configuration" msg = err if sc.isVideoRecording == True: err = "Please stop video recording before changing the tuning configuration" msg = err if not err: fd = tc.tuningFolder fn = tc.tuningFile fdCustom = current_app.static_folder + "/tuning" if fd == fdCustom: msg = "No changes. Custom folder was already set." else: try: os.makedirs(fdCustom, exist_ok=True) tc.tuningFolder = fdCustom except Exception as e: msg = "Error while creating custom folder " + fdCustom + ":" + e if msg == "": if fn != "": if isTuningFile(fn, fdCustom) == True: if tc.loadTuningFile == True: msg = "Tuning file switched to custom file." restart = True else: if isTuningFile(fn, None) == True: fpCustom = os.path.join(fdCustom, fn) fpDefault = findTuningFile(fn, None) if fpDefault is not None: try: shutil.copyfile(fpDefault, fpCustom) msg = ( "Tuning file " + fn + " copied to custom directory." ) if tc.loadTuningFile == True: restart = True except Exception as e: logger.debug( "error while copying tuning file: %s", str(e), ) msg = ( "Tuning file directory switched to custom directory, but tuning file " + fn + " could not be copied." ) fn = "" if tc.loadTuningFile == True: restart = True tc.loadTuningFile = False else: tc.tuningFile = "" if tc.loadTuningFile == True: restart = True tc.loadTuningFile = False else: tc.tuningFile = "" if tc.loadTuningFile == True: restart = True tc.loadTuningFile = False sc.unsavedChanges = True sc.addChangeLogEntry(f"Tuning folder set to custom folder") cfg.streamingCfgInvalid = True if restart: Camera().restartLiveStream() if len(msg) > 0: flash(msg) tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/defaultTuning", methods=("GET", "POST")) @login_required def defaultTuning(): logger.debug("In defaultTuning") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None msg = "" restart = False if tc.loadTuningFile == True: if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the tuning configuration" msg = err if sc.isPhotoSeriesRecording: err = "Please go to 'Photo Series' and stop the active process before changing the tuning configuration" msg = err if sc.isVideoRecording == True: err = "Please stop video recording before changing the tuning configuration" msg = err if not err: fd = tc.tuningFolder fn = tc.tuningFile fdDefault = tc.tuningFolderDef if fd == fdDefault: msg = "No changes. Default folder was already set." else: tc.tuningFolder = fdDefault if fn != "": if isTuningFile(fn, fd) == True: if tc.loadTuningFile == True: msg = "Tuning file switched to default file." restart = True else: fn = sc.activeCameraModel + ".json" if isTuningFile(fn, fd) == True: tc.tuningFile = fn if tc.loadTuningFile == True: msg = "Tuning file switched to default file." restart = True else: tc.tuningFile = "" if tc.loadTuningFile == True: restart = True sc.unsavedChanges = True sc.addChangeLogEntry(f"Tuning folder set to default folder") cfg.streamingCfgInvalid = True if restart: Camera().restartLiveStream() if len(msg) > 0: flash(msg) tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/deleteTuningFile", methods=("GET", "POST")) @login_required def deleteTuningFile(): logger.debug("In deleteTuningFile") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": restart = False fp = None if tc.isDefaultFolder == True: msg = "You cannot delete a tuning file from the default folder" else: fd = tc.tuningFolder fn = tc.tuningFile fp = findTuningFile(fn, fd) if fp is not None: os.remove(fp) msg = f"Tuning File deleted: {fp}" else: msg = "Tuning file not found" tc.tuningFile = "" if tc.loadTuningFile == True: restart = True tc.loadTuningFile = False tfl = getTuningFiles(tc.tuningFolder, None) if len(tfl) > 0: fn = sc.activeCameraModel + ".json" found = False for f in tfl: if f == fn: tc.tuningFile = fn found = True break if found == False: tc.tuningFile = tfl[0] else: logger.debug("deleteTuningFile - No more tuning files in custom folder") fn = sc.activeCameraModel + ".json" tc.tuningFolder = None logger.debug( "deleteTuningFile - fn=%s tuningFolder=%s isTuningFile=%s", fn, tc.tuningFolder, isTuningFile(fn, tc.tuningFolder), ) if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn logger.debug( "deleteTuningFile - tc.tuningFile set to %s", tc.tuningFile ) else: tc.loadTuningFile = False tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if not fp is None: sc.unsavedChanges = True sc.addChangeLogEntry(f"Tuning file deleted: {fp}") cfg.streamingCfgInvalid = True if restart: Camera().restartLiveStream() if msg != "": flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/downloadTuningFile", methods=("GET", "POST")) @login_required def downloadTuningFile(): logger.debug("In downloadTuningFile") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": fd = tc.tuningFolder fn = tc.tuningFile fp = findTuningFile(fn, fd) if fp is not None: msg = f"Downloading {fn}" flash(msg) return send_file(fp, as_attachment=True, download_name=fn) else: msg = "Tuning file not found" flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/uploadTuningFile", methods=("GET", "POST")) @login_required def uploadTuningFile(): logger.debug("In uploadTuningFile") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgtuning" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": msg = "" if tc.tuningFolder is None: msg = "You may only upload to a custom folder!" else: if os.path.exists(tc.tuningFolder) == False: try: os.makedirs(tc.tuningFolder) except Exception as e: msg = f"Error creating folder {tc.tuningFolder}: {str(e)}" if msg == "": if "tuningfile" not in request.files: msg = "No file to save" else: files = request.files.getlist("tuningfile") countSel = len(files) # tf = request.files["tuningfile"] logger.debug("uploadTuningFile - %s files selected", countSel) countUp = 0 for tf in files: fn = tf.filename logger.debug("uploadTuningFile - selected file: %s", fn) nam, ext = os.path.splitext(fn) if ext.lower() == ".json": fp = os.path.join(tc.tuningFolder, fn) tf.save(fp) msg = f"Tuning file saved as {fp}." countUp += 1 if countSel > 1: msg = f"{countUp} of {countSel} files uploaded." if countUp < countSel: msg = msg + " Not all files were .json files." if msg != "": flash(msg) tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/liveViewCfg", methods=("GET", "POST")) @login_required def liveViewCfg(): logger.debug("In liveViewCfg") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfglive" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" msg = err if not err: transform_hflip = not request.form.get("LIVE_transform_hflip") is None cfglive.transform_hflip = transform_hflip transform_vflip = not request.form.get("LIVE_transform_vflip") is None cfglive.transform_vflip = transform_vflip colour_space = request.form["LIVE_colour_space"] cfglive.colour_space = colour_space buffer_count = int(request.form["LIVE_buffer_count"]) cfglive.buffer_count = buffer_count queue = not request.form.get("LIVE_queue") is None cfglive.queue = queue stream = request.form["LIVE_stream"] sensor_mode = request.form["LIVE_sensor_mode"] format = request.form["LIVE_format"] cfglive.format = format if sensor_mode == "custom": size_width = int(request.form["LIVE_stream_size_width"]) if not (size_width % 2) == 0: err = "Stream Size (width, height) must be even" size_height = int(request.form["LIVE_stream_size_height"]) if not (size_height % 2) == 0: err = "Stream Size (width, height) must be even" if stream == "lores": if cfgphoto.stream == "main": if ( size_width > cfgphoto.stream_size[0] or size_height > cfgphoto.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Photo)" if not err and cfgvideo.stream == "main": if ( size_width > cfgvideo.stream_size[0] or size_height > cfgvideo.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Video)" if stream == "main": if cfgphoto.stream == "lores": if ( size_width < cfgphoto.stream_size[0] or size_height < cfgphoto.stream_size[1] ): err = "lores Stream Size (Photo) must not exceed main Stream Size" if not err and cfgvideo.stream == "lores": if ( size_width < cfgvideo.stream_size[0] or size_height < cfgvideo.stream_size[1] ): err = "lores Stream Size (Video) must not exceed main Stream Size" if not err: cfglive.stream = stream cfglive.sensor_mode = sensor_mode cfglive.stream_size = (size_width, size_height) cfglive.stream_size_align = ( not request.form.get("LIVE_stream_size_align") is None ) else: mode = sm[int(sensor_mode)] if stream == "lores": if cfgphoto.stream == "main": if ( mode.size[0] > cfgphoto.stream_size[0] or mode.size[1] > cfgphoto.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Photo)" if not err and cfgvideo.stream == "main": if ( mode.size[0] > cfgvideo.stream_size[0] or mode.size[1] > cfgvideo.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Video)" if stream == "main": if cfgphoto.stream == "lores": if ( mode.size[0] < cfgphoto.stream_size[0] or mode.size[1] < cfgphoto.stream_size[1] ): err = "lores Stream Size (Photo) must not exceed main Stream Size" if not err and cfgvideo.stream == "lores": if ( mode.size[0] < cfgvideo.stream_size[0] or mode.size[1] < cfgvideo.stream_size[1] ): err = "lores Stream Size (Video) must not exceed main Stream Size" if sc.activeCameraIsUsb == True: format = mode.format cfglive.format = format if not err: cfglive.stream = stream cfglive.sensor_mode = sensor_mode cfglive.stream_size = mode.size cfglive.stream_size_align = ( not request.form.get("LIVE_stream_size_align") is None ) cfglive.display = None if sc.activeCameraIsUsb == False: cfglive.encode = cfglive.stream else: cfglive.encode = None if sc.syncAspectRatio == True: doSyncAspectRatio(cfglive.stream_size, ["Photo", "Raw Photo", "Video"]) Camera.resetScalerCropRequested = True doSyncTransform( transform_hflip, transform_vflip, ["Photo", "Raw Photo", "Video"] ) Camera().restartLiveStream() msg = "" if err: msg = err if sc.raspiModelLower5: if cfglive.stream == "lores": if format == "YUV420": cfglive.format = format else: if msg != "": msg = msg + "\n" msg = ( msg + "For Raspberry Pi models < 5, the lowres stream format must be YUV" ) else: cfglive.format = format else: cfglive.format = format if cfglive.stream != "lores": if msg != "": msg = msg + "\n" if sc.activeCameraIsUsb == False: msg = ( msg + "WARNING: If you do not set Stream to 'lores', the Live Stream cannot be shown parallel to other activities!" ) sc.unsavedChanges = True sc.addChangeLogEntry(f"Configuration for Live View changed") cfg.streamingCfgInvalid = True if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/addLiveViewControls", methods=("GET", "POST")) @login_required def addLiveViewControls(): logger.debug("In addLiveViewControls") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfglive" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: for key, value in cc.dict().items(): if value[0] == True: if key not in cfg.liveViewConfig.controls: cfg.liveViewConfig.controls[key] = value[1] Camera().restartLiveStream() sc.unsavedChanges = True sc.addChangeLogEntry(f"Controls added to Configuration for Live View") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/remLiveViewControls", methods=("GET", "POST")) @login_required def remLiveViewControls(): logger.debug("In remLiveViewControls") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfglive" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: cnt = 0 for ctrl in cfg.liveViewConfig.controls: logger.debug("Checking checkbox ID:" + "sel_LIVE_" + ctrl) if request.form.get("sel_LIVE_" + ctrl) is not None: cnt += 1 logger.debug( "Nr controls: %s - selected: %s", len(cfg.liveViewConfig.controls), cnt ) if cnt > 0: if cnt < len(cfg.liveViewConfig.controls): while cnt > 0: for ctrl in cfg.liveViewConfig.controls: if request.form.get("sel_LIVE_" + ctrl) is not None: ctrlDel = ctrl break del cfg.liveViewConfig.controls[ctrlDel] cnt -= 1 Camera().restartLiveStream() else: msg = "At least one control must remain in the configuration" flash(msg) else: msg = "No controls were selected" flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Controls removed from Configuration for Live View") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/photoCfg", methods=("GET", "POST")) @login_required def photoCfg(): logger.debug("In photoCfg") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgphoto" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: transform_hflip = not request.form.get("FOTO_transform_hflip") is None cfgphoto.transform_hflip = transform_hflip transform_vflip = not request.form.get("FOTO_transform_vflip") is None cfgphoto.transform_vflip = transform_vflip colour_space = request.form["FOTO_colour_space"] cfgphoto.colour_space = colour_space buffer_count = int(request.form["FOTO_buffer_count"]) cfgphoto.buffer_count = buffer_count queue = not request.form.get("FOTO_queue") is None cfgphoto.queue = queue stream = request.form["FOTO_stream"] sensor_mode = request.form["FOTO_sensor_mode"] format = request.form["FOTO_format"] cfgphoto.format = format if sensor_mode == "custom": size_width = int(request.form["FOTO_stream_size_width"]) if not (size_width % 2) == 0: err = "Stream Size (width, height) must be even" size_height = int(request.form["FOTO_stream_size_height"]) if not (size_height % 2) == 0: err = "Stream Size (width, height) must be even" if stream == "lores": if cfglive.stream == "main": if ( size_width > cfglive.stream_size[0] or size_height > cfglive.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Live View)" if not err and cfgvideo.stream == "main": if ( size_width > cfgvideo.stream_size[0] or size_height > cfgvideo.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Video)" if stream == "main": if cfglive.stream == "lores": if ( size_width < cfglive.stream_size[0] or size_height < cfglive.stream_size[1] ): err = "lores Stream Size (Live View) must not exceed main Stream Size" if not err and cfgvideo.stream == "lores": if ( size_width < cfgvideo.stream_size[0] or size_height < cfgvideo.stream_size[1] ): err = "lores Stream Size (Video) must not exceed main Stream Size" if not err: cfgphoto.stream = stream cfgphoto.sensor_mode = sensor_mode cfgphoto.stream_size = (size_width, size_height) cfgphoto.stream_size_align = ( not request.form.get("FOTO_stream_size_align") is None ) else: mode = sm[int(sensor_mode)] if stream == "lores": if cfglive.stream == "main": if ( mode.size[0] > cfglive.stream_size[0] or mode.size[1] > cfglive.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Live View)" if not err and cfgvideo.stream == "main": if ( mode.size[0] > cfgvideo.stream_size[0] or mode.size[1] > cfgvideo.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Video)" if stream == "main": if cfglive.stream == "lores": if ( mode.size[0] < cfglive.stream_size[0] or mode.size[1] < cfglive.stream_size[1] ): err = "lores Stream Size (Live View) must not exceed main Stream Size" if not err and cfgvideo.stream == "lores": if ( mode.size[0] < cfgvideo.stream_size[0] or mode.size[1] < cfgvideo.stream_size[1] ): err = "lores Stream Size (Video) must not exceed main Stream Size" if sc.activeCameraIsUsb == True: format = mode.format cfgphoto.format = format if not err: cfgphoto.stream = stream cfgphoto.sensor_mode = sensor_mode cfgphoto.stream_size = mode.size cfgphoto.stream_size_align = ( not request.form.get("FOTO_stream_size_align") is None ) cfgphoto.display = None if sc.activeCameraIsUsb == False: cfgphoto.encode = "main" else: cfgphoto.encode = None cc, cr = Camera().ctrl.requestConfig(cfgphoto, test=True) if cc: msg = ( "This modification will cause the live stream to be interrupted when a photo is taken!\nReason: " + cr ) flash(msg) if sc.syncAspectRatio == True: doSyncAspectRatio( cfgphoto.stream_size, ["Live View", "Raw Photo", "Video"] ) Camera.resetScalerCropRequested = True doSyncTransform( transform_hflip, transform_vflip, ["Live View", "Raw Photo", "Video"] ) Camera().restartLiveStream() sc.unsavedChanges = True sc.addChangeLogEntry(f"Configuration for Photo changed") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/addPhotoControls", methods=("GET", "POST")) @login_required def addPhotoControls(): logger.debug("In addPhotoControls") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgphoto" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: for key, value in cc.dict().items(): if value[0] == True: if key not in cfg.photoConfig.controls: cfg.photoConfig.controls[key] = value[1] sc.unsavedChanges = True sc.addChangeLogEntry(f"Controls added to Configuration for Photo") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/remPhotoControls", methods=("GET", "POST")) @login_required def remPhotoControls(): logger.debug("In remPhotoControls") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgphoto" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: cnt = 0 for ctrl in cfg.photoConfig.controls: if request.form.get("sel_FOTO_" + ctrl) is not None: cnt += 1 if cnt > 0: if cnt < len(cfg.photoConfig.controls): while cnt > 0: for ctrl in cfg.photoConfig.controls: if request.form.get("sel_FOTO_" + ctrl) is not None: ctrlDel = ctrl break del cfg.photoConfig.controls[ctrlDel] cnt -= 1 sc.unsavedChanges = True sc.addChangeLogEntry( f"Controls removed from Configuration for Photo" ) cfg.streamingCfgInvalid = True else: msg = "At least one control must remain in the configuration" flash(msg) else: msg = "No controls were selected" flash(msg) if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/rawCfg", methods=("GET", "POST")) @login_required def rawCfg(): logger.debug("In rawCfg") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgraw" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg.rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: transform_hflip = not request.form.get("PRAW_transform_hflip") is None cfgraw.transform_hflip = transform_hflip transform_vflip = not request.form.get("PRAW_transform_vflip") is None cfgraw.transform_vflip = transform_vflip colour_space = request.form["PRAW_colour_space"] cfgraw.colour_space = colour_space queue = not request.form.get("PRAW_queue") is None cfgraw.queue = queue format = request.form["PRAW_format"] cfgraw.format = format sensor_mode = request.form["PRAW_sensor_mode"] if sensor_mode == "custom": size_width = int(request.form["PRAW_stream_size_width"]) if not (size_width % 2) == 0: err = "Stream Size (width, height) must be even" size_height = int(request.form["PRAW_stream_size_height"]) if not (size_height % 2) == 0: err = "Stream Size (width, height) must be even" if not err: cfgraw.sensor_mode = sensor_mode cfgraw.stream_size = (size_width, size_height) cfgraw.stream_size_align = ( not request.form.get("PRAW_stream_size_align") is None ) else: mode = sm[int(sensor_mode)] if not err: cfgraw.sensor_mode = sensor_mode cfgraw.stream_size = mode.size cfgraw.stream_size_align = ( not request.form.get("PRAW_stream_size_align") is None ) if sc.activeCameraIsUsb == True: cfgraw.format = "tiff" cfgraw.sensor_mode = sensor_mode cfgraw.display = None cfgraw.encode = None if sc.syncAspectRatio == True: doSyncAspectRatio(cfgraw.stream_size, ["Live View", "Photo", "Video"]) Camera.resetScalerCropRequested = True doSyncTransform( transform_hflip, transform_vflip, ["Live View", "Photo", "Video"] ) Camera().restartLiveStream() sc.unsavedChanges = True sc.addChangeLogEntry(f"Configuration for Raw Photo changed") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/addRawControls", methods=("GET", "POST")) @login_required def addRawControls(): logger.debug("In addRawControls") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgraw" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: for key, value in cc.dict().items(): if value[0] == True: if key not in cfg.rawConfig.controls: cfg.rawConfig.controls[key] = value[1] sc.unsavedChanges = True sc.addChangeLogEntry(f"Controls added to Configuration for Raw Photo") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/remRawControls", methods=("GET", "POST")) @login_required def remRawControls(): logger.debug("In remRawControls") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgraw" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: cnt = 0 for ctrl in cfg.rawConfig.controls: if request.form.get("sel_PRAW_" + ctrl) is not None: cnt += 1 if cnt > 0: if cnt < len(cfg.rawConfig.controls): while cnt > 0: for ctrl in cfg.rawConfig.controls: if request.form.get("sel_PRAW_" + ctrl) is not None: ctrlDel = ctrl break del cfg.rawConfig.controls[ctrlDel] cnt -= 1 sc.unsavedChanges = True sc.addChangeLogEntry( f"Controls removed from Configuration for Raw Photo" ) cfg.streamingCfgInvalid = True else: msg = "At least one control must remain in the configuration" flash(msg) else: msg = "No controls were selected" flash(msg) if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/videoCfg", methods=("GET", "POST")) @login_required def videoCfg(): logger.debug("In videoCfg") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgvideo" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: transform_hflip = not request.form.get("VIDO_transform_hflip") is None cfgvideo.transform_hflip = transform_hflip transform_vflip = not request.form.get("VIDO_transform_vflip") is None cfgvideo.transform_vflip = transform_vflip colour_space = request.form["VIDO_colour_space"] cfgvideo.colour_space = colour_space buffer_count = int(request.form["VIDO_buffer_count"]) cfgvideo.buffer_count = buffer_count queue = not request.form.get("VIDO_queue") is None cfgvideo.queue = queue stream = request.form["VIDO_stream"] sensor_mode = request.form["VIDO_sensor_mode"] format = request.form["VIDO_format"] cfgvideo.format = format if sensor_mode == "custom": size_width = int(request.form["VIDO_stream_size_width"]) if not (size_width % 2) == 0: err = "Stream Size (width, height) must be even" size_height = int(request.form["VIDO_stream_size_height"]) if not (size_height % 2) == 0: err = "Stream Size (width, height) must be even" if stream == "lores": if cfglive.stream == "main": if ( size_width > cfglive.stream_size[0] or size_height > cfglive.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Live View)" if not err and cfgphoto.stream == "main": if ( size_width > cfgphoto.stream_size[0] or size_height > cfgphoto.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Photo)" if stream == "main": if cfglive.stream == "lores": if ( size_width < cfglive.stream_size[0] or size_height < cfglive.stream_size[1] ): err = "lores Stream Size (Live View) must not exceed main Stream Size" if not err and cfgphoto.stream == "lores": if ( size_width < cfgphoto.stream_size[0] or size_height < cfgphoto.stream_size[1] ): err = "lores Stream Size (Photo) must not exceed main Stream Size" if not err: cfgvideo.stream = stream cfgvideo.sensor_mode = sensor_mode cfgvideo.stream_size = (size_width, size_height) cfgvideo.stream_size_align = ( not request.form.get("VIDO_stream_size_align") is None ) else: mode = sm[int(sensor_mode)] if stream == "lores": if cfglive.stream == "main": if ( mode.size[0] > cfglive.stream_size[0] or mode.size[1] > cfglive.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Live View)" if not err and cfgphoto.stream == "main": if ( mode.size[0] > cfgphoto.stream_size[0] or mode.size[1] > cfgphoto.stream_size[1] ): err = "lores Stream Size must not exceed main Stream Size (Photo)" if stream == "main": if cfglive.stream == "lores": if ( mode.size[0] < cfglive.stream_size[0] or mode.size[1] < cfglive.stream_size[1] ): err = "lores Stream Size (Live View) must not exceed main Stream Size" if not err and cfgphoto.stream == "lores": if ( mode.size[0] < cfgphoto.stream_size[0] or mode.size[1] < cfgphoto.stream_size[1] ): err = "lores Stream Size (Photo) must not exceed main Stream Size" if sc.activeCameraIsUsb == True: format = mode.format cfgvideo.format = format if not err: cfgvideo.stream = stream cfgvideo.sensor_mode = sensor_mode cfgvideo.stream_size = mode.size cfgvideo.stream_size_align = ( not request.form.get("VIDO_stream_size_align") is None ) cfgvideo.display = None if sc.activeCameraIsUsb == False: cfgvideo.encode = "main" else: cfgvideo.encode = None if sc.syncAspectRatio == True: doSyncAspectRatio( cfgvideo.stream_size, ["Live View", "Photo", "Raw Photo"] ) Camera.resetScalerCropRequested = True doSyncTransform( transform_hflip, transform_vflip, ["Live View", "Photo", "Raw Photo"] ) Camera().restartLiveStream() sc.unsavedChanges = True sc.addChangeLogEntry(f"Configuration for Video changed") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/addVideoControls", methods=("GET", "POST")) @login_required def addVideoControls(): logger.debug("In addVideoControls") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgvideo" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: for key, value in cc.dict().items(): if value[0] == True: if key not in cfg.videoConfig.controls: cfg.videoConfig.controls[key] = value[1] sc.unsavedChanges = True sc.addChangeLogEntry(f"Controls added to Configuration for Video") cfg.streamingCfgInvalid = True if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/remVideoControls", methods=("GET", "POST")) @login_required def remVideoControls(): logger.debug("In remVideoControls") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgvideo" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": err = None if sc.isTriggerRecording: err = "Please go to 'Trigger' and stop the active process before changing the configuration" if not err: cnt = 0 for ctrl in cfg.videoConfig.controls: if request.form.get("sel_VIDO_" + ctrl) is not None: cnt += 1 if cnt > 0: if cnt < len(cfg.videoConfig.controls): while cnt > 0: for ctrl in cfg.videoConfig.controls: if request.form.get("sel_VIDO_" + ctrl) is not None: ctrlDel = ctrl break del cfg.videoConfig.controls[ctrlDel] cnt -= 1 sc.unsavedChanges = True sc.addChangeLogEntry( f"Controls removed from Configuration for Video" ) cfg.streamingCfgInvalid = True else: msg = "At least one control must remain in the configuration" flash(msg) else: msg = "No controls were selected" flash(msg) if err: flash(err) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/getAiModelFiles", methods=("GET", "POST")) @login_required def getAiModelFiles(): logger.debug("In getAiModelFiles") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgai" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": msg = "" # Try to import IMX500 try: from picamera2.devices import IMX500 logger.debug("In getAiModelFiles - imported IMX500 successfully") except ImportError: msg = "The class IMX500 could not be imported." msg += "\n Maybe, the IMX500 firmware is not installed." msg += "\n Try installing with 'sudo apt install imx500-all'." if msg == "": modelFolder = request.form.get("modelfolder") if modelFolder.strip() == "": modelfolder = ai.modelFolderDef if os.path.isdir(modelFolder) == False: msg = "The specified AI Model Folder does not exist" msg += "\n Maybe, the IMX500 firmware is not installed." msg += "\n Try installing with 'sudo apt install imx500-all'." if sc.isLiveStream == True \ or sc.isPhotoSeriesRecording == True \ or sc.isTriggerRecording == True \ or sc.isVideoRecording == True: msg = "This setting cannot be changed while the AI camera is active. Please wait and repeat the action when the camera has stopped." if msg == "": ai.modelFolder = modelFolder task = request.form.get("aitask").lower() ai.task = task logger.debug("In getAiModelFiles - searching %s for model files having task '%s'", ai.modelFolder, ai.task) modelFiles = os.listdir(modelFolder) modelFiles.sort(reverse=False) ai.modelFiles = [] for mf in modelFiles: if mf.endswith(".rpk"): mfp = os.path.join(modelFolder, mf) imx500 = IMX500(mfp) intrinsics = imx500.network_intrinsics intrTask = "" if intrinsics: intrTask = intrinsics.task if intrTask: intrTask = intrTask.lower() if intrTask == ai.task: logger.debug("In getAiModelFiles - found model file: %s", mf) ai.modelFiles.append(mf) else: logger.debug("In getAiModelFiles - skipping model file: %s having task '%s'", mf, intrTask) continue imx500 = None ai.modelFile = "" ai.modelIntrinsics = {} if len(ai.modelFiles) == 0: msg = "No AI model files (*.rpk) for the given task were found in the specified folder" if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/setAiModelFile", methods=("GET", "POST")) @login_required def setAiModelFile(): logger.debug("In setAiModelFile") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgai" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": msg = "" # Try to import IMX500 try: from picamera2.devices import IMX500 logger.debug("In setAiModelFile - imported IMX500 successfully") except ImportError: msg = "The class IMX500 could not be imported." msg += "\n Maybe, the IMX500 firmware is not installed." msg += "\n Try installing with 'sudo apt install imx500-all'." if sc.isLiveStream == True \ or sc.isPhotoSeriesRecording == True \ or sc.isTriggerRecording == True \ or sc.isVideoRecording == True: msg = "This setting cannot be changed while the AI camera is active. Please wait and repeat the action when the camera has stopped." if msg == "": modelFolder = ai.modelFolder task = ai.task mf = request.form.get("aimodelfile") if mf.endswith(".rpk"): mfp = os.path.join(modelFolder, mf) imx500 = IMX500(mfp) intrinsics = imx500.network_intrinsics intrTask = "" if intrinsics: intrTask = intrinsics.task if intrTask: intrTask = intrTask.lower() if intrTask != task: msg = "The selected AI model file does not match the given Task" else: ai.modelFile = mf logger.debug("In setAiModelFile - selected model file: %s", mf) modelIntrinsics = intrinsics.__dict__.copy() if "_NetworkIntrinsics__intrinsics" in modelIntrinsics: modelIntrinsics = modelIntrinsics["_NetworkIntrinsics__intrinsics"] if "classes" in modelIntrinsics: modelIntrinsics.pop("classes") if "task" in modelIntrinsics: modelIntrinsics.pop("task") else: modelIntrinsics = {} ai.modelIntrinsics = modelIntrinsics sc.unsavedChanges = True sc.addChangeLogEntry(f"AI model file changed for camera {sc.activeCameraInfo} to {mf}") cfg.streamingCfgInvalid = True else: msg = "The selected AI model file is not a valid .rpk file" imx500 = None if len(ai.modelFiles) == 0: msg = "No AI model files (*.rpk) for the given task were found in the specified folder" if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/enableAi", methods=("GET", "POST")) @login_required def enableAi(): logger.debug("In enableAi") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgai" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": msg = "" restart = False if sc.isTriggerRecording: msg = "Please go to 'Trigger' and stop the active process before enabling AI processing" if sc.isPhotoSeriesRecording: msg = "Please go to 'Photo Series' and stop the active process before enabling AI processing" if sc.isVideoRecording == True: msg = "Please stop video recording before enabling AI processing" if msg == "": enableAi = not request.form.get("enableai") is None if ai.enable == True: if ai.drawOnLores == True: if (cfglive.stream != "lores") \ and (cfgphoto.stream != "lores"): msg = "AI drawing on lores stream is enabled, but no configuration is set to use the lores stream." if ai.drawOnMain == True: if (cfglive.stream != "main") \ and (cfgphoto.stream != "main"): msg = "AI drawing on main stream is enabled, but no configuration is set to use the main stream." if msg == "": if ai.enable != enableAi: restart = True Camera().liveViewDeactivated = True Camera().stopLiveStream() ai.enable = enableAi logger.debug("In enableAi - set enable AI to %s", ai.enable) sc.unsavedChanges = True if ai.enable == False: sc.addChangeLogEntry(f"AI disabled for camera {sc.activeCameraInfo}") else: sc.addChangeLogEntry(f"AI enabled for camera {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True else: logger.debug("In enableAi - left enable AI at %s", ai.enable) if restart: Camera.resetAiCache() Camera().liveViewDeactivated = False Camera().startLiveStream() if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) @bp.route("/ai_settings", methods=("GET", "POST")) @login_required def ai_settings(): logger.debug("In ai_settings") g.hostname = request.host g.version = version cfg = CameraCfg() cp = cfg.cameraProperties sm = cfg.sensorModes rf = cfg.rawFormats sc = cfg.serverConfig tc = cfg.tuningConfig ai = cfg.aiConfig sc.lastConfigTab = "cfgai" cfgs = cfg.cameraConfigs cfglive = cfg.liveViewConfig cfgphoto = cfg.photoConfig cfgraw = cfg._rawConfig cfgvideo = cfg.videoConfig cfgrf = cfg.rawFormats if tc.tuningFile == "": fn = sc.activeCameraModel + ".json" if isTuningFile(fn, tc.tuningFolder) == True: tc.tuningFile = fn tfl = getTuningFiles(tc.tuningFolder, tc.tuningFile) if request.method == "POST": msg = "" restart = False if msg == "": if ai.task == "classification": ai.topK = int(request.form["topk"]) if ai.task == "object detection" \ or ai.task == "pose estimation": ai.detectionThreshold = float(request.form["detectionthreshold"]) if ai.task == "object detection": ai.iouThreshold = float(request.form["iouthreshold"]) ai.maxDetections = int(request.form["maxdetections"]) ai.drawOnLores = not request.form.get("drawonlores") is None ai.drawOnMain = not request.form.get("drawonmain") is None if ai.drawOnLores == True: if (cfglive.stream != "lores") \ and (cfgphoto.stream != "lores"): msg = "AI drawing on lores stream is enabled, but no configuration is set to use the lores stream." if ai.drawOnMain == True: if (cfglive.stream != "main") \ and (cfgphoto.stream != "main"): msg = "AI drawing on main stream is enabled, but no configuration is set to use the main stream." if msg == "": if ai.enable == True: restart = True sc.unsavedChanges = True sc.addChangeLogEntry(f"AI setting 'Draw on lores stream' set to {ai.drawOnLores} for camera {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if restart: Camera().restartLiveStream() if len(msg) > 0: flash(msg) return render_template( "config/main.html", sc=sc, tc=tc, ai=ai, cp=cp, sm=sm, rf=rf, cfglive=cfglive, cfgphoto=cfgphoto, cfgraw=cfgraw, cfgvideo=cfgvideo, cfgrf=cfgrf, cfgs=cfgs, tfl=tfl, ) ================================================ FILE: raspiCamSrv/console.py ================================================ from flask import Blueprint, Response, flash, g, redirect, render_template, request, url_for from werkzeug.exceptions import abort from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.version import version import subprocess from subprocess import CalledProcessError from raspiCamSrv.triggerHandler import TriggerHandler from raspiCamSrv.auth import login_required import logging bp = Blueprint("console", __name__) logger = logging.getLogger(__name__) @bp.route("/console") @login_required def console(): cam = Camera().cam g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig if sc.vButtonHasCommandLine == True: if sc.vButtonCommand is None: sc.vButtonCommand = "" sc.curMenu = "console" return render_template("console/console.html", sc=sc) @bp.route("/execute//", methods=("GET", "POST")) @login_required def execute(row:None, col=None): logger.debug("In execute - row=%s, col=%s", row, col) cam = Camera().cam g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig sc.curMenu = "console" sc.vButtonCommand = None sc.vButtonArgs = None sc.vButtonReturncode = None sc.vButtonStderr = None sc.vButtonStdout = None sc.lastConsoleTab = "versbuttons" if request.method == "POST": msg = "" r = int(row) c = int(col) btn = sc.vButtons[r][c] cmd = btn.buttonExec sc.vButtonCommand = cmd args = cmd.rsplit(" ") sc.vButtonArgs = args msg = "Command successfully executed." result = None if cmd != "": try: result = subprocess.run(args, capture_output=True, text=True, check=False) except CalledProcessError as e: msg = f"Command executed with error: {e}." except Exception as e: msg = f"Command executed with error: {e}." if result: sc.vButtonReturncode = result.returncode sc.vButtonStdout = result.stdout sc.vButtonStderr = result.stderr else: msg = "No command executed" if msg != "": flash(msg) return render_template("console/console.html", sc=sc) @bp.route("/execCommandline", methods=("GET", "POST")) @login_required def execCommandline(): logger.debug("In execCommandline") cam = Camera().cam g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig sc.curMenu = "console" sc.vButtonCommand = None sc.vButtonArgs = None sc.vButtonReturncode = None sc.vButtonStderr = None sc.vButtonStdout = None sc.lastConsoleTab = "versbuttons" if request.method == "POST": msg = "" cmd = request.form["commandline"] sc.vButtonCommand = cmd args = cmd.rsplit(" ") sc.vButtonArgs = args msg = "Command successfully executed." result = None if cmd != "": try: result = subprocess.run(args, capture_output=True, text=True, check=False) except CalledProcessError as e: msg = f"Command executed with error: {e}." except Exception as e: msg = f"Command executed with error: {e}." if result: sc.vButtonReturncode = result.returncode sc.vButtonStdout = result.stdout sc.vButtonStderr = result.stderr else: msg = "No command executed" if msg != "": flash(msg) return render_template("console/console.html", sc=sc) @bp.route("/do_action//", methods=("GET", "POST")) @login_required def do_action(row:None, col=None): logger.debug("In do_action - row=%s, col=%s", row, col) cam = Camera().cam g.hostname = request.host g.version = version cfg = CameraCfg() sc = cfg.serverConfig sc.curMenu = "console" sc.vButtonCommand = None sc.vButtonArgs = None sc.vButtonReturncode = None sc.vButtonStderr = None sc.vButtonStdout = None sc.lastConsoleTab = "actionbuttons" if request.method == "POST": msg = "" # if sc.isEventhandling == False: # msg = "Event handling is not active. Activate 'Configured Triggers' in Trigger/Control and press Start." if msg == "": r = int(row) c = int(col) btn = sc.aButtons[r][c] action = btn.buttonAction msg = "Action successfully executed." result = None if action != "": msg = TriggerHandler.doAction(action) else: msg = "No Action executed" if msg != "": flash(msg) return render_template("console/console.html", sc=sc) ================================================ FILE: raspiCamSrv/db.py ================================================ import sqlite3 import click from flask import current_app, g from raspiCamSrv.camCfg import CameraCfg import logging logger = logging.getLogger(__name__) def get_db(): if "db" not in g: g.db = sqlite3.connect( current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES ) g.db.row_factory = sqlite3.Row return g.db def close_db(e=None): db = g.pop("db", None) if db is not None: db.close() def init_db(): db = get_db() with current_app.open_resource("schema.sql") as f: db.executescript(f.read().decode("utf8")) @click.command("init-db") def init_db_command(): """Clear the existing data and create new tables.""" init_db() click.echo("Initialized the database.") def init_app(app): app.teardown_appcontext(close_db) app.cli.add_command(init_db_command) ================================================ FILE: raspiCamSrv/dbx.py ================================================ import sqlite3 import raspiCamSrv.camCfg as camCfg import logging logger = logging.getLogger(__name__) def get_dbx() -> sqlite3.Connection: """ Get database outside of application context """ database = camCfg.CameraCfg().serverConfig.database logger.debug("get_dbx - database: %s", database) db = sqlite3.connect(database, detect_types=sqlite3.PARSE_DECLTYPES) db.row_factory = sqlite3.Row return db ================================================ FILE: raspiCamSrv/gpioDeviceTypes.py ================================================ gpioDeviceTypes = [ { "type": "Button", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#button", "image": "device_button.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "pull_up": {"value": True, "type": "boolOrNone"}, "active_state": {"value": None, "type": "boolOrNone"}, "bounce_time": { "value": None, "type": "floatOrNone", "min": 0.0, "max": 10.0, }, "hold_time": {"value": 1.0, "type": "float", "min": 0.0, "max": 10.0}, "hold_repeat": {"value": False, "type": "bool"}, }, "testMethods": ["is_pressed", "value"], "events": ["when_pressed", "when_released"], "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "RotaryEncoder", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#rotaryencoder", "image": "device_RotaryEncoder.jpg", "params": { "a": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "b": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "bounce_time": { "value": None, "type": "floatOrNone", "min": 0.0, "max": 10.0, }, "max_steps": {"value": 16, "type": "int", "min": 0, "max": 100}, "threshold_steps": {"value": (0, 0), "type": "tuple(int)"}, "wrap": {"value": False, "type": "bool"}, }, "testMethods": ["steps", "value"], "testStepDuration": 3, "events": [ "when_rotated", "when_rotated_clockwise", "when_rotated_counter_clockwise", ], "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "LightSensor", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#lightsensor-ldr", "image": "device_LightSensor.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "queue_len": {"value": 5, "type": "int", "min": 0, "max": 100}, "charge_time_limit": { "value": 0.01, "type": "float", "min": 0.0, "max": 100.0, }, "threshold": {"value": 0.1, "type": "float", "min": 0.0, "max": 100.0}, "partial": {"value": False, "type": "bool"}, }, "testMethods": ["light_detected", "value"], "events": ["when_dark", "when_light"], "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "MotionSensor", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#motionsensor-d-sun-pir", "image": "device_motionSensor.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "pull_up": {"value": True, "type": "boolOrNone"}, "active_state": {"value": None, "type": "boolOrNone"}, "queue_len": {"value": 1, "type": "int", "min": 0, "max": 100}, "sample_rate": {"value": 10.0, "type": "float", "min": 0.0, "max": 1000.0}, "threshold": {"value": 0.5, "type": "float", "min": 0.0, "max": 1000.0}, "partial": {"value": False, "type": "bool"}, }, "testMethods": ["motion_detected", "value"], "events": ["when_motion", "when_no_motion"], "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "LineSensor", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#linesensor-trct5000", "image": "device_LineSensor.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "pull_up": {"value": False, "type": "boolOrNone"}, "active_state": {"value": None, "type": "boolOrNone"}, "queue_len": {"value": 5, "type": "int", "min": 0, "max": 100}, "sample_rate": {"value": 100.0, "type": "float", "min": 0.0, "max": 1000.0}, "threshold": {"value": 0.5, "type": "float", "min": 0.0, "max": 100.0}, "partial": {"value": False, "type": "bool"}, }, "testMethods": ["value"], "events": ["when_line", "when_no_line"], "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "DistanceSensor", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#distancesensor-hc-sr04", "image": "device_DistanceSensor.jpg", "params": { "echo": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "trigger": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "queue_len": {"value": 9, "type": "int", "min": 0, "max": 99}, "max_distance": {"value": 1.0, "type": "float", "min": 0.0, "max": 100.0}, "threshold_distance": { "value": 0.3, "type": "float", "min": 0.0, "max": 100.0, }, "partial": {"value": False, "type": "bool"}, }, "testMethods": ["distance", "value"], "events": ["when_in_range", "when_out_of_range"], "eventSettings": {"threshold_distance": 0.0}, "control": {"bounce_time": 0.0, "event_log": False}, }, { "type": "DigitalInputDevice", "usage": "Input", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_input.html#digitalinputdevice", "image": "device_DigitalInputDevice.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "pull_up": {"value": False, "type": "boolOrNone"}, "active_state": {"value": None, "type": "boolOrNone"}, "bounce_time": { "value": None, "type": "floatOrNone", "min": 0.0, "max": 10.0, }, }, "testMethods": ["value", "active_time"], "events": ["when_activated", "when_deactivated"], "eventSettings": {}, "control": {}, }, { "type": "LED", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#led", "image": "device_LED.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": False, "type": "boolOrNone"}, }, "testMethods": ["on", "value"], "testDuration": 2, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "toggle", "params": {}, "control": {}}, { "method": "blink", "params": { "on_time": {"value": 1.0, "type": "float", "min": 0.0}, "off_time": {"value": 1.0, "type": "float", "min": 0.0}, "n": {"value": None, "type": "intOrNone", "min": 1}, "background": {"value": True, "type": "bool"} }, "control": {"duration": 0.0} }, { "method": "value", "params": { "value": {"value": 1, "type": "int", "min": 0, "max": 1} }, "control": {} }, ], }, { "type": "PWMLED", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#pwmled", "image": "device_LED.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": 0.0, "type": "float", "min": 0.0, "max": 1.0}, "frequency": {"value": "100", "type": "int", "min": 0, "max": 1000}, }, "testMethods": ["on", "off", "pulse", "value", "off"], "testStepDuration": 2, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "toggle", "params": {}, "control": {}}, { "method": "blink", "params": { "on_time": {"value": 1.0, "type": "float", "min": 0.0}, "off_time": {"value": 1.0, "type": "float", "min": 0.0}, "fade_in_time": {"value": 0.0, "type": "float", "min": 0.0}, "fade_out_time": {"value": 0.0, "type": "float", "min": 0.0}, "n": {"value": None, "type": "intOrNone", "min": 1}, "background": {"value": True, "type": "bool"} }, "control": {"duration": 0.0} }, { "method": "pulse", "params": { "fade_in_time": 1.0, "fade_out_time": 1.0, "n": {"value": None, "type": "intOrNone", "min": 1}, "background": True }, "control": {"duration": 0.0} }, { "method": "value", "params": { "value": { "value": 1.0, "type": "float", "min": 0.0, "max": 1.0, } }, "control": {} }, ], }, { "type": "RGBLED", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#rgbled", "image": "device_RGBLED.jpg", "params": { "red": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "green": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "blue": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": (0.0, 0.0, 0.0), "type": "tuple(float)"}, "pwm": {"value": True, "type": "bool"}, }, "testMethods": [ "on", {"color": (1, 0, 0)}, {"color": (0, 1, 0)}, {"color": (0, 0, 1)}, "is_lit", "value", ], "testStepDuration": 1, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "toggle", "params": {}, "control": {}}, { "method": "blink", "params": { "on_time": {"value": 1.0, "type": "float", "min": 0.0}, "off_time": {"value": 1.0, "type": "float", "min": 0.0}, "fade_in_time": {"value": 0.0, "type": "float", "min": 0.0}, "fade_out_time": {"value": 0.0, "type": "float", "min": 0.0}, "on_color": (1.0, 1.0, 1.0), "off_color": (0.0, 0.0, 0.0), "n": {"value": None, "type": "intOrNone", "min": 1}, "background": {"value": True, "type": "bool"} }, "control": {"duration": 0.0} }, { "method": "pulse", "params": { "fade_in_time": 1.0, "fade_out_time": 1.0, "on_color": (1.0, 1.0, 1.0), "off_color": (0.0, 0.0, 0.0), "n": {"value": None, "type": "intOrNone", "min": 1}, "background": True }, "control": {"duration": 0.0} }, { "method": "value", "params": {"value": (0.0, 0.0, 0.0)}, "control": {"duration": 0.0}, }, ], }, { "type": "Buzzer", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#buzzer", "image": "device_Buzzer.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": False, "type": "boolOrNone"}, }, "testMethods": ["on", "value"], "testDuration": 2, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "toggle", "params": {}, "control": {}}, { "method": "beep", "params": { "on_time": 1.0, "off_time": 1.0, "n": {"value": None, "type": "intOrNone", "min": 1}, "background": True }, "control": {"duration": 0.0} }, { "method": "value", "params": {"value": 1, "type": "int", "min": 0, "max": 1}, "control": {"duration": 0.0} }, ], }, { "type": "TonalBuzzer", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#tonalbuzzer", "image": "device_TonalBuzzer.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "initial_value": { "value": None, "type": "floatOrNone", "min": -1.0, "max": 1.0, }, "mid_tone": {"value": 69, "type": "int", "min": 0, "max": 127}, "octaves": {"value": 1, "type": "int", "min": 0, "max": 127}, }, "testMethods": [{"play": 60}, {"play": 64}, {"play": 67}, "value", "stop"], "testStepDuration": 1, "actionTargets": [ {"method": "play", "params": {"tone": 69}, "control": {"duration": 0.0}}, {"method": "stop", "params": {}, "control": {}}, ], }, { "type": "Servo", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#servo", "image": "device_Servo.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "initial_value": { "value": 0.0, "type": "floatOrNone", "min": -1.0, "max": 1.0, }, "min_pulse_width": { "value": 0.001, "type": "float", "min": 0.0, "max": 10000.0, }, "max_pulse_width": { "value": 0.002, "type": "float", "min": 0.0, "max": 10000.0, }, "frame_width": { "value": 0.020, "type": "float", "min": 0.0, "max": 10000.0, }, }, "testMethods": ["min", "max", "mid", "is_active", "value"], "testStepDuration": 1, "actionTargets": [ { "method": "value", "params": {"value": 0.0}, "control": {"duration": 0.0, "steps": 1}, }, ], }, { "type": "AngularServo", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#angularservo", "image": "device_Servo.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "initial_angle": {"value": 0.0, "type": "float", "min": -90.0, "max": 90.0}, "min_angle": {"value": -90.0, "type": "float", "min": -360.0, "max": 360.0}, "max_angle": {"value": 90.0, "type": "float", "min": -360.0, "max": 360.0}, "min_pulse_width": { "value": 0.001, "type": "float", "min": 0.0, "max": 10000.0, }, "max_pulse_width": { "value": 0.002, "type": "float", "min": 0.0, "max": 10000.0, }, "frame_width": { "value": 0.020, "type": "float", "min": 0.0, "max": 10000.0, }, }, "testMethods": ["min", "max", "mid", "is_active", "angle", "value"], "testStepDuration": 1, "actionTargets": [ { "method": "angle", "params": {"angle": 0.0}, "control": {"duration": 0.0, "steps": 1}, }, ], }, { "type": "Motor", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#motor", "image": "device_Motor.jpg", "params": { "forward": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "backward": { "value": "", "type": "int", "min": 0, "max": 27, "isPin": True, }, "enable": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "pwm": {"value": True, "type": "bool"}, }, "testMethods": [{"forward": 1}, {"backward": 1}, "stop"], "testStepDuration": 3, "actionTargets": [ { "method": "forward", "params": {"speed": 1.0}, "control": {"duration": 0.0, "steps": 1}, }, { "method": "backward", "params": {"speed": 1.0}, "control": {"duration": 0.0, "steps": 1}, }, {"method": "reverse", "params": {}, "control": {}}, {"method": "stop", "params": {}, "control": {}}, ], }, { "type": "StepperMotor", "usage": "Output", "docUrl": "https://signag.github.io/raspi-cam-srv/latest/gpioDevices/StepperMotor/", "image": "device_StepperMotor.jpg", "params": { "in1": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "in2": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "in3": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "in4": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "mode": { "value": 0, "type": "int", "min": 0, "max": 1, }, "speed": { "value": 1.0, "type": "float", "min": 0.0, "max": 1.0, }, "current_angle": { "value": 0.0, "type": "float", "min": -360.0, "max": 360.0, }, "swing_from": { "value": -45.0, "type": "float", "min": -360.0, "max": 0.0, }, "swing_to": { "value": 45.0, "type": "float", "min": 0.0, "max": 360.0, }, "swing_step": { "value": 9.0, "type": "float", "min": 0.0, "max": 360.0, }, "swing_direction": { "value": 1, "type": "int", "min": -1, "max": 1, }, "stride_angle": { "value": 5.625, "type": "float", "min": 0.0, "max": 360.0, }, "gear_reduction": { "value": 64, "type": "int", "min": 1, "max": 1000.0, }, }, "testMethods": [ {"rotate_right": 90.0}, {"rotate_left": 90.0}, {"rotate_to": 0.0}, ], "testStepDuration": 1, "calibration": { "fbwd": { "method": "rotate", "params": {"angle": -10.0}, }, "bwd": { "method": "rotate", "params": {"angle": -1.0}, }, "calibrate": { "method": "value", "params": {"value": 0.0}, }, "fwd": { "method": "rotate", "params": {"angle": 1.0}, }, "ffwd": { "method": "rotate", "params": {"angle": 10.0}, }, }, "actionTargets": [ {"method": "step", "params": {"steps": -1}, "control": {}}, {"method": "step_forward", "params": {"steps": 1}, "control": {}}, {"method": "step_backward", "params": {"speed": 1}, "control": {}}, {"method": "rotate", "params": {"angle": -1.0}, "control": {}}, {"method": "rotate_right", "params": {"angle": 1.0}, "control": {}}, {"method": "rotate_left", "params": {"angle": 1.0}, "control": {}}, {"method": "rotate_to", "params": {"target": 1.0}, "control": {}}, {"method": "swing", "params": {}, "control": {}}, { "method": "wipe", "params": {"angle_from": -45.0, "angle_to": 45.0, "speed": 0.0, "count": 1}, "control": {} }, { "method": "stop", "params": {}, "control": {} }, ], }, { "type": "ServoPWM", "usage": "Output", "docUrl": "https://signag.github.io/raspi-cam-srv/latest/gpioDevices/ServoPWM/", "image": "device_ServoPWM.jpg", "params": { "pin": {"value": "", "type": "int", "min": 12, "max": 19, "isPin": True}, "min_angle": {"value": -90.0, "type": "float", "min": -360.0, "max": 360.0}, "max_angle": {"value": 90.0, "type": "float", "min": -360.0, "max": 360.0}, "min_pulse_width_us": { "value": 500, "type": "int", "min": 0, "max": 1000000.0, }, "max_pulse_width_us": { "value": 2500, "type": "int", "min": 0, "max": 1000000.0, }, "frame_width_us": { "value": 20000, "type": "int", "min": 0, "max": 1000000.0, }, "speed": { "value": 1.5, "type": "float", "min": 0.0, "max": 10000.0, }, "idle_off": {"value": False, "type": "bool"}, "calibration": {"value": 0.0, "type": "float", "min": -90.0, "max": 90.0}, }, "testMethods": ["min", "max", "mid", {"rotate_to": 0.0}], "testStepDuration": 1, "calibration": { "fbwd": { "method": "rotate_by", "params": {"angle": -10.0}, }, "bwd": { "method": "rotate_by", "params": {"angle": -1.0}, }, "calibrate": { "param": "calibration", }, "fwd": { "method": "rotate_by", "params": {"angle": 1.0}, }, "ffwd": { "method": "rotate_by", "params": {"angle": 10.0}, }, }, "actionTargets": [ {"method": "min", "params": {}, "control": {}}, {"method": "max", "params": {}, "control": {}}, {"method": "mid", "params": {}, "control": {}}, {"method": "rotate_to", "params": {"angle": -1.0}, "control": {}}, {"method": "rotate_by", "params": {"angle": -1.0}, "control": {}}, {"method": "rotate_right", "params": {"angle": 1.0}, "control": {}}, {"method": "rotate_left", "params": {"angle": 1.0}, "control": {}}, {"method": "stop", "params": {}, "control": {}}, {"method": "close", "params": {}, "control": {}}, ], }, { "type": "DigitalOutputDevice", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#digitaloutputdevice", "image": "device_DigitalOutputDevice.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": False, "type": "boolOrNone"}, }, "testMethods": ["on", "value"], "testDuration": 2, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "value", "params": {"value": 0}, "control": {}}, ], }, { "type": "OutputDevice", "usage": "Output", "docUrl": "https://gpiozero.readthedocs.io/en/stable/api_output.html#outputdevice", "image": "device_OutputDevice.jpg", "params": { "pin": {"value": "", "type": "int", "min": 0, "max": 27, "isPin": True}, "active_high": {"value": True, "type": "bool"}, "initial_value": {"value": False, "type": "boolOrNone"}, }, "testMethods": ["on", "value"], "testDuration": 2, "actionTargets": [ {"method": "on", "params": {}, "control": {"duration": 0.0}}, {"method": "off", "params": {}, "control": {}}, {"method": "toggle", "params": {}, "control": {}}, {"method": "value", "params": {"value": 0}, "control": {}}, ], }, ] ================================================ FILE: raspiCamSrv/gpioDevices.py ================================================ from gpiozero import OutputDevice, PWMOutputDevice import threading from _thread import allocate_lock import time from datetime import datetime import math import subprocess from subprocess import CalledProcessError import logging # Try to import rpi_hardware_pwm try: from rpi_hardware_pwm import HardwarePWM useHardwarePwm = True except ImportError: useHardwarePwm = False logger = logging.getLogger(__name__) class StepperMotor(): """ This class implements a stepper motor Developped and tested with Stepper motor: 28BYJ-48 Motor driver : ULN2003A """ def __init__(self, \ in1:int, \ in2:int, \ in3:int, \ in4:int, \ mode:int=0, \ speed:float=1.0, \ current_angle:float=0.0, \ swing_from:float=-45.0, \ swing_to:float=45.0, \ swing_step:float=9.0, \ swing_direction:int=1, \ stride_angle:float=5.625, \ gear_reduction:int=64 ): """ Constructor for StepperMotor Args: in1 (int): GPIO Pin connected to IN1 in2 (int): GPIO Pin connected to IN2 in3 (int): GPIO Pin connected to IN3 in4 (int): GPIO Pin connected to IN4 mode (int, optional): 0: half-step mode 1: full-step mode Defaults to 0. speed (float, optional): 0.0 : lowest speed 1.0 : highest speed Defaults to 1.0. current_angle (float, optional): current angle of the motor Defaults to 0.0. swing_from (float, optional): left limit of the swing (-360.0 to 0.0) Defaults to -45.0. swing_to (float, optional): right limit of the swing (0.0 to 360.0) Defaults to 45.0. swing_step (float, optional): step size of the swing (0.0 to 360.0) Defaults to 9.0. swing_direction (int, optional): 1: clockwise -1: counter-clockwise Defaults to 1. stride_angle (float, optional) angle per step in half-step mode Defaults to 5.625. gear_reduction (int, optional) gear reduction ratio Defaults to 64. """ # Constant waiting times for highest (1) and lowest (0) speed self._WAIT_HIGH_SPEED = 0.001 self._WAIT_LOW_SPEED = 0.040 # Set the pins self._in1 = OutputDevice(in1) self._in2 = OutputDevice(in2) self._in3 = OutputDevice(in3) self._in4 = OutputDevice(in4) self._mode = mode self._speed = speed self._stride_angle = stride_angle self._gear_reduction = gear_reduction self._wait = self._WAIT_HIGH_SPEED self._pins = [self._in1, self._in2, self._in3, self._in4] # Step sequence for half-step operation self._seq_half_step = [ \ [1,0,0,0], [1,1,0,0], [0,1,0,0], [0,1,1,0], [0,0,1,0], [0,0,1,1], [0,0,0,1], [1,0,0,1], ] # Step sequence for full-step operation self._seq_full_step = [ \ [1,1,0,0], [0,1,1,0], [0,0,1,1], [1,0,0,1] ] if self._mode == 0: self._seq = self._seq_half_step else: self._seq = self._seq_full_step self._seq_len = len(self._seq) # Set the waiting time tepending on the speed if self._speed < 0.0: self._speed = 0.0 if self._speed > 1.0: self._speed = 1.0 self._wait = self._WAIT_LOW_SPEED + self._speed * (self._WAIT_HIGH_SPEED - self._WAIT_LOW_SPEED) # For full-step mode, the wait time is doubled if self._mode == 1: self._wait = self._wait * 2 # Set the current step self._current_step = 0 # Set parameters for swinging self._current_angle = current_angle self._swing_from = swing_from self._swing_to = swing_to self._swing_step = swing_step self._swing_direction = swing_direction # Set parameters for swiping self.wipe_active = False self.wipeLock = allocate_lock() # lock for wipe status self.wipeThread = None # thread for wipe operation @property def in1(self) -> int: return self._in1 @in1.setter def in1(self, value: int): self._in1 = value @property def in2(self) -> int: return self._in2 @in2.setter def in2(self, value: int): self._in2 = value @property def in3(self) -> int: return self._in3 @in3.setter def in3(self, value: int): self._in3 = value @property def in4(self) -> int: return self._in4 @in4.setter def in4(self, value: int): self._in4 = value @property def mode(self) -> int: return self._mode @mode.setter def mode(self, value: int): self._mode = value if self._mode == 0: self._seq = self._seq_half_step else: self._seq = self._seq_full_step self._seq_len = len(self._seq) # Set the waiting time tepending on the speed self.speed = self.speed @property def speed(self) -> float: return self._speed @speed.setter def speed(self, value: float): self._speed = value if self._speed < 0.0: self._speed = 0.0 if self._speed > 1.0: self._speed = 1.0 self._wait = self._WAIT_LOW_SPEED + self._speed * (self._WAIT_HIGH_SPEED - self._WAIT_LOW_SPEED) # For full-step mode, the wait time is doubled if self._mode == 1: self._wait = self._wait * 2 @property def stride_angle(self) -> float: return self._stride_angle @property def gear_reduction(self) -> float: return self._gear_reduction @property def current_angle(self) -> float: return self._current_angle @current_angle.setter def current_angle(self, value: float): self._current_angle = value @property def value(self) -> float: return self._current_angle @value.setter def value(self, value: float): self._current_angle = value @property def swing_from(self) -> float: return self._swing_from @swing_from.setter def swing_from(self, value: float): self._swing_from = value @property def swing_to(self) -> float: return self._swing_to @swing_to.setter def swing_to(self, value: float): self._swing_to = value @property def swing_step(self) -> float: return self._swing_step @swing_step.setter def swing_step(self, value: float): self._swing_step = value @property def swing_direction(self) -> float: return self._swing_direction @swing_direction.setter def swing_direction(self, value: float): self._swing_direction = value def _motor_step(self, direction:int): """ Do one motor step in the current direction Args: direction (int): 1: forward -1: backward """ # Move for pin in range(0, 4): if self._seq[self._current_step][pin] != 0: self._pins[pin].on() else: self._pins[pin].off() # Proceed self._current_step += direction if self._current_step >= self._seq_len: self._current_step = 0 if self._current_step < 0: self._current_step = self._seq_len - 1 # Wait time.sleep(self._wait) def _step(self, direction:int): """ Do one step in the current direction Args: direction (int): 1: forward -1: backward """ for motor_step in range(0, self._gear_reduction): self._motor_step(direction) # Update the current angle if self._mode == 0: self._current_angle += direction * self._stride_angle else: self._current_angle += direction * self._stride_angle * 2 if self._current_angle > 360.0: self._current_angle -= 360.0 if self._current_angle < -360.0: self._current_angle += 360.0 def step(self, steps:int): """ step forward or backward by a given number of steps Args: steps (int): number of steps to step forward (positive) or backward (negative) """ nrSteps = abs(steps) if steps < 0: direction = -1 else: direction = 1 for step in range(0, nrSteps): self._step(direction) def step_forward(self, steps:int): """ step forward by a given number of steps Args: steps (int): number of steps to step forward """ for step in range(0, steps): self._step(1) def step_backward(self, steps:int): """ step forward by a given number of steps Args: steps (int): number of steps to step forward """ for step in range(0, steps): self._step(-1) def rotate(self, angle:float): """ Rotate right by the given angle Args: angle (float): angle to rotate. Positive angle is clockwise, negative angle is counter-clockwise """ abs_angle = abs(angle) dir = 1 if angle < 0: dir = -1 motor_steps = round(self.gear_reduction * abs_angle / self._stride_angle) if self.mode == 1: motor_steps = round(motor_steps / 2) for motor_step in range(0, motor_steps): self._motor_step(dir) self._current_angle += angle def rotate_right(self, angle:float): """ Rotate right by the given angle Args: angle (float): angle to rotate """ self.rotate(angle) def rotate_left(self, angle:float): """ Rotate left by the given angle Args: angle (float): angle to rotate """ self.rotate(-angle) def rotate_to(self, target:float): """ Rotate to a given angle Args: angle (float): angle to rotate to """ angle = target - self._current_angle self.rotate(angle) def swing(self): """ Swing the motor back and forth between the given angles """ angle_rest = 0.0 angle_step = self._swing_direction * self._swing_step angle_new = self._current_angle + angle_step if angle_new > self._swing_to: angle_rest = angle_new - self._swing_to angle_step = self._swing_to - self._current_angle elif angle_new < self._swing_from: angle_rest = angle_new - self._swing_from angle_step = self._swing_from - self._current_angle self.rotate(angle_step) if angle_rest != 0.0: self._swing_direction = -self._swing_direction angle_step = -angle_rest self.rotate(angle_step) def wipe(self, angle_from:float=-45, angle_to:float=45, speed:float=1.0, count:int=1): """Start swiping in a separate thread Args: angle_from (float): left limit of the wipe angle_to (float): right limit of the wipe duration (float): duration of the wipe in seconds count (int): number of wipes """ if self.wipeThread is not None and self.wipeThread.is_alive(): return self.wipeThread = threading.Thread(target=self._do_wipe, args=(angle_from, angle_to, speed, count)) self.wipeThread.start() def _do_wipe(self, angle_from, angle_to, speed, count): """ Wipe the motor back and forth between the given angles Args: angle_from (float): left limit of the wipe angle_to (float): right limit of the wipe duration (float): duration of the wipe in seconds count (int): number of wipes """ self.wipe_active = True current_angle = self._current_angle current_speed = self._speed self.speed = speed self.rotate_to(angle_from) i = count if i == 0: i = 1 while i > 0: self.rotate_to(angle_to) with self.wipeLock: if self.wipe_active == False: i = 0 if i > 0: self.rotate_to(angle_from) if count > 0: i -= 1 with self.wipeLock: if self.wipe_active == False: i = 0 self.speed = current_speed self.rotate_to(current_angle) self.wipe_active = False self.wipeThread = None def stop(self): """ Stop any activity """ with self.wipeLock: self.wipe_active = False while self.wipeThread is not None and self.wipeThread.is_alive(): time.sleep(0.1) def close(self): """ Close gpiozero resources associated with pins """ self.stop() for pin in range(0, 4): self._pins[pin].close() class ServoPWM(): """ This class implements a servo motor using PWM signal Developped and tested with Servo motor: KY66 Development of this class was motivated by the fact that software-based PWM, provided by the default RPi.GPIO pin factory in gpiozero results in significant jitter for servo motors. The alternative pigpio pin factory does support hardware PWM, but it is currently not compatible with the latest Debian release (Trixie) for Raspberry Pi. Therefore, this class implements the control of the servo motor using the rpi-hardware-pwm library, which provides access to the hardware PWM channels of the Raspberry Pi. (https://github.com/Pioreactor/rpi_hardware_pwm) """ def __init__(self, pin:int, min_angle:float=-90.0, max_angle:float= 90.0, min_pulse_width_us:int=500, max_pulse_width_us:int=2500, frame_width_us:int=20000, speed:float=2.8, idle_off:bool=False, calibration:float=0.0 ): """ Constructor for ServoPWM Args: pin (int): GPIO Pin connected to the servo signal line min_angle (float, optional): minimum angle of the servo. Defaults to -90.0. max_angle (float, optional): maximum angle of the servo. Defaults to 90.0. min_pulse_width_us (int, optional): minimum pulse width corresponding to 0 degree in microseconds. Defaults to 500. max_pulse_width_us (int, optional): maximum pulse width corresponding to 180 degree in microseconds. Defaults to 2500. frame_width_us (int, optional): duration of one PWM frame in microseconds. Defaults to 20000. speed (float, optional): speed of the servo [sec/360°]. Defaults to 0.72. idle_off (bool, optional): whether to turn off the signal when idle. Defaults to True. calibration (float, optional): calibration angle of the servo. Defaults to 0.0. """ logger.debug("ServoPWM.__init__: Initializing on pin %d with calibration=%s, min_angle=%s, max_angle=%s, min_pulse_width_us=%s, max_pulse_width_us=%s, frame_width_us=%s, speed=%s, idle_off=%s", pin, calibration, min_angle, max_angle, min_pulse_width_us, max_pulse_width_us, frame_width_us, speed, idle_off) if useHardwarePwm == False: raise ImportError("rpi_hardware_pwm library is not available. Please run: pip install rpi_hardware_pwm") self._pin = pin match pin: case 12: self._pwm_channel = 0 case 13: self._pwm_channel = 1 case 18: self._pwm_channel = 2 case 19: self._pwm_channel = 3 case _: raise ValueError(f"Invalid pin {pin}. Valid pins are 12, 13, 18, 19.") if not self.is_pin_ok(pin): raise ValueError(f"PWM is not routed to pin {pin}. Please check dtoverlay configuration and verify with 'pinctrl get {pin}' command.") self._min_angle = min_angle self._max_angle = max_angle self._min_pulse_width_us = min_pulse_width_us self._max_pulse_width_us = max_pulse_width_us self._frame_width_us = frame_width_us self._current_angle = 0.0 self._current_duty_cycle = 0.0 self._frequency = int(1000000 / self._frame_width_us) self._speed = speed self._idle_off = idle_off self._calib_angle = calibration self._pwm = HardwarePWM(pwm_channel=self._pwm_channel, hz=self._frequency) self._pwm.start(0) logger.debug("ServoPWM.__init__: Initialization complete. Current duty cycle: %s", self._current_duty_cycle) def is_pin_ok(self, pin:int) -> bool: """ Check if the given pin is valid for hardware PWM Args: pin (int): GPIO pin number to check Returns: bool: True if the pin is valid for hardware PWM, False otherwise """ try: result = subprocess.run( ["pinctrl", "get", f"{pin}"], capture_output=True, text=True ).stdout.strip() if result.find("PWM") >= 0: return True else: return False except CalledProcessError as e: logger.error("Error checking pin %d: %s", pin, e) return False @property def current_angle(self) -> float: return self._current_angle @current_angle.setter def current_angle(self, value: float): """ Set the new current angle and rotate servo to the given value Given limits are regarded Args: value (float): target angle to rotate to relative to calibration zero """ logger.debug("ServoPWM.current_angle: Setting current_angle to %s. Actually: %s", value, self._current_angle) value = value + self._calib_angle logger.debug("ServoPWM.current_angle: calibration correction %s", value) if value < self._min_angle: value = self._min_angle if value > self._max_angle: value = self._max_angle logger.debug("ServoPWM.current_angle: Limited to min/max %s", value) diff = abs(value - self._calib_angle - self._current_angle) logger.debug("ServoPWM.current_angle: Difference to current angle %s", diff) self._current_angle = value - self._calib_angle logger.debug("ServoPWM.current_angle: New current angle %s", self._current_angle) self._current_duty_cycle = self._angle_to_duty_cycle(value) logger.debug("ServoPWM.current_angle: New current duty cycle %s", self._current_duty_cycle) self._pwm.change_duty_cycle(self._current_duty_cycle) logger.debug("ServoPWM.current_angle: New duty cycle set: %s", self._current_duty_cycle) if self._idle_off: duration = diff * self._speed / 360.0 if duration < 0.1: duration = 0.1 logger.debug("ServoPWM.current_angle: Waiting for %s sec", duration) time.sleep(duration) self._pwm.change_duty_cycle(0) logger.debug("ServoPWM.current_angle: Done") @property def value(self) -> float: return self._current_angle @value.setter def value(self, value: float): self.current_angle = value def _angle_to_duty_cycle(self, angle:float) -> float: """ Convert angle to duty cycle Args: angle (float): angle to convert Returns: float: duty cycle corresponding to the given angle """ pulse_width_us = self._min_pulse_width_us + (angle - self._min_angle) * (self._max_pulse_width_us - self._min_pulse_width_us) / (self._max_angle - self._min_angle) duty_cycle = 100 * pulse_width_us / self._frame_width_us return duty_cycle def _duty_cycle_to_angle(self, duty_cycle:float) -> float: """ Convert duty cycle to angle Args: duty_cycle (float): duty cycle to convert Returns: float: angle corresponding to the given duty cycle """ pulse_width_us = duty_cycle * self._frame_width_us angle = self._min_angle + (pulse_width_us - self._min_pulse_width_us) * (self._max_angle - self._min_angle) / (self._max_pulse_width_us - self._min_pulse_width_us) if angle < self._min_angle: angle = self._min_angle if angle > self._max_angle: angle = self._max_angle return angle def min(self): """ Rotate the servo to the minimum angle """ self.current_angle = self._min_angle - self._calib_angle def max(self): """ Rotate the servo to the maximum angle """ self.current_angle = self._max_angle - self._calib_angle def mid(self): """ Rotate the servo to the middle angle """ self.current_angle = (self._min_angle + self._max_angle) / 2.0 - self._calib_angle def rotate_to(self, angle:float): """ Rotate the servo to the given angle Args: angle (float): angle to rotate to """ self.current_angle = angle def rotate_by(self, angle:float): """ Rotate the servo by the given angle Args: angle (float): angle to rotate by """ self.current_angle = self.current_angle + angle def rotate_left(self, angle:float): """ Rotate the servo left by the given angle Args: angle (float): angle to rotate left by """ self.rotate_by(-angle) def rotate_right(self, angle:float): """ Rotate the servo right by the given angle Args: angle (float): angle to rotate right by """ self.rotate_by(angle) def stop(self): """ Stop any activity """ self._pwm.stop() def close(self): """ Stop any activity """ self._pwm.stop() if __name__ == "__main__": testClass="ServoPWM" if testClass == "StepperMotor": test = 6 print("==== Test StepperMotor ======") sm = StepperMotor(10, 9, 11, 0, 0, 1) # sm = StepperMotor(14, 15, 18, 23, 0, 1) if test == 3: print(f"==== Test Calibration ====") print (f"==== 1. current_angle:{sm.current_angle}") sm.step(8) print (f"==== 2. step(8) ====") time.sleep(2) print (f"==== 3. current_angle:{sm.current_angle}") sm.value = 0.0 print (f"==== 4. value=0.0 ====") sm.rotate_to(-45.0) print (f"==== 5. rotate_to(-90.0) ====") print (f"==== 6. current_angle:{sm.current_angle}") if test == 2: print(f"==== Test swinging ====") for i in range(0, 40): print (f"==== Step {i + 1} Start {sm.current_angle} ====") sm.swing() print (f"==== Step {i + 1} End {sm.current_angle} ====") print(" ") if test == 3: print(f"==== Test mode & speed ====") for mode in range(0, 2): sm.mode = mode for ispeed in range(1, -1, -1): speed = float(ispeed) sm.speed = speed print(f"==== mode={sm.mode} == speed={sm.speed } ====") print(f"==== step_forward(64) =======") sm.step_forward(64) time.sleep(2) print(f"==== step_backward(64)") sm.step_backward(64) time.sleep(2) print(f"==== step(64)") sm.step(64) time.sleep(2) print(f"==== step(-64)") sm.step(-64) time.sleep(2) print(f"==== rotate_right(90) =======") sm.rotate_right(90) time.sleep(2) print(f"==== rotate_left(90) =======") sm.rotate_left(90) time.sleep(2) print(f"==== rotate(360) =======") sm.rotate(360) time.sleep(2) print(f"==== rotate(-360) =======") sm.rotate(-360) time.sleep(2) if test == 4: sm.value = 0.0 sm.rotate_to(0) sm.mode = 0 sm.speed = 1.0 for a in range(0, -91, -15): sm.rotate_to(a) time.sleep(0.5) for a in range(-80, 91, 15): sm.rotate_to(a) time.sleep(0.5) sm.rotate_to(0) if test == 5: print(f"==== Test wipe ====") sm.value = 0.0 sm.rotate_to(0) sm.mode = 0 sm.speed = 0.0 sm.wipe(angle_from=-45, angle_to=45, speed=0, count=3) time.sleep(5) sm.stop() if test == 5: print(f"==== Measuring Angular Velocity ====") sm.value = 0.0 sm.rotate_to(0) print("") print(f"==== Half-Step Mode ====") sm.mode = 0 print(f"==== Slow (speed=0) ====") sm.speed = 0.0 startTime = datetime.now() sm.rotate_to(360) endTime = datetime.now() duration = (endTime - startTime).total_seconds() print(f"==== Duration: {duration} seconds ====") print(f"==== Angular Velocity: {360 / duration} degrees/second ====") print(f"==== Fast (speed=1) ====") sm.speed = 1.0 startTime = datetime.now() sm.rotate_to(0) endTime = datetime.now() duration = (endTime - startTime).total_seconds() print(f"==== Duration: {duration} seconds ====") print(f"==== Angular Velocity: {360 / duration} degrees/second ====") print("") print(f"==== Full-Step Mode ====") sm.mode = 1 print(f"==== Slow (speed=0) ====") sm.speed = 0.0 startTime = datetime.now() sm.rotate_to(360) endTime = datetime.now() duration = (endTime - startTime).total_seconds() print(f"==== Duration: {duration} seconds ====") print(f"==== Angular Velocity: {360 / duration} degrees/second ====") print(f"==== Fast (speed=1) ====") sm.speed = 1.0 startTime = datetime.now() sm.rotate_to(0) endTime = datetime.now() duration = (endTime - startTime).total_seconds() print(f"==== Duration: {duration} seconds ====") print(f"==== Angular Velocity: {360 / duration} degrees/second ====") print(f"==== close ====") sm.close() print(f"==== Test completed ====") if testClass == "ServoPWM": print("==== Test ServoPWM ======") servo = ServoPWM(pin=12, idle_off=True) print(f"==== Rotate to Min ====") servo.min() time.sleep(2) print(f"==== Rotate to Max ====") servo.max() time.sleep(2) print(f"==== Rotate to Mid ====") servo.mid() time.sleep(2) for angle in range(-90, 91, 30): print(f"==== Rotate to {angle} ====") servo.rotate_to(angle) time.sleep(2) for angle in range(60, -91, -30): print(f"==== Rotate to {angle} ====") servo.rotate_to(angle) time.sleep(1) print(f"==== Rotate by 45 ====") servo.rotate_by(45) time.sleep(2) print(f"==== Rotate by 45 ====") servo.rotate_by(45) time.sleep(2) print(f"==== Rotate left by 90 ====") servo.rotate_left(90) time.sleep(2) print(f"==== Rotate right by 90 ====") servo.rotate_right(90) time.sleep(2) print(f"==== stop ====") servo.stop() print(f"==== Test completed ====") ================================================ FILE: raspiCamSrv/home.py ================================================ from flask import ( current_app, Blueprint, Response, flash, g, redirect, render_template, request, url_for, ) from werkzeug.exceptions import abort from raspiCamSrv.auth import login_required, login_for_streaming from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg, ServerConfig from raspiCamSrv.version import version from raspiCamSrv.triggerHandler import TriggerHandler from libcamera import controls from _thread import get_ident import subprocess from subprocess import CalledProcessError import math import os import datetime import time import logging bp = Blueprint("home", __name__) logger = logging.getLogger(__name__) @bp.route("/") @login_required def index(): logger.debug("Thread %s: In index", get_ident()) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.error = None sc.getLatestVersion(now=True) Camera().startLiveStream() logger.debug("Thread %s: Camera instantiated", get_ident()) if sc.noCamera == False: sc.curMenu = "live" else: sc.curMenu = "info" logger.debug("Thread %s: cp.hasFocus is %s", get_ident(), cp.hasFocus) sc.displayBufferCheck() if sc.error: msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) if sc.noCamera == False: return render_template("home/index.html", cc=cc, sc=sc, cp=cp) else: return redirect(url_for("info.main")) def gen(camera): """Video streaming generator function.""" # logger.debug("Thread %s: In gen", get_ident()) yield b"--frame\r\n" while True: frame, frameRaw = camera.get_frame() if frame: # logger.debug("Thread %s: gen - Got frame of length %s", get_ident(), len(frame)) yield b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n--frame\r\n" def gen2(camera): """Video streaming generator function.""" # logger.debug("Thread %s: In gen", get_ident()) yield b"--frame\r\n" while True: frame, frameRaw = camera.get_frame2() if frame: # logger.debug("Thread %s: gen - Got frame of length %s", get_ident(), len(frame)) yield b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n--frame\r\n" @bp.route("/live_view_feed") @login_required def live_view_feed(): logger.debug( "Thread %s: In live_view_feed - client IP: %s", get_ident(), request.remote_addr ) sc = CameraCfg().serverConfig sc.registerStreamingClient(request.remote_addr, "live_view", get_ident()) Camera().startLiveStream() return Response(gen(Camera()), mimetype="multipart/x-mixed-replace; boundary=frame") @bp.route("/video_feed") @login_for_streaming def video_feed(): logger.debug( "Thread %s: In video_feed - client IP: %s", get_ident(), request.remote_addr ) sc = CameraCfg().serverConfig sc.registerStreamingClient(request.remote_addr, "video_feed", get_ident()) Camera().startLiveStream() return Response(gen(Camera()), mimetype="multipart/x-mixed-replace; boundary=frame") @bp.route("/video_feed2") @login_for_streaming def video_feed2(): logger.debug( "Thread %s: In video_feed2 - client IP: %s", get_ident(), request.remote_addr ) sc = CameraCfg().serverConfig sc.registerStreamingClient(request.remote_addr, "video_feed2", get_ident()) Camera().startLiveStream2() return Response( gen2(Camera()), mimetype="multipart/x-mixed-replace; boundary=frame" ) @bp.route("/photos/") @login_required def displayImage(photo: str): logger.debug("In displayImage") logger.debug("photo=%s", photo) logger.debug("current_app.root_path=%s", current_app.root_path) fp = current_app.root_path + "/photos/" + photo logger.debug("fp = %s", fp) return Response(fp, mimetype="image/jpg") @bp.route("/focus_control", methods=("GET", "POST")) @login_required def focus_control(): logger.debug("In focus_control") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "focus" sc.getLatestVersion(now=True) if request.method == "POST": if cp.hasFocus: ctrls = {} if request.form.get("include_afmode") is None: cc.include_afMode = False else: cc.include_afMode = True afMode = int(request.form["afmode"]) cc.afMode = afMode ctrls["AfMode"] = afMode if request.form.get("include_lensposition") is None: cc.include_lensPosition = False else: cc.include_lensPosition = True fDist = float(request.form["fdist"]) cc.focalDistance = fDist lensPosition = cc.lensPosition ctrls["LensPosition"] = lensPosition if request.form.get("include_afmetering") is None: cc.include_afMetering = False else: cc.include_afMetering = True afMetering = int(request.form["afmetering"]) cc.afMetering = afMetering ctrls["AfMetering"] = afMetering if request.form.get("include_afpause") is None: cc.include_afPause = False else: cc.include_afPause = True afPause = int(request.form["afpause"]) cc.afPause = afPause ctrls["AfPause"] = afPause if request.form.get("include_afrange") is None: cc.include_afRange = False else: cc.include_afRange = True afRange = int(request.form["afrange"]) cc.afRange = afRange ctrls["AfRange"] = afRange if request.form.get("include_afspeed") is None: cc.include_afSpeed = False else: cc.include_afSpeed = True afSpeed = int(request.form["afspeed"]) cc.afSpeed = afSpeed ctrls["AfSpeed"] = afSpeed if request.form.get("include_afwindows") is None: cc.include_afWindows = False afWindowsStr = "()" cc.afWindowsStr = afWindowsStr ctrls["AfWindows"] = cc.afWindows else: cc.include_afWindows = True afWindowsStr = request.form["afwindows"] cc.afWindowsStr = afWindowsStr ctrls["AfWindows"] = cc.afWindows if len(cc.afWindows) == 0: cc.include_afWindows = False sc.unsavedChanges = True sc.addChangeLogEntry(f"Focus handling changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/trigger_autofocus", methods=("GET", "POST")) @login_required def trigger_autofocus(): logger.debug("In trigger_autofocus") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "focus" sc.getLatestVersion(now=True) if request.method == "POST": if cp.hasFocus: if cc.afMode == controls.AfModeEnum.Auto: Camera().applyControlsForAfCycle(cfg.liveViewConfig) success = Camera().cam.autofocus_cycle() if success: lp = Camera().getLensPosition() # lp = int(100 * lp) / 100 if lp > 0: cc.lensPosition = lp cc.include_lensPosition = True cc.afMode = 0 msg = "Autofocus successful. See Focal Distance. Autofocus Mode set to 'Manual'." sc.unsavedChanges = True sc.addChangeLogEntry( f"Autofocus triggered for {sc.activeCameraInfo}" ) cfg.streamingCfgInvalid = True else: msg = "Camera returned LensPosition 0. Ignored" else: msg = "Autofocus not successful" else: msg = "ERROR: Autofocus Mode must be set to 'Auto'!" flash(msg) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/set_zoom", methods=("GET", "POST")) @login_required def set_zoom(): logger.debug("In set_zoom") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": step = int(request.form["zoomfactorstep"]) sc.zoomFactorStep = step logger.debug("sc.zoomFactorStep set to %s", step) if sc.isZoomModeDraw == True: sc.isZoomModeDraw = False scalerCropStr = request.form["scalercrop"] logger.debug("Form scalerCrop: %s", scalerCropStr) sc.scalerCropLiveViewStr = scalerCropStr logger.debug("sc.scalerCropLiveView: %s", sc.scalerCropLiveView) cc.scalerCropStr = scalerCropStr logger.debug("cc.scalerCrop: %s", cc.scalerCrop) cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) metadata = Camera.getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] #zoomFactor = sc.zoomFactorStep * math.floor( # (100 * cc.scalerCrop[2] / cp.pixelArraySize[0]) / sc.zoomFactorStep #) zoomFactor = round(100 * cc.scalerCrop[2] / cp.pixelArraySize[0], 3) if zoomFactor <= 0: zoomFactor = sc.zoomFactorStep sc.zoomFactor = zoomFactor sc.unsavedChanges = True sc.addChangeLogEntry(f"Zoom changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/zoom_in", methods=("GET", "POST")) @login_required def zoom_in(): logger.debug("In zoom_in") g.hostname = request.host g.version = version cfg = CameraCfg() logger.debug("cfg.liveViewConfig.controls=%s", cfg.liveViewConfig.controls) cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": logger.debug("ScalerCrop old: %s", cc.scalerCrop) xCenter = cc.scalerCrop[0] + int(cc.scalerCrop[2] / 2) yCenter = cc.scalerCrop[1] + int(cc.scalerCrop[3] / 2) zfNext = sc.zoomFactor - sc.zoomFactorStep msg = [] if zfNext < sc.zoomFactorStep: msg.append("WARNING: Minimum zoom factor reached!") zfNext = sc.zoomFactorStep width = int(sc.scalerCropDef[2] * zfNext / 100) height = int(sc.scalerCropDef[3] * zfNext / 100) if width < sc.scalerCropMin[2]: height = int(height * sc.scalerCropMin[2] / width) width = sc.scalerCropMin[2] msg.append("WARNING: Smallest ScalerCrop width reached") if height < sc.scalerCropMin[3]: width = int(width * sc.scalerCropMin[3] / height) height = sc.scalerCropMin[3] msg.append("WARNING: Smallest ScalerCrop height reached") if len(msg) > 0: for m in msg: flash(m) sccrop = (int(xCenter - width / 2), int(yCenter - height / 2), width, height) sc.zoomFactor = zfNext cc.scalerCrop = sccrop cc.include_scalerCrop = True logger.debug("ScalerCrop new: %s", cc.scalerCrop) Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Zoom changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) def checkScalerCrop(crop: tuple, range: tuple) -> tuple: """Check given cropping rectangle with respect to maximum rectangle Params: crop: cropping rectangle to be tested (xOffset, yOffset, width, height) range: allowed range (xOffset, yOffset, width, height) Return: crop: cropping rectangle with initial dimensions but eventually adjusted offset msg: Message list with modifications made """ res = crop msg = [] x0 = crop[0] y0 = crop[1] width = crop[2] height = crop[3] if x0 < range[0]: msg.append("WARNING: left border reached") x0 = range[0] if y0 < range[1]: msg.append("WARNING: upper border reached") y0 = range[1] if x0 + width > range[0] + range[2]: msg.append("WARNING: right border reached") x0 = range[0] + range[2] - width if y0 + height > range[1] + range[3]: msg.append("WARNING: lower border reached") y0 = range[1] + range[3] - height return ((x0, y0, crop[2], crop[3]), msg) @bp.route("/zoom_out", methods=("GET", "POST")) @login_required def zoom_out(): logger.debug("In zoom_out") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": xCenter = cc.scalerCrop[0] + int(cc.scalerCrop[2] / 2) yCenter = cc.scalerCrop[1] + int(cc.scalerCrop[3] / 2) zfNext = sc.zoomFactor + sc.zoomFactorStep msg0 = "" if zfNext >= 100: zfNext = 100 width = sc.scalerCropDef[2] height = sc.scalerCropDef[3] msg0 = "WARNING: Maximum zoom reached" else: width = int(sc.scalerCropDef[2] * zfNext / 100) height = int(sc.scalerCropDef[3] * zfNext / 100) ll = (xCenter - int(width / 2), yCenter - int(height / 2)) sccrop = (ll[0], ll[1], width, height) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) if msg0 != "": msg.append(msg0) if len(msg) > 0: for m in msg: flash(m) sc.zoomFactor = zfNext cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Zoom changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/zoom_full", methods=("GET", "POST")) @login_required def zoom_full(): logger.debug("In zoom_full") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": sc.isZoomModeDraw = False sc.zoomFactor = 100 width = sc.scalerCropDef[2] height = sc.scalerCropDef[3] xCenter = cc.scalerCrop[0] + int(cc.scalerCrop[2] / 2) yCenter = cc.scalerCrop[1] + int(cc.scalerCrop[3] / 2) xOffset = int(xCenter - width / 2) yOffset = int(yCenter - height / 2) sccrop = (xOffset, yOffset, width, height) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Zoom changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/pan_up", methods=("GET", "POST")) @login_required def pan_up(): logger.debug("In pan_up") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": step = int((sc.scalerCropDef[2] * sc.zoomFactorStep) / 100) yOffset = cc.scalerCrop[1] - step sccrop = (cc.scalerCrop[0], yOffset, cc.scalerCrop[2], cc.scalerCrop[3]) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Pan changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/pan_left", methods=("GET", "POST")) @login_required def pan_left(): logger.debug("In pan_left") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": step = int((sc.scalerCropDef[2] * sc.zoomFactorStep) / 100) xOffset = cc.scalerCrop[0] - step sccrop = (xOffset, cc.scalerCrop[1], cc.scalerCrop[2], cc.scalerCrop[3]) logger.debug("pan_left - scalarCropDef : %s", sc.scalerCropDef) logger.debug("pan_left - scalarCrop old : %s", cc.scalerCrop) logger.debug("pan_left - scalarCrop Max : %s", sc.scalerCropMax) logger.debug("pan_left - step: %s xOffset: %s", step, xOffset) logger.debug("pan_left - scalarCrop Init : %s", sccrop) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) logger.debug("pan_left - scalarCrop Final: %s", sccrop) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Pan changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/pan_center", methods=("GET", "POST")) @login_required def pan_center(): logger.debug("In pan_center") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": logger.debug("pan_center scalerCropDef: %s", sc.scalerCropDef) logger.debug("pan_center scalerCrop : %s", cc.scalerCrop) xOffset = int( sc.scalerCropDef[0] + sc.scalerCropDef[2] / 2 - cc.scalerCrop[2] / 2 ) yOffset = int( sc.scalerCropDef[1] + sc.scalerCropDef[3] / 2 - cc.scalerCrop[3] / 2 ) logger.debug("pan_center xOffset: %s, yOffset: %s", xOffset, yOffset) sccrop = (xOffset, yOffset, cc.scalerCrop[2], cc.scalerCrop[3]) logger.debug("pan_center - sccrop initial: %s", sccrop) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) logger.debug("pan_center - sccrop final : %s", sccrop) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Pan changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/pan_right", methods=("GET", "POST")) @login_required def pan_right(): logger.debug("In pan_right") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": step = int((sc.scalerCropDef[2] * sc.zoomFactorStep) / 100) xOffset = cc.scalerCrop[0] + step sccrop = (xOffset, cc.scalerCrop[1], cc.scalerCrop[2], cc.scalerCrop[3]) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Pan changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/pan_down", methods=("GET", "POST")) @login_required def pan_down(): logger.debug("In pan_down") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": step = int((sc.scalerCropDef[2] * sc.zoomFactorStep) / 100) yOffset = cc.scalerCrop[1] + step sccrop = (cc.scalerCrop[0], yOffset, cc.scalerCrop[2], cc.scalerCrop[3]) (sccrop, msg) = checkScalerCrop(sccrop, sc.scalerCropMax) if len(msg) > 0: for m in msg: flash(m) cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Pan changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/zoom_default", methods=("GET", "POST")) @login_required def zoom_default(): logger.debug("In zoom_default") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" sc.getLatestVersion(now=True) if request.method == "POST": sc.isZoomModeDraw = False sc.zoomFactor = 100 sccrop = sc.scalerCropDef cc.scalerCrop = sccrop cc.include_scalerCrop = True Camera().applyControlsForLivestream() time.sleep(0.5) cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Image section set to default for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/zoom_draw", methods=("GET", "POST")) @login_required def zoom_draw(): logger.debug("In zoom_draw") g.hostname = request.host cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "zoom" if request.method == "POST": sc.isZoomModeDraw = True return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/ae_control", methods=("GET", "POST")) @login_required def ae_control(): logger.debug("In ae_control") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "autoexposure" sc.getLatestVersion(now=True) if request.method == "POST": if request.form.get("include_aeconstraintmode") is None: cc.include_aeConstraintMode = False else: cc.include_aeConstraintMode = True aeConstraintMode = int(request.form["aeconstraintmode"]) cc.aeConstraintMode = aeConstraintMode if request.form.get("include_aeenable") is None: cc.include_aeEnable = False else: cc.include_aeEnable = True aeEnable = not request.form.get("aeenable") is None cc.aeEnable = aeEnable if request.form.get("include_aeexposuremode") is None: cc.include_aeExposureMode = False else: cc.include_aeExposureMode = True aeExposureMode = int(request.form["aeexposuremode"]) cc.aeExposureMode = aeExposureMode if request.form.get("include_aemeteringmode") is None: cc.include_aeMeteringMode = False else: cc.include_aeMeteringMode = True aeMeteringMode = int(request.form["aemeteringmode"]) cc.aeMeteringMode = aeMeteringMode if cp.hasFlicker: if request.form.get("include_aeflickermode") is None: cc.include_aeFlickerMode = False else: cc.include_aeFlickerMode = True aeFlickerMode = int(request.form["aeflickermode"]) cc.aeFlickerMode = aeFlickerMode if request.form.get("include_aeflickerperiod") is None: cc.include_aeFlickerPeriod = False else: cc.include_aeFlickerPeriod = True aeFlickerPeriod = int(request.form["aeflickerperiod"]) cc.aeFlickerPeriod = aeFlickerPeriod sc.unsavedChanges = True sc.addChangeLogEntry( f"Auto-Exposure settings changed for {sc.activeCameraInfo}" ) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/exposure_control", methods=("GET", "POST")) @login_required def exposure_control(): logger.debug("In exposure_control") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "exposure" sc.getLatestVersion(now=True) if request.method == "POST": if request.form.get("include_analoguegain") is None: cc.include_analogueGain = False else: cc.include_analogueGain = True analogueGain = float(request.form["analoguegain"]) cc.analogueGain = analogueGain if request.form.get("include_colourgains") is None: cc.include_colourGains = False else: cc.include_colourGains = True colourGainRed = float(request.form["colourgainred"]) colourGainBlue = float(request.form["colourgainblue"]) colourGains = (colourGainRed, colourGainBlue) cc.colourGains = colourGains if request.form.get("include_exposuretime") is None: cc.include_exposureTime = False else: cc.include_exposureTime = True exposureTimeSec = float(request.form["exposuretimesec"]) cc.exposureTimeSec = exposureTimeSec exposureTime = cc.exposureTime if request.form.get("include_exposurevalue") is None: cc.include_exposureValue = False else: cc.include_exposureValue = True exposureValue = float(request.form["exposurevalue"]) cc.exposureValue = exposureValue if request.form.get("include_framedurationlimits") is None: cc.include_frameDurationLimits = False else: cc.include_frameDurationLimits = True frameDurationLimitMax = int(request.form["framedurationlimitmax"]) frameDurationLimitMin = int(request.form["framedurationlimitmin"]) frameDurationLimits = (frameDurationLimitMax, frameDurationLimitMin) cc.frameDurationLimits = frameDurationLimits if cp.hasHdr: if request.form.get("include_hdrmode") is None: cc.include_hdrMode = False else: cc.include_hdrMode = True hdrMode = int(request.form["hdrmode"]) cc.hdrMode = hdrMode sc.unsavedChanges = True sc.addChangeLogEntry(f"Exposure settings changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/image_control", methods=("GET", "POST")) @login_required def image_control(): logger.debug("In image_control") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "image" sc.getLatestVersion(now=True) if request.method == "POST": if request.form.get("include_noisereductionmode") is None: cc.include_noiseReductionMode = False else: cc.include_noiseReductionMode = True noiseReductionMode = int(request.form["noisereductionmode"]) cc.noiseReductionMode = noiseReductionMode if request.form.get("include_saturation") is None: cc.include_saturation = False else: cc.include_saturation = True saturation = float(request.form["saturation"]) cc.saturation = saturation if request.form.get("include_sharpness") is None: cc.include_sharpness = False else: cc.include_sharpness = True sharpness = float(request.form["sharpness"]) cc.sharpness = sharpness if request.form.get("include_awbenable") is None: cc.include_awbEnable = False else: cc.include_awbEnable = True awbEnable = not request.form.get("awbenable") is None cc.awbEnable = awbEnable if request.form.get("include_awbmode") is None: cc.include_awbMode = False else: cc.include_awbMode = True awbMode = int(request.form["awbmode"]) cc.awbMode = awbMode if request.form.get("include_contrast") is None: cc.include_contrast = False else: cc.include_contrast = True contrast = float(request.form["contrast"]) cc.contrast = contrast if request.form.get("include_brightness") is None: cc.include_brightness = False else: cc.include_brightness = True brightness = float(request.form["brightness"]) cc.brightness = brightness sc.unsavedChanges = True sc.addChangeLogEntry(f"Image settings changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/meta_clear", methods=("GET", "POST")) @login_required def meta_clear(): logger.debug("In meta_clear") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayMeta = None sc.displayPhoto = None sc.displayHistogram = None sc.displayMetaFirst = 0 sc.displayMetaLast = 999 return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/meta_prev", methods=("GET", "POST")) @login_required def meta_prev(): logger.debug("In meta_prev") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayMetaFirst -= 10 if sc.displayMetaFirst < 0: sc.displayMetaFirst = 0 sc.displayMetaLast = sc.displayMetaFirst + 10 if sc.displayMetaLast > len(sc.displayMeta): sc.displayMetaLast = 999 return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/meta_next", methods=("GET", "POST")) @login_required def meta_next(): logger.debug("In meta_next") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayMetaFirst += 10 sc.displayMetaLast = sc.displayMetaFirst + 10 if sc.displayMetaLast > len(sc.displayMeta): sc.displayMetaLast = 999 sc.displayMetaFirst = len(sc.displayMeta) - 10 if sc.displayMetaFirst < 0: sc.displayMetaFirst = 0 return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/photoBuffer_add", methods=("GET", "POST")) @login_required def photoBuffer_add(): logger.debug("In photoBuffer_add") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayBufferAdd() if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/photoBuffer_remove", methods=("GET", "POST")) @login_required def photoBuffer_remove(): logger.debug("In photoBuffer_remove") g.hostname = request.host cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayBufferRemove() if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/photoBuffer_prev", methods=("GET", "POST")) @login_required def photoBuffer_prev(): logger.debug("In photoBuffer_prev") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayBufferPrev() if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/photoBuffer_next", methods=("GET", "POST")) @login_required def photoBuffer_next(): logger.debug("In photoBuffer_next") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayBufferNext() if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/show_photo", methods=("GET", "POST")) @login_required def show_photo(): logger.debug("In show_photo") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.isDisplayHidden = False return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/hide_photo", methods=("GET", "POST")) @login_required def hide_photo(): logger.debug("In hide_photo") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.isDisplayHidden = True return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/clear_buffer", methods=("GET", "POST")) @login_required def clear_buffer(): logger.debug("In clear_buffer") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayBufferClear() return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/take_photo", methods=("GET", "POST")) @login_required def take_photo(): logger.debug("Thread %s: In take_photo", get_ident()) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Saving image %s", filename) fp = Camera().takeImage(filename) if not sc.error: logger.debug("take_photo - success") logger.debug("take_photo - sc.displayContent: %s", sc.displayContent) if sc.displayContent == "hist": logger.debug( "take_photo - sc.displayHistogram: %s", sc.displayHistogram ) if sc.displayHistogram is None: logger.debug("take_photo - sc.displayPhoto: %s", sc.displayPhoto) if sc.displayPhoto: generateHistogram(sc) msg = "Image saved as " + fp flash(msg) else: msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/take_raw_photo", methods=("GET", "POST")) @login_required def take_raw_photo(): logger.debug("Thread %s: In take_raw_photo", get_ident()) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": timeImg = datetime.datetime.now() filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType if sc.activeCameraIsUsb == False: filenameRaw = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.rawPhotoType else: filenameRaw = timeImg.strftime("%Y%m%d_%H%M%S") + ".tiff" logger.debug("Saving raw image %s", filenameRaw) fp = Camera().takeRawImage(filenameRaw, filename) if not sc.error: if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) msg = "Image saved as " + fp flash(msg) else: msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/record_video", methods=("GET", "POST")) @login_required def record_video(): logger.debug("Thread %s: In record_video", get_ident()) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": timeImg = datetime.datetime.now() filenameVid = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.videoType filename = timeImg.strftime("%Y%m%d_%H%M%S") + "." + sc.photoType logger.debug("Recording a video %s", filenameVid) fp = Camera().recordVideo(filenameVid, filename) # TODO: Check sleep time. This might lead to errors when stopping video within that time time.sleep(4) if not sc.error: if sc.displayContent == "hist": if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) # Check whether video is being recorded if Camera.isVideoRecording(): logger.debug("Video recording started") sc.isVideoRecording = True if sc.recordAudio: sc.isAudioRecording = True msg = "Video saved as " + fp flash(msg) else: logger.debug("Video recording did not start") sc.isVideoRecording = False sc.isAudioRecording = False msg = "Video recording failed. Requested resolution too high " flash(msg) else: msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/stop_recording", methods=("GET", "POST")) @login_required def stop_recording(): logger.debug("Thread %s: In stop_recording", get_ident()) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": logger.debug("Requesting video recording to stop") Camera().stopVideoRecording() sc.isVideoRecording = False sc.isAudioRecording = False # sleep a little bit to avoid race condition with restoreLiveStream in video thread time.sleep(2) msg = "Video recording stopped" flash(msg) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) def generateHistogram(sc: ServerConfig): """Generate a histogram for the specified image""" logger.debug("In generateHistogram ") import cv2 import numpy as np from matplotlib import pyplot as plt import matplotlib matplotlib.use("agg") source = sc.photoRoot + "/" + sc.displayPhoto destPath = sc.photoRoot + "/" + sc.cameraHistogramSubPath if not os.path.exists(destPath): os.makedirs(destPath) logger.debug("generateHistogram - Created directory %s", destPath) file = sc.displayFile if not file.endswith(".jpg"): file = file[: file.find(".")] + ".jpg" dest = destPath + "/" + file try: plt.figure() img = cv2.imread(source) color = ("b", "g", "r") for i, col in enumerate(color): histr = cv2.calcHist([img], [i], None, [256], [0, 256], accumulate=False) plt.plot(histr, color=col) plt.xlim([0, 256]) plt.savefig(dest) sc.displayHistogram = sc.cameraHistogramSubPath + "/" + file logger.debug( "In generateHistogram - Histogram success: %s", sc.displayHistogram ) plt.close() except Exception as e: sc.displayHistogram = "histogramfailed.jpg" logger.error("Histogram generation error: %s", e) @bp.route("/show_histogram", methods=("GET", "POST")) @login_required def show_histogram(): logger.debug("In show_histogram") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": if sc.useHistograms: if sc.displayHistogram is None: if sc.displayPhoto: generateHistogram(sc) sc.displayContent = "hist" return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/show_metadata", methods=("GET", "POST")) @login_required def show_metadata(): logger.debug("In show_metadata") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) if request.method == "POST": sc.displayContent = "meta" return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/media-viewer") @login_required def media_viewer(): src = request.args.get("src") media_type = request.args.get("type", "image") filename = os.path.basename(src) if src else "" return render_template( "media_viewer.html", src=src, media_type=media_type, filename=filename ) @bp.route("/live_direct_control", methods=("GET", "POST")) @login_required def live_direct_control(): logger.debug("In live_direct_control") g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.getLatestVersion(now=True) return render_template("home/liveDirectPanel.html", cc=cc, sc=sc, cp=cp) @bp.route("/dc_set_Sharpness", methods=["POST"]) @login_required def dc_set_Sharpness(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_Sharpness - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 32.0 default = 1.0 else: min = float(cc.usbCamControls["Sharpness"]["min"]) max = float(cc.usbCamControls["Sharpness"]["max"]) default = float(cc.usbCamControls["Sharpness"]["default"]) cc.sharpness = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_Contrast", methods=["POST"]) @login_required def dc_set_Contrast(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_Contrast - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 32.0 default = 1.0 else: min = float(cc.usbCamControls["Contrast"]["min"]) max = float(cc.usbCamControls["Contrast"]["max"]) default = float(cc.usbCamControls["Contrast"]["default"]) cc.contrast = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_Saturation", methods=["POST"]) @login_required def dc_set_Saturation(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_Saturation - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 32.0 default = 1.0 else: min = float(cc.usbCamControls["Saturation"]["min"]) max = float(cc.usbCamControls["Saturation"]["max"]) default = float(cc.usbCamControls["Saturation"]["default"]) cc.saturation = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_Brightness", methods=["POST"]) @login_required def dc_set_Brightness(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_Brightness - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = -1.0 max = 1.0 default = 0.0 else: min = float(cc.usbCamControls["Brightness"]["min"]) max = float(cc.usbCamControls["Brightness"]["max"]) default = float(cc.usbCamControls["Brightness"]["default"]) cc.brightness = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_exposureTimeSec", methods=["POST"]) @login_required def dc_set_exposureTimeSec(): logger.debug("In dc_set_exposureTimeSec") data = request.get_json() spos = float(data["value"]) logger.debug("dc_set_exposureTimeSec - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 10.0 default = 0.0 else: min = float(cc.usbCamControls["ExposureTime"]["min"]) max = float(cc.usbCamControls["ExposureTime"]["max"]) default = float(cc.usbCamControls["ExposureTime"]["default"]) cc.exposureTimeSec = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_exposureValue", methods=["POST"]) @login_required def dc_set_exposureValue(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_exposureValue - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = -8.0 max = 8.0 default = 0.0 else: min = float(cc.usbCamControls["ExposureValue"]["min"]) max = float(cc.usbCamControls["ExposureValue"]["max"]) default = float(cc.usbCamControls["ExposureValue"]["default"]) cc.exposureValue = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_AnalogueGain", methods=["POST"]) @login_required def dc_set_AnalogueGain(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_AnalogueGain - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 1.0 max = 99.0 default = 1.0 else: min = float(cc.usbCamControls["AnalogueGain"]["min"]) max = float(cc.usbCamControls["AnalogueGain"]["max"]) default = float(cc.usbCamControls["AnalogueGain"]["default"]) cc.analogueGain = sc.sliderPosToCtrlVal(min, max, default, spos) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_ColourGainRed", methods=["POST"]) @login_required def dc_set_ColourGainRed(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_ColourGainRed - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 32.0 default = 0.0 else: min = float(cc.usbCamControls["ColourGainRed"]["min"]) max = float(cc.usbCamControls["ColourGainRed"]["max"]) default = float(cc.usbCamControls["ColourGainRed"]["default"]) cc.colourGains = (sc.sliderPosToCtrlVal(min, max, default, spos), cc.colourGains[1]) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_ColourGainBlue", methods=["POST"]) @login_required def dc_set_ColourGainBlue(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_ColourGainBlue - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.0 max = 32.0 default = 0.0 else: min = float(cc.usbCamControls["ColourGainBlue"]["min"]) max = float(cc.usbCamControls["ColourGainBlue"]["max"]) default = float(cc.usbCamControls["ColourGainBlue"]["default"]) cc.colourGains = (cc.colourGains[0], sc.sliderPosToCtrlVal(min, max, default, spos)) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_FocalDistance", methods=["POST"]) @login_required def dc_set_FocalDistance(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_FocalDistance - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig if sc.activeCameraIsUsb == False: min = 0.001 max = 999.0 else: min = float(cc.usbCamControls["LensPosition"]["min"]) max = float(cc.usbCamControls["LensPosition"]["max"]) cc.focalDistance = round((max * spos**3.0), 3) cfg.streamingCfgInvalid = True Camera().applyControlsForLivestream() return '', 204 @bp.route("/dc_set_ZoomFactor", methods=["POST"]) @login_required def dc_set_ZoomFactor(): data = request.get_json() spos = float(data["value"]) logger.debug("In dc_set_ZoomFactor - data: %s", spos) cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig tc = cfg.triggerConfig cp = cfg.cameraProperties zoomFactor = spos logger.debug("ScalerCrop old: %s", cc.scalerCrop) xCenter = cc.scalerCrop[0] + int(cc.scalerCrop[2] / 2) yCenter = cc.scalerCrop[1] + int(cc.scalerCrop[3] / 2) width = int(sc.scalerCropDef[2] * zoomFactor / 100) height = int(sc.scalerCropDef[3] * zoomFactor / 100) if width < sc.scalerCropMin[2]: height = int(height * sc.scalerCropMin[2] / width) width = sc.scalerCropMin[2] if height < sc.scalerCropMin[3]: width = int(width * sc.scalerCropMin[3] / height) height = sc.scalerCropMin[3] if width > cp.pixelArraySize[0]: width = cp.pixelArraySize[0] if height > cp.pixelArraySize[1]: height = cp.pixelArraySize[1] x0 = int(xCenter - width / 2) y0 = int(yCenter - height / 2) if x0 < 0: x0 = 0 if y0 < 0: y0 = 0 if x0 + width > cp.pixelArraySize[0]: x0 = cp.pixelArraySize[0] - width if y0 + height > cp.pixelArraySize[1]: y0 = cp.pixelArraySize[1] - height sccrop = (x0, y0, width, height) sc.zoomFactor = zoomFactor cc.scalerCrop = sccrop cc.include_scalerCrop = True logger.debug("ScalerCrop new: %s", cc.scalerCrop) Camera().applyControlsForLivestream() time.sleep(0.5) if cc.scalerCrop != sc.scalerCropDef: cc.include_scalerCrop = True else: cc.include_scalerCrop = False metadata = Camera().getMetaData() sc.scalerCropLiveView = metadata["ScalerCrop"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Zoom changed for {sc.activeCameraInfo}") cfg.streamingCfgInvalid = True if tc.checkRoisAgainstScalerCropLiveView(sc.scalerCropLiveView) == False: sc.addChangeLogEntry(f"RoIs or RoNis adjusted for {sc.activeCameraInfo}") return '', 204 @bp.route("/live_do_action//", methods=("GET", "POST")) @login_required def live_do_action(row:None, col=None): logger.debug("In live_do_action - row=%s, col=%s", row, col) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "control" sc.getLatestVersion(now=True) if request.method == "POST": msg = "" r = int(row) c = int(col) btn = sc.lButtons[r][c] action = btn.buttonAction msg = f"Action successfully executed: {action}." result = None if action != "": msg = TriggerHandler.doAction(action) else: msg = "No Action executed" if msg != "": flash(msg) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) @bp.route("/live_execute//", methods=("GET", "POST")) @login_required def live_execute(row:None, col=None): logger.debug("In live_execute - row=%s, col=%s", row, col) g.hostname = request.host g.version = version cfg = CameraCfg() cc = cfg.controls sc = cfg.serverConfig cp = cfg.cameraProperties sc.lastLiveTab = "control" sc.getLatestVersion(now=True) if request.method == "POST": msg = "" r = int(row) c = int(col) btn = sc.lButtons[r][c] cmd = btn.buttonExec args = cmd.rsplit(" ") sc.vButtonArgs = args msg = f"Command successfully executed: {cmd}." result = None if cmd != "": try: result = subprocess.run(args, capture_output=True, text=True, check=True) except CalledProcessError as e: msg = f"Command executed with error: {e}." except Exception as e: msg = f"Command executed with error: {e}." else: msg = "No command executed" if msg != "": flash(msg) return render_template("home/index.html", cc=cc, sc=sc, cp=cp) ================================================ FILE: raspiCamSrv/images.py ================================================ from flask import Blueprint, Response, flash, g, redirect, render_template, request, url_for from flask import send_file, send_from_directory from werkzeug.exceptions import abort from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.camera_pi import Camera from raspiCamSrv.version import version import os from datetime import datetime, timedelta from io import BytesIO from zipfile import ZipFile from raspiCamSrv.auth import login_required import logging bp = Blueprint("images", __name__) logger = logging.getLogger(__name__) def getFileList() -> list: logger.debug("In images/getFileList") cfg = CameraCfg() sc = cfg.serverConfig # Get the filelist fp = sc.photoRoot + "/" + "photos/" + "camera_" + str(sc.pvCamera) fl = os.listdir(fp) # Sort reverse fl.sort(reverse=True) logger.debug("%s files found in %s", len(fl), fp) dl = [] p = 1 cnt = 0 for file in fl: name, ext = os.path.splitext(file) path = "photos/" + "camera_" + str(sc.pvCamera) + "/" + file fpath = os.path.join(fp, file) if ext.lower() != ".dng" \ and ext.lower() != ".tiff" \ and ext.lower() != ".mp4" \ and ext.lower() != ".h264" \ and (not os.path.isdir(fpath)): nameOK = False try: dat = datetime.strptime(name, "%Y%m%d_%H%M%S") nameOK = True except ValueError: nameOK = False if nameOK == True: include = False if dat >= sc.pvFrom and dat <= sc.pvTo: include = True else: include = False if include == True: cnt += 1 entry = {} entry["sel"] = False entry["path"] = path entry["file"] = file entry["name"] = name entry["type"] = "photo" entry["detailPath"] = path dl.append(entry) logger.debug("%s distinct files in selected range", cnt) for file in fl: name, ext = os.path.splitext(file) path = "photos/" + "camera_" + str(sc.pvCamera) + "/" + file if ext.lower() == ".dng" \ or ext.lower() == ".tiff" \ or ext.lower() == ".mp4" \ or ext.lower() == ".h264": # For raw and video, search the placeholder and update for entry in dl: if entry["name"] == name: if ext.lower() == ".dng" \ or ext.lower() == ".tiff": entry["type"] = "raw" entry["file"] = file else: entry["type"] = "video" entry["file"] = file if ext.lower() == ".mp4": entry["detailPath"] = path break sc.pvList = dl @bp.route("/images") @login_required def main(): logger.debug("In images/main") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if sc.pvCamera is None: sc.pvCamera = sc.activeCamera if sc.pvFrom is None: logger.debug("images/main - Setting sc.pvFrom to current date") pvFrom = datetime.now() sc.pvFrom = datetime(year=pvFrom.year, month=pvFrom.month, day=pvFrom.day, hour=0, minute=0, second=0) if sc.pvTo is None: logger.debug("images/main - Setting sc.pvTo to current date") pvTo = datetime.now() sc.pvTo = datetime(year=pvTo.year, month=pvTo.month, day=pvTo.day, hour=23, minute=59, second=59) getFileList() l = len(sc.pvList) if l > 0: msg = f'{l} distinct media files found in specified range (placeholders not included)' else: msg = f'No media files found in specified range' flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/control", methods=("GET", "POST")) @login_required def control(): logger.debug("In images/control") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": pvCamera = request.form["camera"] if pvCamera == "S": sc.pvCamera = "S" else: sc.pvCamera = int(request.form["camera"]) pvFromStr = request.form.get("pvfrom") sc.pvFromStr = pvFromStr pvToStr = request.form.get("pvto") sc.pvToStr = pvToStr getFileList() l = len(sc.pvList) if l > 0: msg = f'{l} distinct media files found in specified range (placeholders not included)' else: msg = f'No media files found in specified range' flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/today", methods=("GET", "POST")) @login_required def today(): logger.debug("In images/today") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": pvFrom = datetime.now() sc.pvFrom = datetime(year=pvFrom.year, month=pvFrom.month, day=pvFrom.day, hour=0, minute=0, second=0) pvTo = datetime.now() sc.pvTo = datetime(year=pvTo.year, month=pvTo.month, day=pvTo.day, hour=23, minute=59, second=59) getFileList() l = len(sc.pvList) if l > 0: msg = f'{l} distinct media files found in specified range (placeholders not included)' else: msg = f'No media files found in specified range' flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/all", methods=("GET", "POST")) @login_required def all(): logger.debug("In images/all") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": sc.pvFrom = datetime(year=1970, month=1, day=1, hour=0, minute=0, second=0) pvTo = datetime.now() sc.pvTo = datetime(year=pvTo.year, month=pvTo.month, day=pvTo.day, hour=23, minute=59, second=59) getFileList() l = len(sc.pvList) if l > 0: msg = f'{l} distinct media files found in specified range (placeholders not included)' else: msg = f'No media files found in specified range' flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/select_all", methods=("GET", "POST")) @login_required def select_all(): logger.debug("In images/select_all") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": for entry in sc.pvList: entry["sel"] = True return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/deselect_all", methods=("GET", "POST")) @login_required def deselect_all(): logger.debug("In images/deselect_all") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": for entry in sc.pvList: entry["sel"] = False return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/select", methods=("GET", "POST")) @login_required def select(): logger.debug("In images/select") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": logger.debug("images/select - selecting") for entry in sc.pvList: name = entry["name"] id = "photo_" + name sel = not request.form.get(id) is None entry["sel"] = sel return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/delete_selected", methods=("GET", "POST")) @login_required def delete_selected(): logger.debug("In images/delete_selected") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": logger.debug("images/delete_selected - deleting") cnt = 0 cntd = 0 cntErr = 0 for entry in sc.pvList: name = entry["name"] sel = entry["sel"] if sel == True: cntd += 1 fp = sc.photoRoot + "/" + "photos/" + "camera_" + str(sc.pvCamera) + "/" fph = sc.photoRoot + "/" + "photos/" + "camera_" + str(sc.pvCamera) + "/hist/" # Delete histogram if it exists fnh = fph + name + ".jpg" cnt, cntErr = deleteFile(fnh, cnt, cntErr) if entry["type"] == "raw": # Detete raw image fnr = fp + name + ".dng" cnt, cntErr = deleteFile(fnr, cnt, cntErr) fnr = fp + name + ".tiff" cnt, cntErr = deleteFile(fnr, cnt, cntErr) if entry["type"] == "video": # Detete video fnv = fp + name + ".mp4" cnt, cntErr = deleteFile(fnv, cnt, cntErr) fnv = fp + name + ".h264" cnt, cntErr = deleteFile(fnv, cnt, cntErr) # Delete photo or placeholder fn = sc.photoRoot + "/" + entry["path"] cnt, cntErr = deleteFile(fn, cnt, cntErr) # Clear displaybuffer if cntd > 0: sc.displayBufferCheck() getFileList() msg = f"{cntd} distinct media removed: {cnt} successful deletions, {cntErr} failed deletions" flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) def deleteFile(fp: str, cntOK, cntErr): logger.debug("images/deleteFile - trying : %s", fp) if os.path.exists(fp): try: os.remove(fp) logger.debug("images/deleteFile - deleted: %s", fp) cntOK += 1 except: cntErr += 1 return cntOK, cntErr @bp.route("/download_selected", methods=("GET", "POST")) @login_required def download_selected(): logger.debug("In images/download_selected") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig cp = cfg.cameraProperties cs = cfg.cameras sc.curMenu = "photos" if request.method == "POST": logger.debug("images/download_selected - Preparing download") # Setup filelist for compression fp = sc.photoRoot + "/" + "photos/" + "camera_" + str(sc.pvCamera) + "/" zl = [] cnt = 0 cntPhoto = 0 cntRaw = 0 cntVideo = 0 for entry in sc.pvList: name = entry["name"] sel = entry["sel"] if sel == True: if entry["type"] == "photo": fn = sc.photoRoot + "/" + entry["path"] if os.path.exists(fn): cnt += 1 cntPhoto += 1 logger.debug("images/download_selected - added %s", fn) zl.append(fn) if entry["type"] == "raw": fn = fp + name + ".dng" if os.path.exists(fn): cnt += 1 cntRaw += 1 logger.debug("images/download_selected - added %s", fn) zl.append(fn) fn = fp + name + ".tiff" if os.path.exists(fn): cnt += 1 cntRaw += 1 logger.debug("images/download_selected - added %s", fn) zl.append(fn) if entry["type"] == "video": fn = fp + name + ".mp4" if os.path.exists(fn): cnt += 1 cntVideo += 1 logger.debug("images/download_selected - added %s", fn) zl.append(fn) fn = fp + name + ".h264" if os.path.exists(fn): cnt += 1 cntVideo += 1 logger.debug("images/download_selected - added %s", fn) zl.append(fn) if len(zl) > 1: logger.debug("images/download_selected - Preparing archive") stream = BytesIO() with ZipFile(stream, 'w') as zf: for file in zl: zf.write(file, os.path.basename(file)) stream.seek(0) logger.debug("images/download_selected - archive done") now = datetime.now() zipName = "raspiCamSrvMedia_" + now.strftime("%Y%m%d_%H%M%S") + ".zip" logger.debug("images/download_selected - downloading as %s", zipName) msg = f"Downloading archive {zipName} with {cntPhoto} photos, {cntRaw} raw photos and {cntVideo} videos." flash(msg) return send_file( stream, as_attachment=True, download_name=zipName ) elif len(zl) == 1: fp = zl[0] (path, file) = os.path.split(fp) msg = f"Downloading {file}" flash(msg) return send_file( fp, as_attachment=True, download_name=file ) msg = "No files selected for download" flash(msg) return render_template("images/main.html", sc=sc, cp=cp, cs=cs) @bp.route("/media-viewer") @login_required def media_viewer(): src = request.args.get("src") media_type = request.args.get("type", "image") filename = os.path.basename(src) if src else "" return render_template( "media_viewer.html", src=src, media_type=media_type, filename=filename ) ================================================ FILE: raspiCamSrv/info.py ================================================ from flask import Blueprint, Response, flash, g, redirect, render_template, request, url_for from werkzeug.exceptions import abort from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg, TuningConfig from raspiCamSrv.version import version import threading from raspiCamSrv.auth import login_required import logging bp = Blueprint("info", __name__) logger = logging.getLogger(__name__) @bp.route("/info") @login_required def main(): logger.debug("In info") cam = Camera().cam g.hostname = request.host g.version = version cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig cp = cfg.cameraProperties sm = cfg.sensorModes logger.debug("In info - len(sm): %s", len(sm)) tcs = {} for c in cs: camnum = str(c.num) if c.num == sc.activeCamera: tcs[camnum] = cfg.tuningConfig else: strc = cfg.streamingCfg if camnum in strc: cstrc = strc[camnum] if "tuningconfig" in cstrc: tcs[camnum] = cstrc["tuningconfig"] else: tcs[camnum] = [] else: tcs[camnum] = TuningConfig() c.status = Camera.cameraStatus(c.num) # Update streaming clients sc.updateStreamingClients() sc.curMenu = "info" return render_template("info/info.html", sm=sm, sc=sc, tcs=tcs, cp=cp, cs=cs, cfg=cfg) ================================================ FILE: raspiCamSrv/motionAlgoIB.py ================================================ ############################################################################################## # Motion detection Algorithms after Isaac Berrios # Source: https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2 # ############################################################################################## import cv2 import os import shutil from glob import glob import copy from _thread import get_ident import re import numpy as np import matplotlib.pyplot as plt from PIL import Image from datetime import datetime from raspiCamSrv.camCfg import CameraCfg import logging logger = logging.getLogger(__name__) class MotionDetectAlgoIB(): """ Superclass for group of algorithms according to Isaac Berrios """ def __init__(self) -> None: # Frames 2:t, 1:t-1 self._frame1 = None self._frame2 = None self._frame2o = None self._frame1g = None self._frame2g = None self._detections = None # Algorithn reference and testing self._test = False self._rois = [] self._ronis = [] self._currentRoI = None self._currentRoiIdx = None self._testFrame1 = None self._testFrame2 = None self._testFrame3 = None self._testFrame4 = None # Variables for video generation self._cfg = CameraCfg() self._tc = self._cfg.triggerConfig self._recordFilename = None self._recordIdx = None self._frameSize = None self._framerate = 20 self._recordingStart = None self._recordingActive = False self._video = None self._videoWithRoi = False @property def frame1(self): return self._frame1 @frame1.setter def frame1(self, value): self._frame1 = value @property def frame2(self): return self._frame2 @frame2.setter def frame2(self, value): self._frame2 = value @property def frame2o(self): return self._frame2o @frame2o.setter def frame2o(self, value): self._frame2o = value @property def frame1g(self): return self._frame1g @frame1g.setter def frame1g(self, value): self._frame1g = value @property def frame2g(self): return self._frame2g @frame2g.setter def frame2g(self, value): self._frame2g = value @property def detections(self): return self._detections @detections.setter def detections(self, value): self._detections = value @property def test(self) -> bool: return self._test @test.setter def test(self, value:bool): self._test = value @property def rois(self): return self._rois @rois.setter def rois(self, value): self._rois = value @property def ronis(self): return self._ronis @ronis.setter def ronis(self, value): self._ronis = value @property def currentRoI(self): return self._currentRoI @currentRoI.setter def currentRoI(self, value): self._currentRoI = value @property def currentRoiIdx(self): return self._currentRoiIdx @currentRoiIdx.setter def currentRoiIdx(self, value): self._currentRoiIdx = value @property def testFrame1(self): return self._testFrame1 @testFrame1.setter def testFrame1(self, value): self._testFrame1 = value @property def testFrame2(self): return self._testFrame2 @testFrame2.setter def testFrame2(self, value): self._testFrame2 = value @property def testFrame3(self): return self._testFrame3 @testFrame3.setter def testFrame3(self, value): self._testFrame3 = value @property def testFrame4(self): return self._testFrame4 @testFrame4.setter def testFrame4(self, value): self._testFrame4 = value @property def tc(self): return self._tc @tc.setter def tc(self, value): self._tc = value @property def recordFilename(self): return self._recordFilename @recordFilename.setter def recordFilename(self, value): self._recordFilename = value @property def recordIdx(self): return self._recordIdx @recordIdx.setter def recordIdx(self, value): self._recordIdx = value @property def frameSize(self): return self._frameSize @frameSize.setter def frameSize(self, value): self._frameSize = value @property def framerate(self): return self._framerate @framerate.setter def framerate(self, value): self._framerate = value @property def recordingStart(self): return self._recordingStart @recordingStart.setter def recordingStart(self, value): self._recordingStart = value @property def recordingActive(self): return self._recordingActive @recordingActive.setter def recordingActive(self, value): self._recordingActive = value @property def video(self): return self._video @video.setter def video(self, value): self._video = value @property def videoWithRoi(self): return self._videoWithRoi @videoWithRoi.setter def videoWithRoi(self, value): self._videoWithRoi = value def startRecordMotion(self, fnRaw, includeRoI: bool = False) -> str: """ Start recording motion Input: fnRaw: Filename without extension Return Filename for video file """ #logger.debug("Thread %s: MotionDetectFrameDiff.startRecordMotion", get_ident()) done = False err = "" try: if self.recordingActive == False: self.recordFilename = fnRaw + ".mp4" save_path = os.path.join(self.tc.actionPath, self.recordFilename) #fourcc = cv2.VideoWriter_fourcc(*'mp4v') fourcc = cv2.VideoWriter_fourcc(*'avc1') fps = self.framerate logger.debug("Thread %s: MotionDetectFrameDiff.startRecordMotion - fps:%s framesize:%s", get_ident(), fps, self.frameSize) self.video = cv2.VideoWriter(save_path, fourcc, fps, self.frameSize) assert self.video.isOpened() self.recordingActive = True self.recordIdx = 0 self.videoWithRoi = includeRoI self.recordMotion() done = True except Exception as e: logger.error("Thread %s: MotionDetectFrameDiff - error when starting recording: %s", get_ident(), e) err = str(e) return (done, self.recordFilename, err) def stopRecordMotion(self): """ Stop recording motion """ #logger.debug("Thread %s: MotionDetectFrameDiff.stopRecordMotion", get_ident()) if self.recordingActive == True: self.video.release() logger.debug("Thread %s: MotionDetectFrameDiff.stopRecordMotion - video released with %s frames", get_ident(), self.recordIdx) self.recordingActive = False def recordMotion(self): """ Record motion as series of png - add new frame """ if self.recordingActive == True: #logger.debug("Thread %s: MotionDetectFrameDiff.recordMotion - recordIdx:%s", get_ident(), self.recordIdx) # Restore RONIs for roni in self.ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] self.frame2[y:y+h, x:x+w] = self.frame2o[y:y+h, x:x+w] self._draw_bboxes() if self.videoWithRoi: for roi in self.rois: x = roi[0] y = roi[1] w = roi[2] h = roi[3] cv2.rectangle(self.frame2, (x,y), (x+w,y+h), (0,255,0), 2) for roni in self.ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] cv2.rectangle(self.frame2, (x,y), (x+w,y+h), (255,0,0), 2) if self.currentRoI is not None: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] cv2.rectangle(self.frame2, (x,y), (x+w,y+h), (0,0,255), 2) if len(self.frame2.shape) == 2: framergb = cv2.cvtColor(self.frame2, cv2.COLOR_YUV2RGB_I420) elif len(self.frame2.shape) == 3: if self.frame2.shape[2] == 4: framergb = cv2.cvtColor(self.frame2, cv2.COLOR_RGBA2RGB) else: framergb = self.frame2 else: framergb = self.frame2 self.video.write(framergb) self.recordIdx += 1 def _frameToStream(self, frame): """Convert frame to bytestream""" frameb = None (stat, frame_jpg) = cv2.imencode(".jpg", frame) if stat == True: frame_jpg_arr = np.array(frame_jpg) frameb = frame_jpg_arr.tobytes() return frameb def _draw_bboxes(self): """ Draw bounding boxes""" #logger.debug("Thread %s: MotionDetectFrameDiff._draw_bboxes", get_ident()) if not self.detections is None: if self.currentRoI is None: xo = 0 yo = 0 else: xo = self.currentRoI[0] yo = self.currentRoI[1] for det in self.detections: x1,y1,x2,y2 = det cv2.rectangle(self.frame2, (x1+xo,y1+yo), (x2+xo,y2+yo), (0,255,0), 2) def _get_contour_detections(self, mask, thresh=400): """ Obtains initial proposed detections from contours discoverd on the mask. Scores are taken as the bbox area, larger is higher. Inputs: mask - thresholded image mask thresh - threshold for contour size Outputs: detectons - array of proposed detection bounding boxes and scores [[x1,y1,x2,y2,s]] """ # get mask contours contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, # cv2.RETR_TREE, cv2.CHAIN_APPROX_TC89_L1) detections = [] for cnt in contours: x,y,w,h = cv2.boundingRect(cnt) area = w*h if area > thresh: detections.append([x,y,x+w,y+h, area]) return np.array(detections) def _non_max_suppression(self, boxes, scores, threshold=1e-1): """ Perform non-max suppression on a set of bounding boxes and corresponding scores Inputs: boxes: a list of bounding boxes in the format [xmin, ymin, xmax, ymax] scores: a list of corresponding scores threshold: the IoU (intersection-over-union) threshold for merging bounding boxes Outputs: boxes - non-max suppressed boxes """ # Sort the boxes by score in descending order boxes = boxes[np.argsort(scores)[::-1]] # remove all contained bounding boxes and get ordered index order = self._remove_contained_bboxes(boxes) keep = [] while order: i = order.pop(0) keep.append(i) for j in order: # Calculate the IoU between the two boxes intersection = max(0, min(boxes[i][2], boxes[j][2]) - max(boxes[i][0], boxes[j][0])) * \ max(0, min(boxes[i][3], boxes[j][3]) - max(boxes[i][1], boxes[j][1])) union = (boxes[i][2] - boxes[i][0]) * (boxes[i][3] - boxes[i][1]) + \ (boxes[j][2] - boxes[j][0]) * (boxes[j][3] - boxes[j][1]) - intersection iou = intersection / union # Remove boxes with IoU greater than the threshold if iou > threshold: order.remove(j) return boxes[keep] def _remove_contained_bboxes(self, boxes): """ Removes all smaller boxes that are contained within larger boxes. Requires bboxes to be sorted by area (score) Inputs: boxes - array bounding boxes sorted (descending) by area [[x1,y1,x2,y2]] Outputs: keep - indexes of bounding boxes that are not entirely contained in another box """ check_array = np.array([True, True, False, False]) keep = list(range(0, len(boxes))) for i in keep: # range(0, len(bboxes)): for j in range(0, len(boxes)): # check if box j is completely contained in box i if np.all((np.array(boxes[j]) >= np.array(boxes[i])) == check_array): try: keep.remove(j) except ValueError: continue return keep class MotionDetectFrameDiff(MotionDetectAlgoIB): """ Motion detection by Frame Differencing """ def __init__(self) -> None: super().__init__() # Algorithn reference and testing self.algoReferenceTit = "Isaac Berrios - Introduction to Motion Detection: Part 1" self.algoReferenceURL = "https://medium.com/@itberrios6/introduction-to-motion-detection-part-1-e031b0bb9bb2" self.testFrame1Title = "Gray Scale Video" self.testFrame2Title = "Gray Scale Frame Difference" self.testFrame3Title = "Motion Mask" self.testFrame4Title = "Bounding Boxes after Non-Maximal Suppression" # Algorithm parameters self._bbox_threshold = 400 self._nms_threshold = 0.001 @property def bbox_threshold(self): return self._bbox_threshold @bbox_threshold.setter def bbox_threshold(self, value): self._bbox_threshold = value @property def nms_threshold(self): return self._nms_threshold @nms_threshold.setter def nms_threshold(self, value): self._nms_threshold = value def detectMotion(self, frame2, frame1, camInfo: str, rois: list, ronis: list): """ Use frame differencing method to detect motion Inputs: frame2 : frame at t+1 frame1 : frame at t Returns: motion : True/False if motion has been detected (#bboxes > 0) trigger: Dict describing trigger """ #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion", get_ident()) motion = False roiDetected = 0 triggerParams = {} triggerParams["cam"] = camInfo roiDetected = 0 self.frame2 = copy.copy(frame2) self.frame1 = copy.copy(frame1) self.frame2o = frame2 self.rois = rois self.ronis = ronis if self.test == True: self.testFrame1 = self._frameToStream(self.frame2) self.testFrame2 = copy.copy(self.frame2)[ :, :, 0] self.testFrame3 = copy.copy(self.frame2)[ :, :, 0] #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - staged frame_gray", get_ident()) for roni in ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] self.frame2[y:y+h, x:x+w] = (255,0,0) self.frame1[y:y+h, x:x+w] = (255,0,0) self.frame2g = cv2.cvtColor(self.frame2, cv2.COLOR_RGB2GRAY) self.frame1g = cv2.cvtColor(self.frame1, cv2.COLOR_RGB2GRAY) if len(rois) == 0: self.currentRoI = None self.detections = self._get_detections(self.frame1g, self.frame2g, bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold) #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: if not self.detections is None: if len(self.detections) > 0: self._draw_bboxes() #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - done draw_bboxes", get_ident()) self.testFrame4 = self._frameToStream(self.frame2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True else: idx = 0 for roi in rois: idx += 1 self.currentRoI = roi x = roi[0] y = roi[1] w = roi[2] h = roi[3] self.frame2g = cv2.cvtColor(self.frame2[y:y+h, x:x+w], cv2.COLOR_RGB2GRAY) self.frame1g = cv2.cvtColor(self.frame1[y:y+h, x:x+w], cv2.COLOR_RGB2GRAY) self.detections = self._get_detections(self.frame1g, self.frame2g, bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold) #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: color = (0,255,0) if not self.detections is None: if len(self.detections) > 0: color = (0,0,255) self._draw_bboxes() #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - done draw_bboxes", get_ident()) cv2.rectangle(self.frame2, (x,y), (x+w,y+h), color, 2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["roi"] = idx triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True roiDetected = idx motion = True break if self.test == True: self.testFrame2 = self._frameToStream(self.testFrame2) self.testFrame3 = self._frameToStream(self.testFrame3) self.testFrame4 = self._frameToStream(self.frame2) trigger = {"trigger":"Motion Detection", "triggertype":"Frame Diff.", "triggerparam":triggerParams} #logger.debug("Thread %s: MotionDetectFrameDiff.detectMotion - motion:%s", get_ident(), motion) return (motion, trigger, roiDetected) def _get_detections(self, frame1, frame2, bbox_thresh=400, nms_thresh=1e-3, mask_kernel=np.array((9,9), dtype=np.uint8)): """ Main function to get detections via Frame Differencing Inputs: frame1 - Grayscale frame at time t frame2 - Grayscale frame at time t + 1 bbox_thresh - Minimum threshold area for declaring a bounding box nms_thresh - IOU threshold for computing Non-Maximal Supression mask_kernel - kernel for morphological operations on motion mask Outputs: detections - list with bounding box locations of all detections bounding boxes are in the form of: (xmin, ymin, xmax, ymax) """ #logger.debug("Thread %s: MotionDetectFrameDiff._get_detections", get_ident()) # get image mask for moving pixels mask = self._get_mask(frame1, frame2, mask_kernel) #logger.debug("Thread %s: MotionDetectFrameDiff._get_detections got mask", get_ident()) if self.test == True: if self.currentRoI is None: self.testFrame3 = self._frameToStream(mask) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] self.testFrame3[y:y+h, x:x+w] = copy.copy(mask[0:h, 0:w]) # get initially proposed detections from contours detections = self._get_contour_detections(mask, bbox_thresh) if len(detections) == 0: return None # separate bboxes and scores bboxes = detections[:, :4] scores = detections[:, -1] # perform Non-Maximal Supression on initial detections return self._non_max_suppression(bboxes, scores, nms_thresh) def _get_mask(self, frame1, frame2, kernel=np.array((9,9), dtype=np.uint8)): """ Obtains image mask Inputs: frame1 - Grayscale frame at time t frame2 - Grayscale frame at time t + 1 kernel - (NxN) array for Morphological Operations Outputs: mask - Thresholded mask for moving pixels """ #logger.debug("Thread %s: MotionDetectFrameDiff._get_mask", get_ident()) frame_diff = cv2.subtract(frame2, frame1) # blur the frame difference frame_diff = cv2.medianBlur(frame_diff, 3) if self.test == True: if self.currentRoI is None: self.testFrame2 = self._frameToStream(frame_diff) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] #logger.debug("Thread %s: MotionDetectFrameDiff._get_mask . Preparing test frame 2 with ROI", get_ident()) self.testFrame2[y:y+h, x:x+w] = copy.copy(frame_diff[0:h, 0:w]) #logger.debug("Thread %s: MotionDetectFrameDiff._get_mask . Done preparing test frame 2 with ROI", get_ident()) mask = cv2.adaptiveThreshold(frame_diff, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,\ cv2.THRESH_BINARY_INV, 11, 3) mask = cv2.medianBlur(mask, 3) # morphological operations mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1) return mask class MotionDetectOpticalFlow(MotionDetectAlgoIB): """ Motion detection by Optical Flow """ def __init__(self) -> None: super().__init__() # Algorithn reference and testing self.algoReferenceTit = "Isaac Berrios - Introduction to Motion Detection: Part 2" self.algoReferenceURL = "https://medium.com/@itberrios6/introduction-to-motion-detection-part-2-6ec3d6b385d4" self.testFrame1Title = "Gray Scale blurred" self.testFrame2Title = "Optical Flow" self.testFrame3Title = "Motion Mask" self.testFrame4Title = "Bounding Boxes after Non-Maximal Suppression" # Algorithm parameters self.bbox_threshold = 400 self.nms_threshold = 0.001 self.motion_threshold = 1 def detectMotion(self, frame2, frame1, camInfo: str, rois: list, ronis: list): """ Use frame differencing method to detect motion Inputs: frame2 : frame at t+1 frame1 : frame at t Returns: motion : True/False if motion has been detected trigger: Dict describing trigger """ #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion", get_ident()) motion = False roiDetected = 0 triggerParams = {} triggerParams["cam"] = camInfo roiDetected = 0 self.frame2 = copy.copy(frame2) self.frame1 = copy.copy(frame1) self.frame2o = frame2 self.rois = rois self.ronis = ronis if self.test == True and len(ronis) > 0: self.testFrame1 = copy.copy(self.frame2) self.testFrame2 = copy.copy(self.frame2) self.testFrame3 = copy.copy(self.frame2) self.testFrame3 = self.testFrame3[:, :, 0] self.testFrame4 = copy.copy(self.frame2) if self.test == True: # convert to grayscale self.testFrame1 = cv2.cvtColor(self.frame2, cv2.COLOR_RGB2GRAY) # blurr image self.testFrame1 = cv2.GaussianBlur(self.testFrame1, dst=None, ksize=(3,3), sigmaX=5) for roni in ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] self.frame2[y:y+h, x:x+w] = (255,0,0) self.frame1[y:y+h, x:x+w] = (255,0,0) if len(rois) == 0: self.currentRoI = None self.detections = self._get_detections(self.frame1, self.frame2, motion_thresh=self.motion_threshold, bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold) #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: if not self.detections is None: if len(self.detections) > 0: self._draw_bboxes() #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - done draw_bboxes", get_ident()) self.testFrame1 = self._frameToStream(self.testFrame1) self.testFrame4 = self._frameToStream(self.frame2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["Motion_thr"] = self.motion_threshold triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True else: idx = 0 for roi in rois: idx += 1 self.currentRoI = roi x = roi[0] y = roi[1] w = roi[2] h = roi[3] #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - checking ROI: %s", get_ident(), self.currentRoI) self.detections = self._get_detections(self.frame1[y:y+h, x:x+w], self.frame2[y:y+h, x:x+w], motion_thresh=self.motion_threshold, bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold) #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: color = (0,255,0) if not self.detections is None: if len(self.detections) > 0: color = (0,0,255) self._draw_bboxes() #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - done draw_bboxes", get_ident()) cv2.rectangle(self.frame2, (x,y), (x+w,y+h), color, 2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["roi"] = idx triggerParams["Motion_thr"] = self.motion_threshold triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True roiDetected = idx motion = True break if self.test == True: self.testFrame1 = self._frameToStream(self.testFrame1) self.testFrame2 = self._frameToStream(self.testFrame2) self.testFrame3 = self._frameToStream(self.testFrame3) self.testFrame4 = self._frameToStream(self.frame2) trigger = {"trigger":"Motion Detection", "triggertype":"Optical Flow", "triggerparam":triggerParams} #logger.debug("Thread %s: MotionDetectOpticalFlow.detectMotion - motion:%s", get_ident(), motion) return (motion, trigger, roiDetected) def _get_detections(self, frame1, frame2, motion_thresh=1, bbox_thresh=400, nms_thresh=0.1, mask_kernel=np.ones((7,7), dtype=np.uint8)): """ Main function to get detections via Frame Differencing Inputs: frame1 - Grayscale frame at time t frame2 - Grayscale frame at time t + 1 motion_thresh - Minimum flow threshold for motion bbox_thresh - Minimum threshold area for declaring a bounding box nms_thresh - IOU threshold for computing Non-Maximal Supression mask_kernel - kernel for morphological operations on motion mask Outputs: detections - list with bounding box locations of all detections bounding boxes are in the form of: (xmin, ymin, xmax, ymax) """ #logger.debug("Thread %s: MotionDetectOpticalFlow._get_detections", get_ident()) # get optical flow flow = self._compute_flow(frame1, frame2) if self.test == True: if self.currentRoI is None: self.testFrame2 = self._frameToStream(self._get_flow_viz(flow)) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] flow_viz = self._get_flow_viz(flow) self.testFrame2[y:y+h, x:x+w] = copy.copy(flow_viz[0:h, 0:w]) #logger.debug("Thread %s: MotionDetectOpticalFlow._get_detections - staged testFrame2", get_ident()) # separate into magntiude and angle mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1]) motion_mask = self._get_motion_mask(mag, motion_thresh=motion_thresh, kernel=mask_kernel) if self.test == True: if self.currentRoI is None: self.testFrame3 = self._frameToStream(motion_mask) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] self.testFrame3[y:y+h, x:x+w] = copy.copy(motion_mask[0:h, 0:w]) #logger.debug("Thread %s: MotionDetectOpticalFlow._get_detections - staged testFrame3", get_ident()) # get initially proposed detections from contours detections = self._get_contour_detections(motion_mask, thresh=bbox_thresh) if len(detections) == 0: return None # separate bboxes and scores bboxes = detections[:, :4] scores = detections[:, -1] # perform Non-Maximal Supression on initial detections return self._non_max_suppression(bboxes, scores, threshold=nms_thresh) def _compute_flow(self, frame1, frame2): #logger.debug("Thread %s: MotionDetectOpticalFlow._compute_flow", get_ident()) # convert to grayscale gray1 = cv2.cvtColor(frame1, cv2.COLOR_RGB2GRAY) gray2 = cv2.cvtColor(frame2, cv2.COLOR_RGB2GRAY) self.frame1g = gray1 self.frame2g = gray2 # blurr image gray1 = cv2.GaussianBlur(gray1, dst=None, ksize=(3,3), sigmaX=5) gray2 = cv2.GaussianBlur(gray2, dst=None, ksize=(3,3), sigmaX=5) flow = cv2.calcOpticalFlowFarneback(gray1, gray2, None, pyr_scale=0.75, levels=3, winsize=5, iterations=3, poly_n=10, poly_sigma=1.2, flags=0) return flow def _get_flow_viz(self, flow): """ Obtains BGR image to Visualize the Optical Flow """ hsv = np.zeros((flow.shape[0], flow.shape[1], 3), dtype=np.uint8) hsv[..., 1] = 255 mag, ang = cv2.cartToPolar(flow[..., 0], flow[..., 1]) hsv[..., 0] = ang*180/np.pi/2 hsv[..., 2] = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX) rgb = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) return rgb def _get_motion_mask(self, flow_mag, motion_thresh=1, kernel=np.ones((7,7))): """ Obtains Detection Mask from Optical Flow Magnitude Inputs: flow_mag (array) Optical Flow magnitude motion_thresh - thresold to determine motion kernel - kernal for Morphological Operations Outputs: motion_mask - Binray Motion Mask """ motion_mask = np.uint8(flow_mag > motion_thresh)*255 motion_mask = cv2.erode(motion_mask, kernel, iterations=1) motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_OPEN, kernel, iterations=1) motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_CLOSE, kernel, iterations=3) return motion_mask def _get_contour_detections_2(self, mask, ang, angle_thresh=2, thresh=400): """ Obtains initial proposed detections from contours discoverd on the mask. Scores are taken as the bbox area, larger is higher. Inputs: mask - thresholded image mask angle_thresh - threshold for flow angle standard deviation thresh - threshold for contour size Outputs: detectons - array of proposed detection bounding boxes and scores [[x1,y1,x2,y2,s]] """ # get mask contours contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, # cv2.RETR_TREE, cv2.CHAIN_APPROX_TC89_L1) temp_mask = np.zeros_like(mask) # used to get flow angle of contours angle_thresh = angle_thresh*ang.std() detections = [] for cnt in contours: # get area of contour x,y,w,h = cv2.boundingRect(cnt) area = w*h # get flow angle inside of contour cv2.drawContours(temp_mask, [cnt], 0, (255,), -1) flow_angle = ang[np.nonzero(temp_mask)] if (area > thresh) and (flow_angle.std() < angle_thresh): detections.append([x,y,x+w,y+h, area]) return np.array(detections) class MotionDetectBgSubtract(MotionDetectAlgoIB): """ Motion detection by Background Subtraction """ def __init__(self) -> None: super().__init__() # Algorithn reference and testing self.algoReferenceTit = "Isaac Berrios - Introduction to Motion Detection: Part 3" self.algoReferenceURL = "https://medium.com/@itberrios6/introduction-to-motion-detection-part-3-025271f66ef9" self.testFrame1Title = "Normal Video" self.testFrame2Title = "Current Background" self.testFrame3Title = "Motion Mask" self.testFrame4Title = "Bounding Boxes after Non-Maximal Suppression" # Algorithm parameters self.bbox_threshold = 400 self.nms_threshold = 0.001 self._backSubModel = "MOG2" self._backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) self._backSub.setShadowThreshold(0.5) # For ROIs, each ROI will have its own background model cfg = CameraCfg() tc = cfg.triggerConfig self._roiBackSubs = [] if tc.useRoI == True: for roi in tc.regionOfInterest: backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) backSub.setShadowThreshold(0.5) self._roiBackSubs.append(backSub) @property def backSubModel(self): return self._backSubModel @backSubModel.setter def backSubModel(self, value): logger.debug("Thread %s: MotionDetectBgSubtract.backSubModel - value: %s", get_ident(), value) cfg = CameraCfg() tc = cfg.triggerConfig if value == "MOG2": self._backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) self._backSub.setShadowThreshold(0.5) self._roiBackSubs = [] if tc.useRoI == True: for roi in tc.regionOfInterest: backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) backSub.setShadowThreshold(0.5) self._roiBackSubs.append(backSub) elif value == "KNN": self._backSub = cv2.createBackgroundSubtractorKNN(dist2Threshold=1000, detectShadows=True) self._roiBackSubs = [] if tc.useRoI == True: for roi in tc.regionOfInterest: backSub = cv2.createBackgroundSubtractorKNN(dist2Threshold=1000, detectShadows=True) backSub.setShadowThreshold(0.5) self._roiBackSubs.append(backSub) else: value = "MOG2" self._backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) self._backSub.setShadowThreshold(0.5) self._roiBackSubs = [] if tc.useRoI == True: for roi in tc.regionOfInterest: backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=16, detectShadows=True) backSub.setShadowThreshold(0.5) self._roiBackSubs.append(backSub) self._backSubModel = value def detectMotion(self, frame2, frame1, camInfo: str, rois: list, ronis: list): """ Use frame differencing method to detect motion Inputs: frame2 : frame at t+1 frame1 : frame at t Returns: motion : True/False if motion has been detected trigger: Dict describing trigger """ #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion", get_ident()) motion = False roiDetected = 0 triggerParams = {} triggerParams["cam"] = camInfo roiDetected = 0 self.frame2 = copy.copy(frame2) self.frame1 = copy.copy(frame1) self.frame2o = frame2 self.rois = rois self.ronis = ronis if self.test == True: self.testFrame1 = self._frameToStream(self.frame2) self.testFrame2 = copy.copy(self.frame2) self.testFrame3 = copy.copy(self.frame2)[ :, :, 0] #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - staged frame_gray", get_ident()) for roni in ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] self.frame2[y:y+h, x:x+w] = (255,0,0) self.frame1[y:y+h, x:x+w] = (255,0,0) if len(rois) == 0: self.currentRoI = None kernel=np.array((9,9), dtype=np.uint8) self.detections = self._get_detections(self._backSub, self.frame2, bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold, kernel=kernel) #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: if not self.detections is None: if len(self.detections) > 0: self._draw_bboxes() #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - done draw_bboxes", get_ident()) self.testFrame4 = self._frameToStream(self.frame2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["Model"] = self.backSubMod triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True else: idx = 0 for roi in rois: idx += 1 self.currentRoI = roi self.currentRoiIdx = idx x = roi[0] y = roi[1] w = roi[2] h = roi[3] kernel=np.array((9,9), dtype=np.uint8) self.detections = self._get_detections(self._backSub, self.frame2[y:y+h, x:x+w], bbox_thresh=self.bbox_threshold, nms_thresh=self.nms_threshold, kernel=kernel) #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - got detections: %s", get_ident(), self.detections) if self.test == True: color = (0,255,0) if not self.detections is None: if len(self.detections) > 0: color = (0,0,255) self._draw_bboxes() #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - done draw_bboxes", get_ident()) cv2.rectangle(self.frame2, (x,y), (x+w,y+h), color, 2) else: if not self.detections is None: if len(self.detections) > 0: triggerParams["roi"] = idx triggerParams["Model"] = self.backSubMod triggerParams["BBox_thr"] = self.bbox_threshold triggerParams["IOU_thr"] = self.nms_threshold motion = True roiDetected = idx motion = True break if self.test == True: self.testFrame2 = self._frameToStream(self.testFrame2) self.testFrame3 = self._frameToStream(self.testFrame3) self.testFrame4 = self._frameToStream(self.frame2) trigger = {"trigger":"Motion Detection", "triggertype":"BG Subtraction", "triggerparam":triggerParams} #logger.debug("Thread %s: MotionDetectBgSubtract.detectMotion - motion:%s", get_ident(), motion) return (motion, trigger, roiDetected) def _get_detections(self, backSub, frame, bbox_thresh=100, nms_thresh=0.1, kernel=np.array((9,9), dtype=np.uint8)): """ Main function to get detections via Frame Differencing Inputs: backSub - Background Subtraction Model frame - Current BGR Frame bbox_thresh - Minimum threshold area for declaring a bounding box nms_thresh - IOU threshold for computing Non-Maximal Supression kernel - kernel for morphological operations on motion mask Outputs: detections - list with bounding box locations of all detections bounding boxes are in the form of: (xmin, ymin, xmax, ymax) """ #logger.debug("Thread %s: MotionDetectBgSubtract._get_detections - backSub %s", get_ident(), backSub) # Update Background Model and get foreground mask if self.currentRoI is None: fg_mask = backSub.apply(frame) else: backSub = self._roiBackSubs[self.currentRoiIdx - 1] fg_mask = backSub.apply(frame) if self.test == True: if self.currentRoI is None: self.testFrame2 = self._frameToStream(backSub.getBackgroundImage()) #logger.debug("Thread %s: MotionDetectBgSubtract._get_detections - staged background", get_ident()) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] bgImage = backSub.getBackgroundImage() self.testFrame2[y:y+h, x:x+w] = copy.copy(bgImage[0:h, 0:w]) # get clean motion mask motion_mask = self._get_motion_mask(fg_mask, kernel=kernel) if self.test == True: if self.currentRoI is None: self.testFrame3 = self._frameToStream(motion_mask) else: x = self.currentRoI[0] y = self.currentRoI[1] w = self.currentRoI[2] h = self.currentRoI[3] self.testFrame3[y:y+h, x:x+w] = copy.copy(motion_mask[0:h, 0:w]) # get initially proposed detections from contours detections = self._get_contour_detections(motion_mask, bbox_thresh) if len(detections) == 0: return None # separate bboxes and scores bboxes = detections[:, :4] scores = detections[:, -1] # perform Non-Maximal Supression on initial detections return self._non_max_suppression(bboxes, scores, nms_thresh) def _get_motion_mask(self, fg_mask, min_thresh=0, kernel=np.array((9,9), dtype=np.uint8)): """ Obtains image mask Inputs: fg_mask - foreground mask kernel - kernel for Morphological Operations Outputs: mask - Thresholded mask for moving pixels """ _, thresh = cv2.threshold(fg_mask,min_thresh,255,cv2.THRESH_BINARY) motion_mask = cv2.medianBlur(thresh, 3) # morphological operations motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_OPEN, kernel, iterations=1) motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_CLOSE, kernel, iterations=1) return motion_mask ================================================ FILE: raspiCamSrv/motionDetector.py ================================================ from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg import numpy as np from _thread import get_ident, allocate_lock import threading import time from datetime import datetime from datetime import timedelta import logging from raspiCamSrv.dbx import get_dbx import smtplib from email.message import EmailMessage import mimetypes import copy logger = logging.getLogger(__name__) class MotionEvent(object): """An Event-like class that signals all active clients when a new frame is available. """ def __init__(self): #logger.debug("Thread %s: CameraEvent.__init__", get_ident()) self.events = {} def wait(self): """Invoked from each client's thread to wait for the next frame.""" #logger.debug("Thread %s: CameraEvent.wait", get_ident()) ident = get_ident() if ident not in self.events: # this is a new client # add an entry for it in the self.events dict # each entry has two elements, a threading.Event() and a timestamp self.events[ident] = [threading.Event(), time.time()] #logger.debug("Thread %s: CameraEvent.wait - Event ident: %s added to events dict. time:%s", get_ident(), ident, self.events[ident][1]) #for ident, event in self.events.items(): #logger.debug("Thread %s: CameraEvent.wait - Event ident: %s Flag: %s Time: %s (Flag False -> blocking)", get_ident(), ident, self.events[ident][0].is_set(), event[1]) return self.events[ident][0].wait() def set(self): """Invoked by MotionDetector when a new frame is available.""" #logger.debug("Thread %s: CameraEvent.set", get_ident()) now = time.time() remove = None for ident, event in self.events.items(): if not event[0].isSet(): # if this client's event is not set, then set it # also update the last set timestamp to now event[0].set() event[1] = now #logger.debug("Thread %s: CameraEvent.set - Event ident: %s Flag: False -> True (unblock/notify)", get_ident(), ident) else: # if the client's event is already set, it means the client # did not process a previous frame # if the event stays set for more than 5 seconds, then assume # the client is gone and remove it #logger.debug("Thread %s: CameraEvent.set - Event ident: %s Flag: True (Last image not processed).", get_ident(), ident) if now - event[1] > 5: #logger.debug("Thread %s: CameraEvent.set - Event ident: %s too old; marked for removal.", get_ident(), ident) remove = ident if remove: del self.events[remove] #logger.debug("Thread %s: CameraEvent.set - Event ident: %s removed.", get_ident(), ident) def clear(self): """Invoked from each client's thread after a frame was processed.""" ident = get_ident() if ident in self.events: self.events[get_ident()][0].clear() #logger.debug("Thread %s: CameraEvent.clear - Flag set to False -> blocking.", get_ident()) class MotionDetector(): """ Class for detection of motion and triggering actions """ logger.debug("Thread %s: MotionDetector - setting class variables", get_ident()) _instance = None db = None mThread = None mThreadStop = False camInfo = "" rois = [] ronis = [] roiDetected = 0 eventKey = None eventStart = None nrPhotos = 0 lastPhoto = None videoStart = None videoKey = None videoStop = None videoEncoder = None videoCircOutput = None videoName = None notificationDone = None notifyMail = None notifyBuffer = [] notifyBufferLock = allocate_lock() # lock for making access to notifyBuffer thread-safe mdAlgo = None event = MotionEvent() # Callbacks when_motion_detected = None def __new__(cls): logger.debug("Thread %s: MotionDetector.__new__", get_ident()) if cls._instance is None: logger.debug("Thread %s: MotionDetector.__new__ - Instantiating Class", get_ident()) cls._instance = super(MotionDetector, cls).__new__(cls) cfg = CameraCfg() tc = cfg.triggerConfig if tc.motionDetectAlgo in range(2, 5): if CameraCfg().serverConfig.supportsExtMotionDetection == True: import raspiCamSrv.motionAlgoIB as mda if tc.motionDetectAlgo == 2: cls.mdAlgo = mda.MotionDetectFrameDiff() elif tc.motionDetectAlgo == 3: cls.mdAlgo = mda.MotionDetectOpticalFlow() else: cls.mdAlgo = mda.MotionDetectBgSubtract() else: tc.error = f"Error while initializing MotionDetector: algorithm {tc.motionDetectAlgo} currently not supported." tc.errorSource = "MotionDetector.__new__" logger.error("Error while initializing MotionDetector: algorithm %s currently not supported", tc.motionDetectAlg) return cls._instance @classmethod def setAlgorithm(cls) -> bool: """ Set the motion detection algorithm The currently active algorithm is taken from trigger configuration Return: True : requested algorithm has been set False: requested algorithm could not be set """ ret = False cls.mdAlgo = None cfg = CameraCfg() tc = cfg.triggerConfig logger.debug("Thread %s: MotionDetector.setAlgorithm - set algorithm to %s", get_ident(), tc.motionDetectAlgo) if tc.motionDetectAlgo == 1: ret = True if tc.motionDetectAlgo in range(2, 5): if CameraCfg().serverConfig.supportsExtMotionDetection == True: import raspiCamSrv.motionAlgoIB as mda if tc.motionDetectAlgo == 2: cls.mdAlgo = mda.MotionDetectFrameDiff() elif tc.motionDetectAlgo == 3: cls.mdAlgo = mda.MotionDetectOpticalFlow() else: cls.mdAlgo = mda.MotionDetectBgSubtract() cls.mdAlgo.backSubModel = tc.backSubModel ret = True return ret def get_testFrame1(self): if not MotionDetector.mdAlgo is None: MotionDetector.event.wait() MotionDetector.event.clear() return MotionDetector.mdAlgo.testFrame1 else: return None def get_testFrame2(self): if not MotionDetector.mdAlgo is None: MotionDetector.event.wait() MotionDetector.event.clear() return MotionDetector.mdAlgo.testFrame2 else: return None def get_testFrame3(self): if not MotionDetector.mdAlgo is None: MotionDetector.event.wait() MotionDetector.event.clear() return MotionDetector.mdAlgo.testFrame3 else: return None def get_testFrame4(self): if not MotionDetector.mdAlgo is None: MotionDetector.event.wait() MotionDetector.event.clear() return MotionDetector.mdAlgo.testFrame4 else: return None @classmethod def prepareRoIs(cls): """ Prepare Regions of Interest for motion detection In the following, capital letters refer to sensor coordinates, while lowercase letters refer to image coordinates. """ logger.debug("Thread %s: MotionDetector.prepareRoIs", get_ident()) cls.rois = [] cls.ronis = [] cfg = CameraCfg() sc = cfg.serverConfig tc = cfg.triggerConfig cls.camInfo = f"{sc.activeCamera}-{sc.activeCameraModel[:8]}" if tc.useRoI == False: return # Sensor size camProps = cfg.cameraProperties Wsensor = camProps.pixelArraySize[0] Hsensor = camProps.pixelArraySize[1] logger.debug("Thread %s: MotionDetector.prepareRoIs - Sensor Size: %sx%s", get_ident(), Wsensor, Hsensor) # Scaler Crop Live View scalerCrop = sc.scalerCropLiveView Xl = scalerCrop[0] Yl = scalerCrop[1] Wl = scalerCrop[2] Hl = scalerCrop[3] logger.debug("Thread %s: MotionDetector.prepareRoIs - Scaler Crop Live View: (%s,%s,%s,%s)", get_ident(), Xl, Yl, Wl, Hl) # Live View size lvCfg = cfg.liveViewConfig streamSize = lvCfg.stream_size wl = streamSize[0] hl = streamSize[1] logger.debug("Thread %s: MotionDetector.prepareRoIs - Live View Size: %sx%s", get_ident(), wl, hl) # Regions of Interest ROIs = tc.regionOfInterest if len(ROIs) > 0: logger.debug("Thread %s: MotionDetector.prepareRoIs - Preparing rois", get_ident()) for ROI in ROIs: Xr = ROI[0] Yr = ROI[1] Wr = ROI[2] Hr = ROI[3] logger.debug("Thread %s: MotionDetector.prepareRoIs - Defined RoI: (%s,%s,%s,%s)", get_ident(), Xr, Yr, Wr, Hr) # Convert to image coordinates xr = int((Xr - Xl) * wl / Wl) if xr < 0: xr = 0 if xr >= wl: xr = wl -1 yr = int((Yr - Yl) * hl / Hl) if yr < 0: yr = 0 if yr >= hl: yr = hl -1 wr = int(Wr * wl / Wl) if wr <= 0: wr = 1 hr = int(Hr * hl / Hl) if hr <= 0: hr = 1 roi = (xr, yr, wr, hr) logger.debug("Thread %s: MotionDetector.prepareRoIs - Converted roi: (%s,%s,%s,%s)", get_ident(), xr, yr, wr, hr) cls.rois.append(roi) # Regions of No Interest RONIs = tc.regionOfNoInterest if len(RONIs) > 0: logger.debug("Thread %s: MotionDetector.prepareRoIs - Preparing ronis", get_ident()) for RONI in RONIs: Xr = RONI[0] Yr = RONI[1] Wr = RONI[2] Hr = RONI[3] logger.debug("Thread %s: MotionDetector.prepareRoIs - Defined RONI: (%s,%s,%s,%s)", get_ident(), Xr, Yr, Wr, Hr) # Convert to image coordinates xr = int((Xr - Xl) * wl / Wl) if xr < 0: xr = 0 if xr >= wl: xr = wl -1 yr = int((Yr - Yl) * hl / Hl) if yr < 0: yr = 0 if yr >= hl: yr = hl -1 wr = int(Wr * wl / Wl) if wr <= 0: wr = 1 hr = int(Hr * hl / Hl) if hr <= 0: hr = 1 roni = (xr, yr, wr, hr) logger.debug("Thread %s: MotionDetector.prepareRoIs - Converted roni: (%s,%s,%s,%s)", get_ident(), xr, yr, wr, hr) cls.ronis.append(roni) @classmethod def _motionDetected(cls, fCur, fPrv) -> tuple: """ Analyze input frames to detect motion """ tc = CameraCfg().triggerConfig # logger.debug("Thread %s: MotionDetector._motionDetected - algo: %s", get_ident(), tc.motionDetectAlgo) motion = False trigger = {} if tc.motionDetectAlgo == 1: (motion, trigger, roiDetected) = cls._motionAlgo_MeanSquare(fCur, fPrv, cls.camInfo, cls.rois, cls.ronis) cls.roiDetected = roiDetected if tc.motionDetectAlgo > 1: (motion, trigger, roiDetected) = cls.mdAlgo.detectMotion(fCur, fPrv, cls.camInfo, cls.rois, cls.ronis) cls.roiDetected = roiDetected cls.event.set() return (motion, trigger) @staticmethod def _motionAlgo_MeanSquare(fCur, fPrv, camInfo: str, rois: list, ronis: list) -> tuple: """ Mean Square algorithm for motion detection """ # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare", get_ident()) triggerParams = {} triggerParams["cam"] = camInfo roiDetected = 0 for roni in ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] fCur[y:y+h, x:x+w] = 0 fPrv[y:y+h, x:x+w] = 0 # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare - cleared %s ronis", get_ident(), len(ronis)) motion = False if len(rois) == 0: msd = np.square(np.subtract(fCur, fPrv)).mean() # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare msd: %s", get_ident(), msd) if msd > CameraCfg().triggerConfig.msdThreshold: triggerParams["msd"] = str(round(msd, 3)) motion = True else: idx = 0 for roi in rois: idx += 1 x = roi[0] y = roi[1] w = roi[2] h = roi[3] rCur = fCur[y:y+h, x:x+w] rPrv = fPrv[y:y+h, x:x+w] msd = np.square(np.subtract(rCur, rPrv)).mean() # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare shape(fCur)=%s", get_ident(), fCur.shape) # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare shape(rCur)=%s", get_ident(), rCur.shape) # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare roi (%s,%s,%s,%s) msd: %s", get_ident(), x, y, w, h, msd) if msd > CameraCfg().triggerConfig.msdThreshold: triggerParams["roi"] = idx triggerParams["msd"] = str(round(msd, 3)) motion = True roiDetected = idx break # logger.debug("Thread %s: MotionDetector._motionAlgo_MeanSquare - motion: %s", get_ident(), motion) return (motion, {"trigger":"Motion Detection", "triggertype":"Mean Square Diff", "triggerparam":triggerParams}, roiDetected) @classmethod def _doAction(cls, trigger: str): """ Execute action """ logger.debug("Thread %s: MotionDetector._doAction", get_ident()) tc = CameraCfg().triggerConfig logEvent = False now = datetime.now() logger.debug("Thread %s: MotionDetector._doAction - now: %s", get_ident(), now) if cls.eventStart is None: cls.eventKey = now.strftime("%Y-%m-%dT%H:%M:%S") cls.eventStart = now logEvent = True logger.debug("Thread %s: MotionDetector._doAction - New event started with key: %s", get_ident(), cls.eventKey) delta = now - cls.eventStart deltaSec = delta.total_seconds() logger.debug("Thread %s: MotionDetector._doAction - deltaSec: %s", get_ident(), deltaSec) if deltaSec > tc.detectionPauseSec: # Difference to previous event is larger than pause -> new event logger.debug("Thread %s: MotionDetector._doAction - Starting new event", get_ident()) cls.eventKey = now.strftime("%Y-%m-%dT%H:%M:%S") cls.eventStart = now cls.nrPhotos = 0 cls.lastPhoto = None cls._stopAction(force=True) cls.videoStart = None cls.videoStop = None if tc.actionVR == 1: cls.videoEncoder = None cls.videoName = None deltaSec = 0 logEvent = True startVideo = False recordVideo = False doPhoto = False doNotify = False if deltaSec >= tc.detectionDelaySec: if tc.actionVideo == True: if cls.videoStart is None: startVideo = True cls.videoStart = now else: if cls.videoStop is None: recordVideo = True if tc.actionPhoto == True: if cls.nrPhotos == 0: doPhoto = True cls.lastPhoto = now cls.nrPhotos = 1 else: if cls.nrPhotos < tc.actionPhotoBurst: deltaP = now - cls.lastPhoto deltaPSec = deltaP.total_seconds() if deltaPSec >= tc.actionPhotoBurstDelaySec: doPhoto = True cls.nrPhotos += 1 cls.lastPhoto = now if tc.actionNotify == True: if cls.notificationDone is None: doNotify = True else: notifyDelta = cls.eventStart - cls.notificationDone notifyDeltaSec = notifyDelta.total_seconds() if notifyDeltaSec >= tc.notifyPause: if cls.notificationDone < cls.eventStart: doNotify = True logger.debug("Thread %s: MotionDetector._doAction - delta>=delay - logEvent: %s startVideo: %s recordVideo: %s doPhoto: %s doNotify: %s", get_ident(), logEvent, startVideo, recordVideo, doPhoto, doNotify) else: logger.debug("Thread %s: MotionDetector._doAction - delta 1 \ and (tc.videoBboxes == True or tc.photoRois == True): (done, fnVideo, err) = cls.mdAlgo.startRecordMotion(fnRaw, tc.photoRois) encoder = None if done == True: if encoder: cls.videoEncoder = encoder cls.videoName = fnVideo with open(tc.logFilePath, "a") as f: f.write(logTS + " Video: " + fnVideo + " started" + "\n") cls.videoKey = logTS # logger.debug("Thread %s: MotionDetector._doAction - INSERT INTO eventactions - Video", get_ident()) cls.db.execute( "INSERT INTO eventactions (event, timestamp, date, time, actiontype, actionduration, filename, fullpath) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (cls.eventKey, logTS, logTS[:10], logTS[11:19], "Video", tc.actionVideoDuration, fnVideo, tc.actionPath + "/" + fnVideo) ) cls.db.commit() # logger.debug("Thread %s: MotionDetector._doAction - DB committed", get_ident()) else: with open(tc.logFilePath, "a") as f: f.write(logTS + " Video: " + fnVideo + " Start Error: " + err + "\n") photoDone = False if doPhoto: fpPhoto = tc.actionPath + "/" + fnPhoto (done, err, frame) = Camera.quickPhoto(fpPhoto, not tc.photoRois) if tc.photoRois == True and done == False and err == "": (done, err) = cls.savePhotoWithRois(frame, fpPhoto, cls.rois, cls.ronis, cls.roiDetected) photoDone = done if done: with open(tc.logFilePath, "a") as f: f.write(logTS + " Photo: " + fnPhoto + "\n") # logger.debug("Thread %s: MotionDetector._doAction - INSERT INTO eventactions - Photo", get_ident()) cls.db.execute( "INSERT INTO eventactions (event, timestamp, date, time, actiontype, actionduration, filename, fullpath) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (cls.eventKey, logTS, logTS[:10], logTS[11:19], "Photo", 0, fnPhoto, tc.actionPath + "/" + fnPhoto) ) cls.db.commit() # logger.debug("Thread %s: MotionDetector._doAction - DB committed", get_ident()) if not cls.notifyMail is None: if tc.notifyIncludePhoto == True: cls._attachToNotification(cls.notifyMail, fnPhoto) if cls.nrPhotos >= tc.actionPhotoBurst: if tc.notifyIncludeVideo == True: if not cls.videoStop is None: cls._sendNotification() else: cls._sendNotification() else: if err != "": with open(tc.logFilePath, "a") as f: f.write(logTS + " Photo: " + fnPhoto + " Error: " + err + "\n") if doNotify: cls.notificationDone = now logger.debug("Thread %s: MotionDetector._doAction - Notification time: %s", get_ident(), cls.notificationDone) cls.notifyMail = cls._initNotificationMessage(cls.eventKey, trigger) if tc.notifyIncludePhoto: if photoDone: cls._attachToNotification(cls.notifyMail, fnPhoto) if (tc.notifyIncludeVideo == False or tc.actionVideo == False) \ and ((tc.notifyIncludePhoto == False or tc.actionPhoto == False) \ or ((tc.notifyIncludePhoto == True and tc.actionPhoto == True) \ and tc.actionPhotoBurst <= 1)): logger.debug("Thread %s: MotionDetector._doAction - Notification to be sent now", get_ident()) cls._sendNotification() else: logger.debug("Thread %s: MotionDetector._doAction - Notification to be sent later", get_ident()) @staticmethod def savePhotoWithRois(frame, fp, rois: list, ronis: list, roiDetected: int = 0) -> tuple: """ Save a photo with ROIs drawn """ done = False err = "" try: import cv2 logger.debug("Thread %s: MotionDetector.savePhotoWithRois - saving photo with rois to %s", get_ident(), fp) for roni in ronis: x = roni[0] y = roni[1] w = roni[2] h = roni[3] color = (255, 0, 0) cv2.rectangle(frame, (x, y), (x+w, y+h), color, 2) idx = 0 for roi in rois: idx += 1 x = roi[0] y = roi[1] w = roi[2] h = roi[3] if idx == roiDetected: color = (0, 0, 255) else: color = (0, 255, 0) cv2.rectangle(frame, (x, y), (x+w, y+h), color, 2) cv2.imwrite(fp, frame) done = True logger.debug("Thread %s: MotionDetector.savePhotoWithRois - done", get_ident()) except Exception as e: err = str(e) logger.error("Thread %s: MotionDetector.savePhotoWithRois - Error saving photo with rois: %s", get_ident(), err) return (done, err) @staticmethod def _initNotificationMessage(logTS, trigger) -> EmailMessage: """ Set up an eMail Message for notification """ logger.debug("Thread %s: MotionDetector._initNotificationMessage", get_ident()) msg = EmailMessage() tc = CameraCfg().triggerConfig msg["From"] = tc.notifyFrom msg["To"] = tc.notifyTo msg["Subject"] = tc.notifySubject msg.set_content( "Notification on an event\n\n" \ "Time : " + logTS + "\n" \ "Trigger: " + trigger["trigger"] + "\n" \ "Type : " + trigger["triggertype"] + "\n" \ "MSD : " + str(trigger["triggerparam"]) ) logger.debug("Thread %s: MotionDetector._initNotificationMessage - done", get_ident()) return msg @staticmethod def _attachToNotification(msg, fn): """ Attach a photo to a notification mail """ logger.debug("Thread %s: MotionDetector._attachToNotification - fn: %s", get_ident(), fn) tc = CameraCfg().triggerConfig img = tc.actionPath + "/" + fn ctype, encoding = mimetypes.guess_type(img) if ctype is None or encoding is not None: ctype = 'application/octet-stream' maintype, subtype = ctype.split('/', 1) with open(img, 'rb') as fp: msg.add_attachment(fp.read(), maintype=maintype, subtype=subtype, filename=fn) logger.debug("Thread %s: MotionDetector._attachToNotification - done", get_ident()) @classmethod def _sendNotificationThread(cls): """ Send notification mail in own thread """ logger.debug("Thread %s: MotionDetector._sendNotificationThread", get_ident()) tc = CameraCfg().triggerConfig scr =CameraCfg().secrets with cls.notifyBufferLock: msg = cls.notifyBuffer.pop(0) try: if tc.notifyUseSSL == True: server = smtplib.SMTP_SSL(host=tc.notifyHost, port=tc.notifyPort) else: server = smtplib.SMTP(host=tc.notifyHost, port=tc.notifyPort) server.connect(tc.notifyHost) if tc.notifyAuthenticate == True: logger.debug("Thread %s: MotionDetector._sendNotificationThread - Authentication with user/pwd", get_ident()) server.login(scr.notifyUser, scr.notifyPwd) else: logger.debug("Thread %s: MotionDetector._sendNotificationThread - Authentication skipped", get_ident()) server.ehlo() server.send_message(msg) server.quit() except Exception as e: tc.error = "Error sending notification mail: " + str(e) tc.errorSource = "MotionDetector._sendNotificationThread" logger.error("Error sending notification mail: %s", e) logger.debug("Thread %s: MotionDetector._sendNotificationThread - done", get_ident()) @classmethod def _sendNotification(cls): """ Send notification mail """ logger.debug("Thread %s: MotionDetector._sendNotification", get_ident()) msg = copy.copy(cls.notifyMail) with cls.notifyBufferLock: cls.notifyBuffer.append(msg) thread = threading.Thread(target=cls._sendNotificationThread) thread.start() cls.notifyMail = None logger.debug("Thread %s: MotionDetector._sendNotification - done", get_ident()) @classmethod def _cleanupEvent(cls): """ Cleanup event data """ logger.debug("Thread %s: MotionDetector._cleanupEvent", get_ident()) cls._stopAction(force=True) cls.eventKey = None cls.eventStart = None cls.lastPhoto = None cls.nrPhotos = 0 if CameraCfg().triggerConfig.actionVR == 1: cls.videoEncoder = None cls.videoKey = None cls.videoName = None cls.videoStart = None cls.videoStop = None if not cls.notifyMail is None: cls._sendNotification() @classmethod def _stopAction(cls, force = False): """ Stop an active action, if required """ # logger.debug("Thread %s: MotionDetector._stopAction", get_ident()) tc = CameraCfg().triggerConfig waitForVideo = False if not cls.videoStart is None: if cls.videoStop is None: logger.debug("Thread %s: MotionDetector._stopAction - video is running", get_ident()) if not cls.videoName is None: now = datetime.now() dur = now-cls.videoStart durSec = dur.total_seconds() if durSec > tc.actionVideoDuration or force: logger.debug("Thread %s: MotionDetector._stopAction - stopping video", get_ident()) done = False if tc._motionDetectAlgo == 1 \ or tc.videoBboxes == False: if tc.actionVR == 1: (done, err) = Camera.quickVideoStop(cls.videoEncoder) else: (done, err) = Camera.stopRecordingCircular(cls.videoCircOutput) if tc._motionDetectAlgo > 1 \ and tc.videoBboxes == True: cls.mdAlgo.stopRecordMotion() done = True logTS = now.strftime("%Y-%m-%d %H:%M:%S") if done: cls.videoEncoder = None with open(tc.logFilePath, "a") as f: f.write(logTS + " Video: " + cls.videoName + " stopped" + "\n") logger.debug("Thread %s: MotionDetector._stopAction - UPDATE eventactions", get_ident()) cls.db.execute( "UPDATE eventactions set actionduration = ? WHERE event = ? AND timestamp = ? AND actiontype = ?", (round(durSec,0), cls.eventKey, cls.videoKey, "Video") ) cls.db.commit() logger.debug("Thread %s: MotionDetector._stopAction - DB committed", get_ident()) if not cls.notifyMail is None: if tc.notifyIncludeVideo == True: cls._attachToNotification(cls.notifyMail, cls.videoName) now = datetime.now() dur = now -cls.notificationDone durSec = dur.total_seconds() noWait = durSec >= (tc.actionPhotoBurst + 1) * tc.actionPhotoBurstDelaySec if noWait: cls._sendNotification() else: with open(tc.logFilePath, "a") as f: f.write(logTS + " Video: " + cls.videoName + " Stop Error" + err + "\n") cls.videoStop = now else: waitForVideo = True logger.debug("Thread %s: MotionDetector._stopAction - video still within duration", get_ident()) # Send outstanding mails if not cls.notifyMail is None: # A mail has been initialized but not sent yet # Check whether we need to wait for more photos now = datetime.now() dur = now-cls.notificationDone durSec = dur.total_seconds() noWait = durSec >= (tc.actionPhotoBurst + 1) * tc.actionPhotoBurstDelaySec if waitForVideo: noWait = False if force or noWait: logger.debug("Thread %s: MotionDetector._stopAction - outstanding mail to be sent", get_ident()) cls._sendNotification() @staticmethod def _isActive() -> bool: """ Check whether trigger is supposed to be active """ active = True cfg = CameraCfg() tc = cfg.triggerConfig now = datetime.now() wd = str(now.isoweekday()) if tc.operationWeekdays[wd] == True: h = now.hour m = now.minute dm = 60 * h + m if dm >= tc.operationStartMinute \ and dm <= tc.operationEndMinute: active = True else: active = False else: active = False if cfg.serverConfig.isTriggerTesting == True: active = True if active: cfg.serverConfig.isTriggerWaiting = False else: cfg.serverConfig.isTriggerWaiting = True return active @classmethod def _motionThread(cls): """ Motion detection thread """ logger.debug("Thread %s: MotionDetector._motionThread", get_ident()) cls.db = get_dbx() logger.debug("Thread %s: MotionDetector._motionThread - got database", get_ident()) cam = Camera() cfg = CameraCfg() tc = cfg.triggerConfig startTime = datetime.now() count = 0 tc.motionTestFramerate = 0 prv = None if cfg.triggerConfig.actionVR == 2: (done, circ, encoder, err) = cam.startCircular() if done: logger.debug("Thread %s: MotionDetector._motionThread - Encoder for circular output started", get_ident()) cls.videoCircOutput = circ cls.videoEncoder = encoder else: logger.error("Circular output not started: %s", err) cfg.triggerConfig.actionVR = 1 stop = False while not stop: if cls._isActive(): if not cfg.serverConfig.isLiveStream: cam.startLiveStream() try: # Just to keep the live stream running frame, frameRaw = cam.get_frame() cur = cam.getLiveViewImageForMotionDetection() # logger.debug("Thread %s: MotionDetector._motionThread - got live view buffer", get_ident()) if prv is not None: (motion, trigger) = cls._motionDetected(cur, prv) if not cls.mdAlgo is None: if cls.mdAlgo.test == True: count += 1 timeDelta = datetime.now() - startTime timeDeltaSec = timeDelta.total_seconds() if timeDeltaSec > 0.5: tc.motionTestFramerate = count / timeDeltaSec else: count += 1 timeDelta = datetime.now() - startTime timeDeltaSec = timeDelta.total_seconds() if timeDeltaSec > 0.5: tc.motionTestFramerate = count / timeDeltaSec if timeDeltaSec > 3600: count = 0 startTime = datetime.now() if motion: # logger.debug("Thread %s: MotionDetector._motionThread - motion detected", get_ident()) cls._doAction(trigger) cls._stopAction() # logger.debug("Thread %s: MotionDetector._motionThread - stopAction done", get_ident()) if tc.motionDetectAlgo > 1 \ and tc.videoBboxes == True: if not cls.videoStart is None \ and cls.videoStop is None: cls.mdAlgo.recordMotion() prv = cur if cls.mThreadStop: logger.debug("Thread %s: MotionDetector._motionThread - stop requested", get_ident()) cls._stopAction(force=True) stop = True except Exception as e: cls._cleanupEvent() logger.error("Exception in _motionThread: %s", e) cfg.triggerConfig.error = "Error in motion detection: " + str(e) cfg.triggerConfig.errorSource = "motionDetector._motionThread" stop = True else: cls._cleanupEvent() time.sleep(2) if cls.mThreadStop: stop = True if cfg.triggerConfig.actionVR == 2: (done, err) = cam.stopCircular(cls.videoEncoder) if done: logger.debug("Thread %s: MotionDetector._motionThread - Encoder for circular output stopped", get_ident()) cls.videoCircOutput = None cls.videoEncoder = None else: logger.error("Circular output not stopped: %s", err) cls.mThread = None @classmethod def startMotionDetection(cls): """ Start motion detection """ logger.debug("Thread %s: MotionDetector.startMotionDetection", get_ident()) sc = CameraCfg().serverConfig tc = CameraCfg().triggerConfig if cls.mThread is None: sc.error = None tc.error = None cls.prepareRoIs() if tc.motionDetectAlgo == 2: cls.mdAlgo.bbox_threshold = tc.bboxThreshold cls.mdAlgo.nms_threshold = tc.nmsThreshold cls.mdAlgo.frameSize = CameraCfg().liveViewConfig.stream_size cls.mdAlgo.framerate = 15 if tc.motionDetectAlgo == 3: cls.mdAlgo.motion_threshold = tc.motionThreshold cls.mdAlgo.nms_threshold = tc.nmsThreshold cls.mdAlgo.backSubMod = tc.backSubModel cls.mdAlgo.frameSize = CameraCfg().liveViewConfig.stream_size cls.mdAlgo.framerate = 5 if tc.motionDetectAlgo == 4: cls.mdAlgo.bbox_threshold = tc.bboxThreshold cls.mdAlgo.nms_threshold = tc.nmsThreshold cls.mdAlgo.backSubMod = tc.backSubModel cls.mdAlgo.frameSize = CameraCfg().liveViewConfig.stream_size cls.mdAlgo.framerate = 15 if sc.isTriggerTesting == True: logger.debug("Thread %s: MotionDetector.startMotionDetection - Activating test mode", get_ident()) cls.mdAlgo.test = True tc._motionTestFrame1Title = cls.mdAlgo.testFrame1Title tc._motionTestFrame2Title = cls.mdAlgo.testFrame2Title tc._motionTestFrame3Title = cls.mdAlgo.testFrame3Title tc._motionTestFrame4Title = cls.mdAlgo.testFrame4Title tc.motionRefTit = cls.mdAlgo.algoReferenceTit tc.motionRefURL = cls.mdAlgo.algoReferenceURL else: logger.debug("Thread %s: MotionDetector.startMotionDetection - Activating normal mode", get_ident()) if not cls.mdAlgo is None: cls.mdAlgo.test = False if not CameraCfg().serverConfig.isLiveStream: Camera().startLiveStream() if not sc.error: logger.debug("Thread %s: MotionDetector.startMotionDetection - starting new thread", get_ident()) cls.mThread = threading.Thread(target=cls._motionThread, daemon=True) cls.mThread.start() logger.debug("Thread %s: MotionDetector.startMotionDetection - thread started", get_ident()) else: logger.debug("Thread %s: MotionDetector.startMotionDetection - not started", get_ident()) @classmethod def stopMotionDetection(cls): """ Stop motion detection """ logger.debug("Thread %s: MotionDetector.stopMotionDetection", get_ident()) if cls.mThread is None: logger.debug("Thread %s: MotionDetector.stopMotionDetection - thread was not active", get_ident()) else: logger.debug("Thread %s: MotionDetector.stopMotionDetection - stopping thread", get_ident()) cls.mThreadStop = True cnt = 0 while cls.mThread: time.sleep(0.01) cnt += 1 if cnt > 500: logger.error("Motion detection thread did not stop within 5 sec") if cls.mThread.is_alive(): cnt = 0 else: cls.mThread = None # raise TimeoutError("Motion detection thread did not stop within 5 sec") cls.mThreadStop = False cls._cleanupEvent() logger.debug("Thread %s: MotionDetector.stopMotionDetection: Thread has stopped", get_ident()) ================================================ FILE: raspiCamSrv/photoseries.py ================================================ from flask import Blueprint, Response, flash, g, redirect, render_template, request, url_for from flask import send_file from werkzeug.exceptions import abort from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.photoseriesCfg import PhotoSeriesCfg from raspiCamSrv.photoseriesCfg import Series from raspiCamSrv.camera_pi import Camera from raspiCamSrv.sun import Sun from raspiCamSrv.version import version import os import copy from pathlib import Path from datetime import datetime from datetime import timedelta from zoneinfo import ZoneInfo from io import BytesIO from zipfile import ZipFile import time from raspiCamSrv.auth import login_required import logging bp = Blueprint("photoseries", __name__) logger = logging.getLogger(__name__) @bp.route("/photoseries") @login_required def main(): g.hostname = request.host g.version = version # Although not directly needed here, the camara needs to be initialized # in order to load the camera-specific parameters into configuration cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if sc.lastPhotoSeriesTab == "": sc.lastPhotoSeriesTab = "series" return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/new_series", methods=("GET", "POST")) @login_required def new_series(): logger.debug("In new_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" seriesName = request.form["tlnewseries"] logger.debug("seriesName: %s", seriesName) if tl.nameExists(seriesName): msg = "Error: There is already a series with this name." flash(msg) serOK = False else: ser = Series() ser.name = seriesName ser.path = tl.rootPath + "/" + ser.name serOK = True logger.debug("ser.path: %s", ser.path) try: os.makedirs(ser.path, exist_ok=False) logger.debug("ser.path created: %s", ser.path) if sc.useHistograms: os.makedirs(ser.histogramPath, exist_ok=False) except FileExistsError: serOK = False msg = "A folder for this series name exists already: " + ser.path flash(msg) except OSError: serOK = False msg = "A folder with this name cannot be created: " + ser.path + " Choose a different name!" flash(msg) except Exception: serOK = False msg = "A folder with this name cannot be created: " + ser.path + " Choose a different name!" flash(msg) if serOK: ser.logFile = ser.path + "/" + ser.logFileName ser.cfgFile = ser.path + "/" + ser.cfgFileName ser.camFile = ser.path + "/" + ser.camFileName try: Path(ser.logFile).touch() Path(ser.cfgFile).touch() Path(ser.camFile).touch() logger.debug("ser.logFile created: %s", ser.logFile) logger.debug("ser.cfgFile created: %s", ser.cfgFile) logger.debug("ser.camFile created: %s", ser.camFile) except Exception: serOK = False msg = "Unable to create .log or .cfg or .cam File: " + ser.logFile flash(msg) if serOK: dt = datetime.now() + timedelta(minutes=1) dt = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) ser.start = dt ser.end = ser.start ser.interval = 5.0 ser.onDialMarks = False ser.nrShots = 1 ser.nextStatus("create") ser.persist() tl.appendSeries(ser) logger.debug("Series appended: %s", ser.name) tl.curSeries = ser logger.debug("Current series set to: %s", ser.name) sr=tl.curSeries return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/select_series", methods=("GET", "POST")) @login_required def select_series(): logger.debug("In select_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" serName = request.form["selectseries"] logger.debug("selected series: %s", serName) for ser in tl.tlSeries: if ser.name == serName: tl.curSeries = ser break sr = tl.curSeries logger.debug("current series set to: %s", tl.curSeries.name) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/start_series", methods=("GET", "POST")) @login_required def start_series(): logger.debug("In start_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" msg = None sr.error = None if sr.isExposureSeries \ or sr.isFocusStackingSeries: if sc.isTriggerRecording: msg = "Please go to 'Trigger' and stop the active process before changing the configuration" if not msg: if sr.status == "READY": if sr.isExposureSeries or sr.isFocusStackingSeries: #Backup controls cfg.controlsBackup = copy.deepcopy(cfg.controls) logger.debug("Created backup for controls: %s", cfg.controlsBackup.__dict__) if sr.isExposureSeries: # For exposure series disable Auto and set fixed control parameter ctrl = cfg.controls ctrl.aeEnable = False ctrl.include_aeEnable = True ctrl.awbEnable = False ctrl.include_awbEnable = True if sr.isExpGainFix: ctrl.include_analogueGain = True ctrl.analogueGain = sr.expGainStart if sr.isExpExpTimeFix: ctrl.include_exposureTime = True ctrl.exposureTime = sr.expTimeStart if sr.isFocusStackingSeries: # For focus series, set Autofocus to manual ctrl = cfg.controls ctrl.afMode = 0 # Ckeck whether series start is in the past dt = datetime.now() + timedelta(seconds=5) startnow = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) startnow = startnow + timedelta(minutes=0) logger.debug("now: %s startnow: %s sr.start: %s", datetime.now(), startnow, sr.start) if sr.isSunControlledSeries == False: if sr.start <= startnow: logger.debug("Start immediately") sr.start = startnow timedifSec = int(sr.interval * sr.nrShots) delta = timedelta(seconds=timedifSec) serEndRaw = sr.start + delta serEnd = datetime(year=serEndRaw.year, month=serEndRaw.month, day=serEndRaw.day, hour=serEndRaw.hour, minute=serEndRaw.minute) serEnd = serEnd + timedelta(minutes=2) sr.end = serEnd tlOK = True Camera.startPhotoSeries(sr) time.sleep(2) if sc.error: tlOK = False sr.nextStatus("pause") msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) msg = None if sr.error: tlOK = False sr.nextStatus("pause") msg = "Error in " + sr.errorSource + ": " + sr.error flash(msg) if sr.error2: flash(sr.error2) msg = None if tlOK: sr.nextStatus("start") sr.persist() else: logger.debug("Nothing to do sr.status is %s", sr.status) if msg: flash(msg) #return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) return redirect(url_for("photoseries.main")) @bp.route("/pause_series", methods=("GET", "POST")) @login_required def pause_series(): logger.debug("In pause_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" sr.nextStatus("pause") sr.persist() Camera.stopPhotoSeries() if sr.isExposureSeries or sr.isFocusStackingSeries: if cfg.controlsBackup: #Restore controls cfg.controls = copy.deepcopy(cfg.controlsBackup) logger.debug("Restored controls from backup: %s", cfg.controls.__dict__) cfg.controlsBackup = None wait = None if sr.isExposureSeries: #For an exposure series wait for the longest exposure time if sr.isExpGainFix: wait = 0.2 + sr.expTimeStop / 1000000 Camera().applyControlsForLivestream(wait) logger.debug("Restored controls backup") return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/finish_series", methods=("GET", "POST")) @login_required def finish_series(): logger.debug("In finish_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" Camera.stopPhotoSeries() logger.debug("Stopped Photo Series") sr.nextStatus("finish") dt = datetime.now() dt = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) sr.ended = dt sr.persist() if sr.isExposureSeries or sr.isFocusStackingSeries: if cfg.controlsBackup: #Restore controls cfg.controls = copy.deepcopy(cfg.controlsBackup) cfg.controlsBackup = None wait = None if sr.isExposureSeries: #For an exposure series wait for the longest exposure time if sr.isExpGainFix: wait = 0.2 + sr.expTimeStop / 1000000 Camera().applyControlsForLivestream(wait) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/continue_series", methods=("GET", "POST")) @login_required def continue_series(): logger.debug("In continue_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" msg = None sr.error = None if sr.isExposureSeries \ or sr.isFocusStackingSeries: if sc.isTriggerRecording: msg = "Please go to 'Trigger' and stop the active process before changing the configuration" if not msg: if sr.status == "PAUSED": if sr.isExposureSeries or sr.isFocusStackingSeries: #Backup controls cfg.controlsBackup = copy.deepcopy(cfg.controls) logger.debug("Created backup for controls: %s", cfg.controlsBackup.__dict__) if sr.isExposureSeries: # For exposure series disable Auto and set fixed control parameter ctrl = cfg.controls ctrl.aeEnable = False ctrl.include_aeEnable = True ctrl.awbEnable = False ctrl.include_awbEnable = True if sr.isExpGainFix: ctrl.include_analogueGain = True ctrl.analogueGain = sr.expGainStart if sr.isExpExpTimeFix: ctrl.include_exposureTime = True ctrl.exposureTime = sr.expTimeStart if sr.isFocusStackingSeries: # For focus series, set Autofocus to manual ctrl = cfg.controls ctrl.afMode = 0 if sr.isSunControlledSeries == False: #Adjust end time of series logger.debug("Start immediately") if sr.nrShots is None or sr.curShots is None: timedifSec = int(sr.interval) else: timedifSec = int(sr.interval * (sr.nrShots - sr.curShots + 1)) delta = timedelta(seconds=timedifSec) serEndRaw = datetime.now() + delta serEnd = datetime(year=serEndRaw.year, month=serEndRaw.month, day=serEndRaw.day, hour=serEndRaw.hour, minute=serEndRaw.minute) serEnd = serEnd + timedelta(minutes=2) sr.end = serEnd logger.debug("Adjusted series end time to %s", sr.end) tlOK = True Camera.startPhotoSeries(sr) time.sleep(2) if sc.error: tlOK = False sr.nextStatus("pause") msg = "Error in " + sc.errorSource + ": " + sc.error flash(msg) if sc.error2: flash(sc.error2) msg = None if sr.error: tlOK = False sr.nextStatus("pause") msg = "Error in " + sr.errorSource + ": " + sr.error flash(msg) if sr.error2: flash(sr.error2) msg = None if tlOK: sr.nextStatus("start") sr.persist() else: logger.debug("Nothing to do sr.status is %s", sr.status) if msg: flash(msg) return redirect(url_for("photoseries.main")) @bp.route("/remove_series", methods=("GET", "POST")) @login_required def remove_series(): logger.debug("In remove_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" nam = sr.name path = sr.path tl.removeCurrentSeries() sr = tl.curSeries msg = "Photoseries " + nam + " removed. Path: " + path flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/download_series", methods=("GET", "POST")) @login_required def download_series(): logger.debug("In download_series") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": logger.debug("download_series - Preparing archive") sc.lastPhotoSeriesTab = "series" nam = sr.name path = sr.path dt = datetime.now() dt = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) sr.downloaded = dt sr.persist() stream = BytesIO() with ZipFile(stream, 'w') as zf: for root, dirs, files in os.walk(path): for file in files: zf.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), os.path.join(path, '..'))) stream.seek(0) logger.debug("download_series - archive done") now = datetime.now() zipName = "raspiCamSrvSeries_" + nam + "_" + now.strftime("%Y%m%d_%H%M%S") + ".zip" logger.debug("images/download_selected - downloading as %s", zipName) msg = f"Downloading archive {zipName}." flash(msg) return send_file( stream, as_attachment=True, download_name=zipName ) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/series_properties", methods=("GET", "POST")) @login_required def series_properties(): logger.debug("In series_properties") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" if sr.status != "FINISHED": serOK = True if sr.status == "ACTIVE" \ or sr.status == "PAUSED": sertype = sr.type else: sertype = request.form["imgtype"] serStartFormIso = request.form["serstart"] sr.start = datetime.fromisoformat(serStartFormIso) serEndFormIso = request.form["serend"] serIntForm = float(request.form["serinterval"]) if request.form.get("serondialmarks") is None: serOnDialMarks = False else: serOnDialMarks = True serShtForm = int(request.form["sernrshots"]) if request.form.get("isautocontinue") is None: continueOnServerStart = False else: continueOnServerStart = True # Iso date from form does not include seconds, # so we need to cut off the seconds from the stored series serEndOldIso = sr.endIso if len(serEndOldIso) > len(serEndFormIso): serEndOldIso = serEndOldIso[:len(serEndFormIso)] logger.debug("Series end Iso: old=%s form=%s", serEndOldIso, serEndFormIso) if serEndFormIso != serEndOldIso: # End time has been changed serEnd = datetime.fromisoformat(serEndFormIso) timedif = serEnd - sr.start timedifSec = timedif.total_seconds() if timedifSec <= 0: msg = "Series end must be later than series start!" flash(msg) serOK = False else: if serIntForm != sr.interval: # Interval has been changed -> calculate nrShots serInt = serIntForm serNrShots = int(timedifSec / serInt) elif serShtForm != sr.nrShots: # Nr shots has been changed -> calculate interval serNrShots = serShtForm serInt = int(10 * timedifSec / serNrShots) / 10 else: # Only series end has been changed -> keep interval and calculate nr shots serInt = sr.interval serNrShots = int(timedifSec / serInt) else: # Series end not changed -> calculate it from other params serInt = serIntForm serNrShots = serShtForm timedifSec = int(serInt * serNrShots) delta = timedelta(seconds=timedifSec) serEndRaw = sr.start + delta serEnd = datetime(year=serEndRaw.year, month=serEndRaw.month, day=serEndRaw.day, hour=serEndRaw.hour, minute=serEndRaw.minute) serEnd = serEnd + timedelta(minutes=1) if serOK: sr.type = sertype if sr.isSunControlledSeries == False: sr.end = serEnd sr.interval = serInt sr.onDialMarks = serOnDialMarks sr.nrShots = serNrShots if sr.isExposureSeries == False \ and sr.isFocusStackingSeries == False: sr.continueOnServerStart = continueOnServerStart else: sr.continueOnServerStart = False if sr.status == "NEW": sr.nextStatus("configure") sr.persist() else: msg = "The series is already FINISHED" flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/attach_camera_cfg", methods=("GET", "POST")) @login_required def attach_camera_cfg(): logger.debug("In attach_camera_cfg") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" sr = tl.curSeries if sr.type == "jpg": sr.cameraConfig = copy.deepcopy(cfg.photoConfig) msg = "Current 'Photo' configuration and Controls attached to Photoseries." else: sr.cameraConfig = copy.deepcopy(cfg.rawConfig) msg = "Current 'Raw Photo' configuration and Controls attached to Photoseries." sr.cameraControls = copy.deepcopy(cfg.controls) sr.persist() flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/activate_camera_cfg", methods=("GET", "POST")) @login_required def activate_camera_cfg(): logger.debug("In activate_camera_cfg") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" sr = tl.curSeries if sr.cameraConfig: if sr.type == "jpg": cfg.photoConfig = copy.deepcopy(sr.cameraConfig) msg = "'Photo' configuration and Controls replaced with settings from Photoseries." else: cfg.rawConfig = copy.deepcopy(sr.cameraConfig) msg = "'Raw Photo' configuration and Controls replaced with settings from Photoseries." cfg.controls = copy.deepcopy(sr.cameraControls) Camera().applyControlsForLivestream() sr.persist() flash(msg) else: msg="The Photoseries has no camera configuration attached." flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/show_preview", methods=("GET", "POST")) @login_required def show_preview(): logger.debug("In show_preview") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" sr.showPreview = True return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/hide_preview", methods=("GET", "POST")) @login_required def hide_preview(): logger.debug("In hide_preview") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": sc.lastPhotoSeriesTab = "series" sr.showPreview = False return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) def calcSunControlledSeries(sr: Series, sun: Sun): """Determine series end and # shots for sun-controlled series Args: - sr (Series): The series to be processed """ logger.debug("In calcSunControlledSeries") if sr.isSunControlledSeries == True \ and sr.sunCtrlMode == 1: serend = sr.end dayStart = sr.start.astimezone(ZoneInfo(sun.sunTimezone())) now = datetime.now(tz=ZoneInfo(sun.sunTimezone())) if dayStart < now: dayStart = now logger.debug("Start at %s with interval %s", dayStart, sr.interval) day = 1 cnt = sr.curShots if not cnt: cnt = 0 while day <= sr.sunCtrlPeriods: dat = dayStart.strftime("%Y-%m-%d") tim = datetime.fromisoformat(dat) sunrise, sunset = sun.sunrise_sunset(tim) if sr.sunCtrlStart1Trg == 1: start1 = sunrise + timedelta(minutes=sr.sunCtrlStart1Shft) if sr.sunCtrlStart1Trg == 2: start1 = sunset + timedelta(minutes=sr.sunCtrlStart1Shft) if sr.sunCtrlEnd1Trg == 1: end1 = sunrise + timedelta(minutes=sr.sunCtrlEnd1Shft) if sr.sunCtrlEnd1Trg == 2: end1 = sunset + timedelta(minutes=sr.sunCtrlEnd1Shft) serend = end1 start = dayStart if start < start1: start = start1 while start < end1: cnt += 1 start += timedelta(seconds=sr.interval) if start <= end1: cnt += 1 logger.debug("Day: %s - Period 1: %s to %s - #shots: %s", day, start1, end1, cnt) if sr.sunCtrlStart2Trg > 0 and sr.sunCtrlEnd2Trg > 0: if sr.sunCtrlStart2Trg == 1: start2 = sunrise + timedelta(minutes=sr.sunCtrlStart2Shft) if sr.sunCtrlStart2Trg == 2: start2 = sunset + timedelta(minutes=sr.sunCtrlStart2Shft) if sr.sunCtrlEnd2Trg == 1: end2 = sunrise + timedelta(minutes=sr.sunCtrlEnd2Shft) if sr.sunCtrlEnd2Trg == 2: end2 = sunset + timedelta(minutes=sr.sunCtrlEnd2Shft) if end2 > serend: serend = end2 if start < start2: start = start2 while start < end2: cnt += 1 start += timedelta(seconds=sr.interval) if start <= end2: cnt += 1 logger.debug("Day: %s - Period 2: %s to %s - #shots: %s", day, start2, end2, cnt) if cnt > 0: day += 1 dayStart += timedelta(days=1) dayStart = dayStart.strftime("%Y-%m-%d") dayStart = datetime.fromisoformat(dayStart) dayStart = dayStart.astimezone(ZoneInfo(sun.sunTimezone())) sr.end = serend sr.nrShots = cnt logger.debug("calcSunControlledSeries - sr.end=%s, sr.nrShots=%s", sr.end, sr.nrShots) def calcSunAzimuthSeries(sr: Series, sun: Sun): """Determine series end and # shots for sun-azimuth-controlled series Args: - sr (Series): The series to be processed """ logger.debug("In calcSunAzimuthSeries") err = "" if sr.isSunControlledSeries == True \ and sr.sunCtrlMode == 2: start = sr.start.astimezone(ZoneInfo(sun.sunTimezone())) now = datetime.now(tz=ZoneInfo(sun.sunTimezone())) if start < now: start = now logger.debug("calcSunAzimuthSeries - Start at %s", start) serend = start day = 1 cnt = sr.curShots if not cnt: cnt = 0 cur = start while day <= sr.sunCtrlPeriods and err == "": dat = cur.strftime("%Y-%m-%d") tim = datetime.fromisoformat(dat) for a in sr.sunAzimuths: times = sun.find_times_for_azimuth(tim, a) if len(times) == 0: err = f"Azimuth {a} is not reached at day {day} ({dat})" break else: if times[0]["time"] > start: cnt += 1 serend = times[0]["time"] day += 1 cur += timedelta(days=1) if err == "": sr.end = serend sr.nrShots = cnt logger.debug("calcSunAzimuthSeries - sr.end=%s, sr.nrShots=%s", sr.end, sr.nrShots) else: logger.debug("calcSunAzimuthSeries - error: %s", err) return err @bp.route("/tlseries_properties", methods=("GET", "POST")) @login_required def tlseries_properties(): logger.debug("In tlseries_properties") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": ok = True msg = "" sc.lastPhotoSeriesTab = "tldetails" locked = True if sr.status == "NEW" or sr.status == "READY": locked = False if locked == False: if request.form.get("issuncontrolled") is None: sr.isSunControlledSeries = False sr.resetSunCtrlData() else: if sr.isFocusStackingSeries or sr.isExposureSeries: ok = False if sr.isFocusStackingSeries: msg = "The series is already marked as Focus Stack" if sr.isExposureSeries: msg = "The series is already marked as Exposure Series" else: sr.isSunControlledSeries = True logger.debug("tlseries_properties - Series marked as Sun-Controlled Series") if sr.isSunControlledSeries == True: mode = int(request.form["sunctrlmode"]) logger.debug("tlseries_properties - Selected Sun-Control Mode: %s", mode) sr.sunCtrlMode = mode if sr.isSunControlledSeries == True: if sc.locLatitude == 0.0 \ and sc.locLongitude == 0.0 \ and sc.locElevation == 0.0: ok = False msg = "Please go to 'Settings' and set Latitude, Longitude, Elevation and Time Zone" if ok: nrDays = int(request.form["sunctrlperiods"]) sr.sunCtrlPeriods = nrDays sun = Sun(sc.locLatitude, sc.locLongitude, sc.locElevation, sc.locTzKey) now = datetime.now() dat = now.strftime("%Y-%m-%d") tim = datetime.fromisoformat(dat) if sr.sunCtrlMode == 1: sr.sunrise, sr.sunset = sun.sunrise_sunset(tim) sr.resetSunAzimuthata() if sr.sunCtrlMode == 2: sr.resetSunSunriseData() if request.form.get("sunazimuthtime") is None: sunAzimuthTime = None else: sunAzimuthTimeFormIso = request.form["sunazimuthtime"] if sunAzimuthTimeFormIso == "": sunAzimuthTime = None else: sunAzimuthTime = datetime.fromisoformat(sunAzimuthTimeFormIso) logger.debug("tlseries_properties - sunAzimuthTime: %s", sunAzimuthTime) if sunAzimuthTime is None: sunAzimuthTime = datetime.now() if sunAzimuthTime == sr.sunAzimuthTime: sunAzimuthTime = datetime.now() sr.sunAzimuthTime = sunAzimuthTime sr.sunAzimuth = sun.solar_position(sunAzimuthTime)["azimuth"] sr.sunElevation = sun.solar_position(sunAzimuthTime)["elevation"] if locked == False: interval = float(request.form["serinterval2"]) sr.interval = interval if sr.sunCtrlMode == 1: p1StartRef = int(request.form["sunctrlstart1trg"]) p1StartShift = int(request.form["sunctrlstart1shft"]) p1EndRef = int(request.form["sunctrlend1trg"]) p1EndShift = int(request.form["sunctrlend1shft"]) p2StartRef = int(request.form["sunctrlstart2trg"]) p2StartShift = int(request.form["sunctrlstart2shft"]) p2EndRef = int(request.form["sunctrlend2trg"]) p2EndShift = int(request.form["sunctrlend2shft"]) if p1StartRef == 0 or p1EndRef == 0: ok = False msg = "Please specify Reference for Start and End for Period 1!" else: if p1StartRef == p1EndRef and p1StartShift >= p1EndShift: ok = False msg = "The specification for Period 1 is invalid!" if p2StartRef != 0 or p2EndRef != 0: if p2StartRef == 0 or p2EndRef == 0: ok = False msg = "Please specify Reference for Start and End for Period 2 or set both to Unused!" else: if p2StartRef == p2EndRef and p2StartShift >= p2EndShift: ok = False msg = "The specification for Period 2 is invalid!" if ok: if sr.sunCtrlMode == 1: sr.sunCtrlStart1Trg = p1StartRef sr.sunCtrlStart1Shft = p1StartShift if p1StartRef == 1: sr.sunCtrlStart1 = sr.sunrise + timedelta(minutes=p1StartShift) if p1StartRef == 2: sr.sunCtrlStart1 = sr.sunset + timedelta(minutes=p1StartShift) sr.sunCtrlEnd1Trg = p1EndRef sr.sunCtrlEnd1Shft = p1EndShift if p1EndRef == 1: sr.sunCtrlEnd1 = sr.sunrise + timedelta(minutes=p1EndShift) if p1EndRef == 2: sr.sunCtrlEnd1 = sr.sunset + timedelta(minutes=p1EndShift) sr.sunCtrlStart2Trg = p2StartRef sr.sunCtrlStart2Shft = p2StartShift if p2StartRef == 1: sr.sunCtrlStart2 = sr.sunrise + timedelta(minutes=p2StartShift) if p2StartRef == 2: sr.sunCtrlStart2 = sr.sunset + timedelta(minutes=p2StartShift) sr.sunCtrlEnd2Trg = p2EndRef sr.sunCtrlEnd2Shft = p2EndShift if p2EndRef == 1: sr.sunCtrlEnd2 = sr.sunrise + timedelta(minutes=p2EndShift) if p2EndRef == 2: sr.sunCtrlEnd2 = sr.sunset + timedelta(minutes=p2EndShift) calcSunControlledSeries(sr, sun) if sr.sunCtrlMode == 2: sunAzimuths = {} errAzimuths = [] if request.form.get("sunazimuth1") is not None: sunAzimuthStr = request.form["sunazimuth1"] if sunAzimuthStr != "": sunAzimuth = float(sunAzimuthStr) times = sun.find_times_for_azimuth(now, sunAzimuth) if len(times) > 0: dt = times[0]["time"] dts = dt.strftime("%Y-%m-%d %H:%M") sunAzimuths[dts] = sunAzimuth else: errAzimuths.append(sunAzimuth) if request.form.get("sunazimuth2") is not None: sunAzimuthStr = request.form["sunazimuth2"] if sunAzimuthStr != "": sunAzimuth = float(sunAzimuthStr) times = sun.find_times_for_azimuth(now, sunAzimuth) if len(times) > 0: dt = times[0]["time"] dts = dt.strftime("%Y-%m-%d %H:%M") sunAzimuths[dts] = sunAzimuth else: errAzimuths.append(sunAzimuth) if request.form.get("sunazimuth3") is not None: sunAzimuthStr = request.form["sunazimuth3"] if sunAzimuthStr != "": sunAzimuth = float(sunAzimuthStr) times = sun.find_times_for_azimuth(now, sunAzimuth) if len(times) > 0: dt = times[0]["time"] dts = dt.strftime("%Y-%m-%d %H:%M") sunAzimuths[dts] = sunAzimuth else: errAzimuths.append(sunAzimuth) if request.form.get("sunazimuth4") is not None: sunAzimuthStr = request.form["sunazimuth4"] if sunAzimuthStr != "": sunAzimuth = float(sunAzimuthStr) times = sun.find_times_for_azimuth(now, sunAzimuth) if len(times) > 0: dt = times[0]["time"] dts = dt.strftime("%Y-%m-%d %H:%M") sunAzimuths[dts] = sunAzimuth else: errAzimuths.append(sunAzimuth) # Sort azimuths by time sunAzimuths = dict(sorted(sunAzimuths.items(), key=lambda item: item[0])) errAzimuths.sort() n = 0 for azimithTime, azimuth in sunAzimuths.items(): n += 1 if n == 1: sr.sunAzimuth1 = azimuth sr.sunAzimuth1Time = datetime.strptime(azimithTime, "%Y-%m-%d %H:%M") elif n == 2: sr.sunAzimuth2 = azimuth sr.sunAzimuth2Time = datetime.strptime(azimithTime, "%Y-%m-%d %H:%M") elif n == 3: sr.sunAzimuth3 = azimuth sr.sunAzimuth3Time = datetime.strptime(azimithTime, "%Y-%m-%d %H:%M") elif n == 4: sr.sunAzimuth4 = azimuth sr.sunAzimuth4Time = datetime.strptime(azimithTime, "%Y-%m-%d %H:%M") for azimuth in errAzimuths: n += 1 if n == 1: sr.sunAzimuth1 = azimuth sr.sunAzimuth1Time = None elif n == 2: sr.sunAzimuth2 = azimuth sr.sunAzimuth2Time = None elif n == 3: sr.sunAzimuth3 = azimuth sr.sunAzimuth3Time = None elif n == 4: sr.sunAzimuth4 = azimuth sr.sunAzimuth4Time = None for m in range(n+1, 5): if m == 1: sr.sunAzimuth1 = None sr.sunAzimuth1Time = None elif m == 2: sr.sunAzimuth2 = None sr.sunAzimuth2Time = None elif m == 3: sr.sunAzimuth3 = None sr.sunAzimuth3Time = None elif m == 4: sr.sunAzimuth4 = None sr.sunAzimuth4Time = None if len(sunAzimuths) > 0 \ or len(errAzimuths) > 0: msg = calcSunAzimuthSeries(sr, sun) if msg != "": ok = False else: ok = False msg = "Please specify at least one Azimuth" if ok: if not locked: sr.nextStatus("configure") sr.persist() logger.debug("tlseries_properties - Series persisted") if msg != "": flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) def calcExpSeries(start, stop, int): """ Iterate an Exposure Series and return number of shots and stop """ if int == 0: fact = 2 elif int == 1: fact = 2 ** (1.0 / 3) elif int == 2: fact = 4 else: fact = 2 v = start vv = v nrShot = 0 while vv <= stop: v = vv nrShot += 1 vv = vv * fact if v < stop: nrShot += 1 v = v * fact return nrShot, v @bp.route("/expseries_properties", methods=("GET", "POST")) @login_required def expseries_properties(): logger.debug("In expseries_properties") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": ok = True sc.lastPhotoSeriesTab = "exposure" locked = True if sr.status == "NEW" or sr.status == "READY": locked = False if not locked: if request.form.get("isexposure") is None: sr.isExposureSeries = False else: msg = "" if sr.isFocusStackingSeries or sr.isSunControlledSeries: ok = False if sr.isFocusStackingSeries: msg = "The series is already marked as Focus Stack" if sr.isSunControlledSeries: msg = "The series is already marked as sun-controlled Timelapse Series" else: sr.isExposureSeries = True sr.continueOnServerStart = False if request.form.get("isexptimefix") is None: sr.isExpExpTimeFix = False if request.form.get("isexpgainfix") is None: msg = "Select exactly one parameter as fix." ok = False else: sr.isExpGainFix = True else: sr.isExpExpTimeFix = True if request.form.get("isexpgainfix") is None: sr.isExpGainFix = False else: msg = "Select exactly one parameter as fix." ok = False if ok: if sr.isExpGainFix: expTimeStart = int(request.form["exptimestart"]) expTimeStop = int(request.form["exptimestop"]) expTimeStep = int(request.form["exptimestep"]) nrShots, expTimeStop = calcExpSeries(expTimeStart, expTimeStop, expTimeStep) expGainFix = float(request.form["expgainstart"]) sr.nrShots = nrShots sr.expTimeStart = expTimeStart sr.expTimeStop = int(expTimeStop) sr.expTimeStep = expTimeStep sr.expGainStart = expGainFix sr.expGainStop = expGainFix sr.expGainStep = 0 if sr.isExpExpTimeFix: expGainStart = float(request.form["expgainstart"]) expGainStop = float(request.form["expgainstop"]) expGainStep = int(request.form["expgainstep"]) nrShots, expGainStop = calcExpSeries(expGainStart, expGainStop, expGainStep) expTimeFix = int(request.form["exptimestart"]) sr.nrShots = nrShots sr.expGainStart = expGainStart sr.expGainStop = expGainStop sr.expGainStep = expGainStep sr.expTimeStart = expTimeFix sr.expTimeStop = expTimeFix sr.expGTimeStep = 0 else: flash(msg) if ok: sr.nextStatus("configure") sr.persist() else: msg = "Series parameters can not be changed for a series in status " + sr.status flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) def calcFocusSeries(start, stop, intv): """ Iterate an Exposure Series and return number of shots and stop """ nrShot = int((stop - start) / intv) + 1 v = start + (nrShot - 1) * intv if intv < 0: if v > stop: if v + intv > 0: nrShot += 1 else: if v < stop: nrShot += 1 v = start + (nrShot - 1) * intv v = round(v, 2) return nrShot, v @bp.route("/focusstack_properties", methods=("GET", "POST")) @login_required def focusstack_properties(): logger.debug("In focusstack_properties") g.hostname = request.host g.version = version cam = Camera().cam cfg = CameraCfg() sc = cfg.serverConfig tl = PhotoSeriesCfg() sr = tl.curSeries cp = cfg.cameraProperties sc.curMenu = "photoseries" if request.method == "POST": ok = True sc.lastPhotoSeriesTab = "focusstack" locked = True if sr.status == "NEW" or sr.status == "READY": locked = False if not locked: if request.form.get("isfocusstack") is None: sr.isFocusStackingSeries = False else: msg = "" if sr.isExposureSeries or sr.isSunControlledSeries: ok = False if sr.isExposureSeries: msg = "The series is already marked as Exposure Series!" if sr.isSunControlledSeries: msg = "The series is already marked as sun-controlled Timelapse Series!" else: focusStart = float(request.form["focaldiststart"]) focusStop = float(request.form["focaldiststop"]) focusStep = float(request.form["focaldiststep"]) if focusStart <= 0.0: msg = "The start value must be > 0!" ok = False else: if focusStop > focusStart: if focusStep > 0.0: pass else: msg = "If Stop > Start, Interval must be > 0!" ok = False elif focusStop == 0.0: msg = "Stop must not be 0!" ok = False else: if focusStep < 0.0: pass else: msg = "If Stop < Start, Interval must be < 0!" ok = False if ok: sr.isFocusStackingSeries = True sr.continueOnServerStart = False nrShots, focusStop = calcFocusSeries(focusStart, focusStop, focusStep) sr.focalDistStart = focusStart sr.focalDistStop = focusStop sr.focalDistStep = focusStep sr.nrShots = nrShots else: flash(msg) if ok: sr.nextStatus("configure") sr.persist() else: msg = "Series parameters can not be changed for a series in status " + sr.status flash(msg) return render_template("photoseries/main.html", sc=sc, tl=tl, sr=sr, cp=cp) @bp.route("/media-viewer") @login_required def media_viewer(): src = request.args.get("src") media_type = request.args.get("type", "image") filename = os.path.basename(src) if src else "" return render_template( "media_viewer.html", src=src, media_type=media_type, filename=filename ) ================================================ FILE: raspiCamSrv/photoseriesCfg.py ================================================ from datetime import datetime from datetime import timedelta from raspiCamSrv.camCfg import CameraCfg, CameraConfig, CameraControls import raspiCamSrv.camera_pi from raspiCamSrv.sun import Sun from _thread import get_ident import os import csv import copy import shutil from pathlib import Path import json import math import logging logger = logging.getLogger(__name__) class Series(): PHOTODIGITS = 6 # Number of digits for photo number in filename HISTOGRAMFOLDER = "hist" SUNCONTROLMODES = ["Sunrise/Sunset", "Azimuth"] def __init__(self): self._name = "" self._status = "NE" self._path = "" self._start = None self._started = None self._end = None self._ended = None self._downloaded= None self._interval = None self._onDialMarks = None self._nrShots = None self._curShots = None self._type = "jpg" self._continueOnServerStart = False self._showPreview = True self._logFile = None self._cfgFile = None self._camFile = None self._cameraConfig = None self._cameraControls = None self._logHeadlineReq = True self._firstCamEntry = True self._isExposureSeries = False self._isExpExpTimeFix = False self._isExpGainFix = True self._expTimeStart = 125 self._expTimeStop = 1024000 self._expTimeStep = 0 self._expGainStart = 1 self._expGainStop = 16 self._expGainStep = 0 self._isFocusStackingSeries = False self._focalDistStart = 0 self._focalDistStop = 0 self._focalDistStep = 0 self._isSunControlledSeries = False self._sunCtrlMode = 1 self._sunCtrlPeriods = 1 self._sunrise = None self._sunset = None self._sunCtrlStart1Trg = 1 self._sunCtrlStart1Shft = 0 self._sunCtrlStart1 = None self._sunCtrlEnd1Trg = 2 self._sunCtrlEnd1Shft = 0 self._sunCtrlEnd1 = None self._sunCtrlStart2Trg = 0 self._sunCtrlStart2Shft = 0 self._sunCtrlStart2 = None self._sunCtrlEnd2Trg = 0 self._sunCtrlEnd2Shft = 0 self._sunCtrlEnd2 = None self._sunAzimuthTime = None self._sunAzimuth = None self._sunElevation = None self._sunAzimuth1 = None self._sunAzimuth2 = None self._sunAzimuth3 = None self._sunAzimuth4 = None self._sunAzimuth1Time = None self._sunAzimuth2Time = None self._sunAzimuth3Time = None self._sunAzimuth4Time = None self._metaData = {} self._error = None self._error2 = None self._errorSource = None @property def name(self) -> str: return self._name @name.setter def name(self, value: str): self._name = value @property def status(self) -> str: return self._status @status.setter def status(self, value: str): self._status = value @property def nextActions(self) -> list: """Return the allowed lifecycle actions depending on current status """ if self._status == "NE": return ["create",] elif self._status == "NEW": return ["configure", "remove"] elif self._status == "READY": return ["start", "remove"] elif self._status == "ACTIVE": return ["pause", "finish"] elif self.status == "PAUSED": return ["continue", "finish"] elif self.status == "FINISHED": return ["remove",] else: return [] def nextStatus(self, action: str) -> str: """Update and return the lifecycle status depending on action """ if action == "create": self._status = "NEW" elif action == "configure": self._status = "READY" elif action == "start": self._status = "ACTIVE" elif action == "pause": self._status = "PAUSED" elif action == "finish": self._status = "FINISHED" self.logCamCfgCtrlClose() elif action == "continue": self._status = "ACTIVE" else: self._status = "NONE" return self._status @property def path(self) -> str: return self._path @path.setter def path(self, value: str): self._path = value @property def histogramPath(self) -> str: return self._path + "/" + Series.HISTOGRAMFOLDER @property def start(self) -> datetime: return self._start @start.setter def start(self, value: datetime): if value is None: self._start = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._start = dt @property def startIso(self) -> str: return self._start.isoformat() @property def started(self) -> datetime: return self._started @started.setter def started(self, value: datetime): if value is None: self._started = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._started = dt @property def startedIso(self) -> str: if self._started is None: return None else: return self._started.isoformat() @property def end(self) -> datetime: return self._end @end.setter def end(self, value: datetime): if value is None: self._end = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._end = dt @property def endIso(self) -> str: return self._end.isoformat() @property def ended(self) -> datetime: return self._ended @ended.setter def ended(self, value: datetime): if value is None: self._ended = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._ended = dt @property def endedIso(self) -> str: if self.ended is None: return None else: return self._ended.isoformat() @property def downloaded(self) -> datetime: return self._downloaded @downloaded.setter def downloaded(self, value: datetime): if value is None: self.downloaded = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._downloaded = dt @property def downloadedIso(self) -> str: if self._downloaded is None: return None else: return self._downloaded.isoformat() @property def interval(self) -> float: return self._interval @interval.setter def interval(self, value: float): self._interval = value @property def onDialMarks(self) -> bool: return self._onDialMarks @onDialMarks.setter def onDialMarks(self, value: bool): self._onDialMarks = value @property def nrShots(self) -> int: return self._nrShots @nrShots.setter def nrShots(self, value: int): self._nrShots = value @property def curShots(self) -> int: return self._curShots @curShots.setter def curShots(self, value: int): self._curShots = value @property def type(self) -> str: return self._type @type.setter def type(self, value: str): self._type = value @property def continueOnServerStart(self) -> bool: return self._continueOnServerStart @continueOnServerStart.setter def continueOnServerStart(self, value: bool): self._continueOnServerStart = value @property def showPreview(self) -> bool: return self._showPreview @showPreview.setter def showPreview(self, value: bool): self._showPreview = value @property def logFileName(self) -> str: return self.name + "_log.csv" @property def logFileRelPath(self) -> str: return "photoseries/" + self.name + "/" + self.logFileName @property def logFile(self) -> str: return self._logFile @logFile.setter def logFile(self, value: str): self._logFile = value @property def cfgFileName(self) -> str: return self.name + "_cfg.json" @property def cfgFileRelPath(self) -> str: return "photoseries/" + self.name + "/" + self.cfgFileName @property def cfgFile(self) -> str: return self._cfgFile @cfgFile.setter def cfgFile(self, value: str): self._cfgFile = value @property def camFileName(self) -> str: return self.name + "_cam.json" @property def camFileRelPath(self) -> str: return "photoseries/" + self.name + "/" + self.camFileName @property def camFile(self) -> str: return self._camFile @camFile.setter def camFile(self, value: str): self._camFile = value @property def isExposureSeries(self) -> bool: return self._isExposureSeries @isExposureSeries.setter def isExposureSeries(self, value: bool): self._isExposureSeries = value @property def isExpExpTimeFix(self) -> bool: return self._isExpExpTimeFix @isExpExpTimeFix.setter def isExpExpTimeFix(self, value: bool): self._isExpExpTimeFix = value @property def isExpGainFix(self) -> bool: return self._isExpGainFix @isExpGainFix.setter def isExpGainFix(self, value: bool): self._isExpGainFix = value @property def expTimeStart(self) -> int: return self._expTimeStart @expTimeStart.setter def expTimeStart(self, value: int): self._expTimeStart = value @property def expTimeStop(self) -> int: return self._expTimeStop @expTimeStop.setter def expTimeStop(self, value: int): self._expTimeStop = value @property def expTimeStep(self) -> int: return self._expTimeStep @expTimeStep.setter def expTimeStep(self, value: int): """ Step for exposure time: 0: 1 EV 1: 1/3 EV 2: 2 EV """ if value == 0 \ or value == 1 \ or value == 2: self._expTimeStep = value else: self._expTimeStep = 0 @property def expGainStart(self) -> float: return self._expGainStart @expGainStart.setter def expGainStart(self, value: float): self._expGainStart = value @property def expGainStop(self) -> float: return self._expGainStop @expGainStop.setter def expGainStop(self, value: float): self._expGainStop = value @property def expGainStep(self) -> int: return self._expGainStep @expGainStep.setter def expGainStep(self, value: int): """ Step for analogue gain: 0: 1 EV 1: 1/3 EV 2: 2 EV """ if value == 0 \ or value == 1 \ or value == 2: self._expGainStep = value else: self._expGainStep = 0 @property def isFocusStackingSeries(self) -> bool: return self._isFocusStackingSeries @isFocusStackingSeries.setter def isFocusStackingSeries(self, value: bool): self._isFocusStackingSeries = value @property def focalDistStart(self) -> float: return self._focalDistStart @focalDistStart.setter def focalDistStart(self, value: float): self._focalDistStart = value @property def focalDistStop(self) -> float: return self._focalDistStop @focalDistStop.setter def focalDistStop(self, value: float): self._focalDistStop = value @property def focalDistStep(self) -> float: return self._focalDistStep @focalDistStep.setter def focalDistStep(self, value: float): self._focalDistStep = value @property def isSunControlledSeries(self) -> bool: return self._isSunControlledSeries @isSunControlledSeries.setter def isSunControlledSeries(self, value: bool): self._isSunControlledSeries = value if self._isSunControlledSeries == False: if "Azimuth" in self.metaData: del self.metaData["Azimuth"] @property def sunCtrlMode(self) -> int: return self._sunCtrlMode @sunCtrlMode.setter def sunCtrlMode(self, value: int): if value == 1 or value == 2: self._sunCtrlMode = value if value == 2: self.metaData["Azimuth"] = None if value == 1: if "Azimuth" in self.metaData: del self.metaData["Azimuth"] else: self._sunCtrlMode = 0 @property def sunCtrlPeriods(self) -> int: return self._sunCtrlPeriods @sunCtrlPeriods.setter def sunCtrlPeriods(self, value: int): self._sunCtrlPeriods = value @property def sunrise(self) -> datetime: return self._sunrise @sunrise.setter def sunrise(self, value: datetime): if value is None: self._sunrise = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunrise = dt @property def sunriseIso(self) -> str: return self._sunrise.isoformat() @property def sunset(self) -> datetime: return self._sunset @sunset.setter def sunset(self, value: datetime): if value is None: self._sunset = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunset = dt @property def sunsetIso(self) -> str: return self._sunset.isoformat() @property def sunCtrlStart1Trg(self) -> int: return self._sunCtrlStart1Trg @sunCtrlStart1Trg.setter def sunCtrlStart1Trg(self, value: int): self._sunCtrlStart1Trg = value @property def sunCtrlStart1Shft(self) -> int: return self._sunCtrlStart1Shft @sunCtrlStart1Shft.setter def sunCtrlStart1Shft(self, value: int): self._sunCtrlStart1Shft = value @property def sunCtrlStart1(self) -> datetime: return self._sunCtrlStart1 @sunCtrlStart1.setter def sunCtrlStart1(self, value: datetime): if value is None: self._sunset = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunCtrlStart1 = dt @property def sunCtrlStart1Iso(self) -> str: return self._sunCtrlStart1.isoformat() @property def sunCtrlEnd1Trg(self) -> int: return self._sunCtrlEnd1Trg @sunCtrlEnd1Trg.setter def sunCtrlEnd1Trg(self, value: int): self._sunCtrlEnd1Trg = value @property def sunCtrlEnd1Shft(self) -> int: return self._sunCtrlEnd1Shft @sunCtrlEnd1Shft.setter def sunCtrlEnd1Shft(self, value: int): self._sunCtrlEnd1Shft = value @property def sunCtrlEnd1(self) -> datetime: return self._sunCtrlEnd1 @sunCtrlEnd1.setter def sunCtrlEnd1(self, value: datetime): if value is None: self._sunCtrlEnd1 = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunCtrlEnd1 = dt @property def sunCtrlEnd1Iso(self) -> str: return self._sunCtrlEnd1.isoformat() @property def sunCtrlStart2Trg(self) -> int: return self._sunCtrlStart2Trg @sunCtrlStart2Trg.setter def sunCtrlStart2Trg(self, value: int): self._sunCtrlStart2Trg = value @property def sunCtrlStart2Shft(self) -> int: return self._sunCtrlStart2Shft @sunCtrlStart2Shft.setter def sunCtrlStart2Shft(self, value: int): self._sunCtrlStart2Shft = value @property def sunCtrlStart2(self) -> datetime: return self._sunCtrlStart2 @sunCtrlStart2.setter def sunCtrlStart2(self, value: datetime): if value is None: self._sunCtrlStart2 = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunCtrlStart2 = dt @property def sunCtrlStart2Iso(self) -> str: return self._sunCtrlStart2.isoformat() @property def sunCtrlEnd2Trg(self) -> int: return self._sunCtrlEnd2Trg @sunCtrlEnd2Trg.setter def sunCtrlEnd2Trg(self, value: int): self._sunCtrlEnd2Trg = value @property def sunCtrlEnd2Shft(self) -> int: return self._sunCtrlEnd2Shft @sunCtrlEnd2Shft.setter def sunCtrlEnd2Shft(self, value: int): self._sunCtrlEnd2Shft = value @property def sunCtrlEnd2(self) -> datetime: return self._sunCtrlEnd2 @sunCtrlEnd2.setter def sunCtrlEnd2(self, value: datetime): if value is None: self._sunCtrlEnd2 = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunCtrlEnd2 = dt @property def sunCtrlEnd2Iso(self) -> str: return self._sunCtrlEnd2.isoformat() @property def cameraConfig(self) -> CameraConfig: return self._cameraConfig @cameraConfig.setter def cameraConfig(self, value: CameraConfig): self._cameraConfig = value @property def cameraControls(self) -> CameraControls: return self._cameraControls @cameraControls.setter def cameraControls(self, value: CameraControls): self._cameraControls = value @property def sunAzimuthTime(self) -> datetime: return self._sunAzimuthTime @sunAzimuthTime.setter def sunAzimuthTime(self, value: datetime): if value is None: self._sunAzimuthTime = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunAzimuthTime = dt @property def sunAzimuthTimeIso(self) -> str: if self._sunAzimuthTime is None: return None else: return self._sunAzimuthTime.isoformat() @property def sunAzimuth(self) -> float: return self._sunAzimuth @sunAzimuth.setter def sunAzimuth(self, value: float): self._sunAzimuth = value @property def sunElevation(self) -> float: return self._sunElevation @sunElevation.setter def sunElevation(self, value: float): self._sunElevation = value @property def sunAzimuth1(self) -> float: return self._sunAzimuth1 @sunAzimuth1.setter def sunAzimuth1(self, value: float): self._sunAzimuth1 = value @property def sunAzimuth2(self) -> float: return self._sunAzimuth2 @sunAzimuth2.setter def sunAzimuth2(self, value: float): self._sunAzimuth2 = value @property def sunAzimuth3(self) -> float: return self._sunAzimuth3 @sunAzimuth3.setter def sunAzimuth3(self, value: float): self._sunAzimuth3 = value @property def sunAzimuth4(self) -> float: return self._sunAzimuth4 @sunAzimuth4.setter def sunAzimuth4(self, value: float): self._sunAzimuth4 = value @property def sunAzimuth1Time(self) -> datetime: return self._sunAzimuth1Time @sunAzimuth1Time.setter def sunAzimuth1Time(self, value: datetime): if value is None: self._sunAzimuth1Time = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunAzimuth1Time = dt @property def sunAzimuth1TimeIso(self) -> str: if self._sunAzimuth1Time is None: return None else: return self._sunAzimuth1Time.isoformat() @property def sunAzimuth2Time(self) -> datetime: return self._sunAzimuth2Time @sunAzimuth2Time.setter def sunAzimuth2Time(self, value: datetime): if value is None: self._sunAzimuth2Time = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunAzimuth2Time = dt @property def sunAzimuth2TimeIso(self) -> str: if self._sunAzimuth2Time is None: return None else: return self._sunAzimuth2Time.isoformat() @property def sunAzimuth3Time(self) -> datetime: return self._sunAzimuth3Time @sunAzimuth3Time.setter def sunAzimuth3Time(self, value: datetime): if value is None: self._sunAzimuth3Time = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunAzimuth3Time = dt @property def sunAzimuth3TimeIso(self) -> str: if self._sunAzimuth3Time is None: return None else: return self._sunAzimuth3Time.isoformat() @property def sunAzimuth4Time(self) -> datetime: return self._sunAzimuth4Time @sunAzimuth4Time.setter def sunAzimuth4Time(self, value: datetime): if value is None: self._sunAzimuth4Time = None else: dt = datetime(year=value.year, month=value.month, day=value.day, hour=value.hour, minute=value.minute) self._sunAzimuth4Time = dt @property def sunAzimuth4TimeIso(self) -> str: if self._sunAzimuth4Time is None: return None else: return self._sunAzimuth4Time.isoformat() @property def sunAzimuths(self) -> list: azimuths = [] if self._sunAzimuth1 is not None: azimuths.append(self._sunAzimuth1) if self._sunAzimuth2 is not None: azimuths.append(self._sunAzimuth2) if self._sunAzimuth3 is not None: azimuths.append(self._sunAzimuth3) if self._sunAzimuth4 is not None: azimuths.append(self._sunAzimuth4) return azimuths @property def sunAzimuthTimes(self) -> list: azimuthTimes = [] if self._sunAzimuth1Time is not None: azimuthTimes.append(self._sunAzimuth1Time) if self._sunAzimuth2Time is not None: azimuthTimes.append(self._sunAzimuth2Time) if self._sunAzimuth3Time is not None: azimuthTimes.append(self._sunAzimuth3Time) if self._sunAzimuth4Time is not None: azimuthTimes.append(self._sunAzimuth4Time) return azimuthTimes @property def metaData(self) -> dict: return self._metaData @metaData.setter def metaData(self, value: dict): self._metaData = value @property def error(self) -> str: return self._error @error.setter def error(self, value: str): self._error = value if value is None: self._errorSource = None self._error2 = None @property def error2(self) -> str: return self._error2 @error2.setter def error2(self, value: str): self._error2 = value @property def errorSource(self) -> str: return self._errorSource @errorSource.setter def errorSource(self, value: str): self._errorSource = value def resetSunCtrlData(self): """Reset sun control data """ self._sunCtrlMode = 1 self._sunCtrlPeriods = 1 self._sunrise = None self._sunset = None self._sunCtrlStart1Trg = 1 self._sunCtrlStart1Shft = 0 self._sunCtrlStart1 = None self._sunCtrlEnd1Trg = 2 self._sunCtrlEnd1Shft = 0 self._sunCtrlEnd1 = None self._sunCtrlStart2Trg = 0 self._sunCtrlStart2Shft = 0 self._sunCtrlStart2 = None self._sunCtrlEnd2Trg = 0 self._sunCtrlEnd2Shft = 0 self._sunCtrlEnd2 = None self._sunAzimuthTime = None self._sunAzimuth = None self._sunAzimuth1 = None self._sunAzimuth2 = None self._sunAzimuth3 = None self._sunAzimuth4 = None self._sunAzimuth1Time = None self._sunAzimuth2Time = None self._sunAzimuth3Time = None self._sunAzimuth4Time = None def resetSunSunriseData(self): """Reset sun control data for mode sunrise/sunset """ self._sunrise = None self._sunset = None self._sunCtrlStart1Trg = 1 self._sunCtrlStart1Shft = 0 self._sunCtrlStart1 = None self._sunCtrlEnd1Trg = 2 self._sunCtrlEnd1Shft = 0 self._sunCtrlEnd1 = None self._sunCtrlStart2Trg = 0 self._sunCtrlStart2Shft = 0 self._sunCtrlStart2 = None self._sunCtrlEnd2Trg = 0 self._sunCtrlEnd2Shft = 0 self._sunCtrlEnd2 = None def resetSunAzimuthata(self): """Reset sun control data for mode Azimuth """ self._sunAzimuthTime = None self._sunAzimuth = None self._sunAzimuth1 = None self._sunAzimuth2 = None self._sunAzimuth3 = None self._sunAzimuth4 = None self._sunAzimuth1Time = None self._sunAzimuth2Time = None self._sunAzimuth3Time = None self._sunAzimuth4Time = None def nextPhoto(self) -> tuple[int, str, dict]: """Return number and name for the next photo of the series Returns: - tuple[int, str]: -- number of next photo -- name of next photo -- series metadata of next photo """ logger.debug("Thread %s: Series.nextPhoto", get_ident()) name = "" serMetaData = self.metaData.copy() if self.curShots is None: self.curShots = 0 if self.curShots < self.nrShots: curShots = self.curShots + 1 name = self.name + "_" + str(curShots).zfill(Series.PHOTODIGITS) else: curShots = self.curShots if self.ended is None: logger.debug("Thread %s: Series.nextPhoto - Finishing series", get_ident()) dt = datetime.now() dt = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) self.ended = dt self.nextStatus("finish") self.persist() #Restore camera controls if CameraCfg().controlsBackup: CameraCfg().controls = copy.deepcopy(CameraCfg().controlsBackup) CameraCfg().controlsBackup = None logger.debug("Thread %s: Series.nextPhoto - Restored controls backup: %s", get_ident(), CameraCfg().controls.__dict__) wait = None if self.isExposureSeries: #For an exposure series wait for the longest exposure time if self.isExpGainFix: wait = 0.2 + self.expTimeStop / 1000000 raspiCamSrv.camera_pi.Camera().applyControlsForLivestream(wait) logger.debug("Thread %s: Series.nextPhoto - returning: %s, %s, %s", get_ident(), curShots, name, serMetaData) return curShots, name, serMetaData def nextTimeOnlyAsStr(self) -> str: """ Returns just the time for the next shot """ t = str(self.nextTime(test=True)) return t[11:] def nextTimeIso(self) -> str: """ Returns the time for the next shot in ISO format """ t = self.nextTime(test=True) return t.isoformat() def calcSunCtrlData(self, dat: str): """Calulate data for sun control for the given date Args: - dat (str): Date in isoformat for which to to calculate sun-control data """ logger.debug("Series.calcSunCtrlData - dat: %s", dat) tim = datetime.fromisoformat(dat) sc = CameraCfg().serverConfig sun = Sun(sc.locLatitude, sc.locLongitude, sc.locElevation, sc.locTzKey) if self.sunCtrlMode == 1: # sunrise/sunset based control self.sunrise, self.sunset = sun.sunrise_sunset(tim) if self.sunCtrlStart1Trg == 1: self.sunCtrlStart1 = self.sunrise + timedelta(minutes=self.sunCtrlStart1Shft) if self.sunCtrlStart1Trg == 2: self.sunCtrlStart1 = self.sunset + timedelta(minutes=self.sunCtrlStart1Shft) if self.sunCtrlEnd1Trg == 1: self.sunCtrlEnd1 = self.sunrise + timedelta(minutes=self.sunCtrlEnd1Shft) if self.sunCtrlEnd1Trg == 2: self.sunCtrlEnd1 = self.sunset + timedelta(minutes=self.sunCtrlEnd1Shft) if self.sunCtrlStart2Trg > 0 and self.sunCtrlEnd2Trg > 0: if self.sunCtrlStart2Trg == 1: self.sunCtrlStart2 = self.sunrise + timedelta(minutes=self.sunCtrlStart2Shft) if self.sunCtrlStart2Trg == 2: self.sunCtrlStart2 = self.sunset + timedelta(minutes=self.sunCtrlStart2Shft) if self.sunCtrlEnd2Trg == 1: self.sunCtrlEnd2 = self.sunrise + timedelta(minutes=self.sunCtrlEnd2Shft) if self.sunCtrlEnd2Trg == 2: self.sunCtrlEnd2 = self.sunset + timedelta(minutes=self.sunCtrlEnd2Shft) else: self.sunCtrlStart2 = None self.sunCtrlEnd2 = None if self.sunCtrlMode == 2: # Sun azimuth based control if self.sunAzimuth1 is not None: times = sun.find_times_for_azimuth(tim, self.sunAzimuth1) if len(times) > 0: self.sunAzimuth1Time = times[0]["time"] else: self.sunAzimuth1Time = None else: self.sunAzimuth1Time = None if self.sunAzimuth2 is not None: times = sun.find_times_for_azimuth(tim, self.sunAzimuth2) if len(times) > 0: self.sunAzimuth2Time = times[0]["time"] else: self.sunAzimuth2Time = None else: self.sunAzimuth2Time = None if self.sunAzimuth3 is not None: times = sun.find_times_for_azimuth(tim, self.sunAzimuth3) if len(times) > 0: self.sunAzimuth3Time = times[0]["time"] else: self.sunAzimuth3Time = None else: self.sunAzimuth3Time = None if self.sunAzimuth4 is not None: times = sun.find_times_for_azimuth(tim, self.sunAzimuth4) if len(times) > 0: self.sunAzimuth4Time = times[0]["time"] else: self.sunAzimuth4Time = None else: self.sunAzimuth4Time = None def nextTimeSunCtrl(self) -> datetime: """Calculate the time for the next photo of a sun-controlled series Returns: datetime: Time for next photo """ logger.debug("Thread %s: Series.nextTimeSunCtrl - Mode: %s", get_ident(), self.sunCtrlMode) # Check whether sunrise/sunset needs to be calculated next = None if self.sunCtrlMode == 1: # sunrise/sunset based control now = datetime.now() dat = now.strftime("%Y-%m-%d") if self.sunrise is None: self.calcSunCtrlData(dat) last = self.sunCtrlEnd1 if self.sunCtrlStart2Trg > 0 and self.sunCtrlEnd2Trg > 0: last = self.sunCtrlEnd2 if now > last: now += timedelta(days=1) dat = now.strftime("%Y-%m-%d") self.calcSunCtrlData(dat) if self.onDialMarks == True: next = self.nextDialMark(self.sunCtrlStart1) else: next = self.sunCtrlStart1 else: if now < self.sunCtrlStart1: if self.onDialMarks == True: next = self.nextDialMark(self.sunCtrlStart1) else: next = self.sunCtrlStart1 else: if self.onDialMarks == True: next = self.nextDialMark(now) else: timedif = now - self.sunCtrlStart1 timedifSec = timedif.total_seconds() nrint = int(timedifSec / self._interval) next = self.sunCtrlStart1 + timedelta(seconds = (nrint + 1)*self.interval) if next > self.sunCtrlEnd1: if self.sunCtrlStart2Trg > 0 and self.sunCtrlEnd2Trg > 0: if now < self.sunCtrlStart2: if self.onDialMarks == True: next = self.nextDialMark(self.sunCtrlStart2) else: next = self.sunCtrlStart2 else: if self.onDialMarks == True: next = self.nextDialMark(now) else: timedif = now - self.sunCtrlStart2 timedifSec = timedif.total_seconds() nrint = int(timedifSec / self._interval) next = self.sunCtrlStart2 + timedelta(seconds = (nrint + 1)*self.interval) if next > self.sunCtrlEnd2: now1 = now + timedelta(days=1) dat = now1.strftime("%Y-%m-%d") self.calcSunCtrlData(dat) if self.onDialMarks == True: next = self.nextDialMark(self.sunCtrlStart1) else: next = self.sunCtrlStart1 else: now1 = now + timedelta(days=1) dat = now1.strftime("%Y-%m-%d") self.calcSunCtrlData(dat) if self.onDialMarks == True: next = self.nextDialMark(self.sunCtrlStart1) else: next = self.sunCtrlStart1 if self.sunCtrlMode == 2: # Sun azimuth based control now = datetime.now() ref = datetime.now() dat = ref.strftime("%Y-%m-%d") logger.debug("Thread %s: Series.nextTimeSunCtrl - Sun azimuths: %s", get_ident(), self.sunAzimuths) if len(self.sunAzimuths) > 0: self.calcSunCtrlData(dat) logger.debug("Thread %s: Series.nextTimeSunCtrl - Sun azimuthTimes: %s, %s, %s, %s", get_ident(), self.sunAzimuth1TimeIso, self.sunAzimuth2TimeIso, self.sunAzimuth3TimeIso, self.sunAzimuth4TimeIso) if len(self.sunAzimuthTimes) > 0: done = False cnt = 0 while not done: timeFound = False i = 0 for t in self.sunAzimuthTimes: if t is not None: timeFound = True if t > now: azimuth = self.sunAzimuths[i] self.metaData["Azimuth"] = azimuth next = t logger.debug("Thread %s: Series.nextTimeSunCtrl - Found next sun azimuth %s at time: %s", get_ident(), azimuth, next) done = True break i += 1 if timeFound == False: done = True if next is None: cnt += 1 if cnt > 3: done = True else: ref += timedelta(days=1) logger.debug("Thread %s: Series.nextTimeSunCtrl - No sun azimuth time found for now. Trying next day: %s", get_ident(), ref) dat = ref.strftime("%Y-%m-%d") self.calcSunCtrlData(dat) logger.debug("Thread %s: Series.nextTimeSunCtrl - Sun azimuthTimes: %s, %s, %s, %s", get_ident(), self.sunAzimuth1TimeIso, self.sunAzimuth2TimeIso, self.sunAzimuth3TimeIso, self.sunAzimuth4TimeIso) if not next: next = datetime.now() logger.debug("Thread %s: Series.nextTimeSunCtrl - returning: %s", get_ident(), next.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]) return next def nextDialMark(self, t:datetime) -> datetime: """ Calculate and return the next dial mark for the given time t: time for which next dial mark is to be calculated Return: updated time """ logger.debug("Thread %s: Series.nextDialMark - t: %s", get_ident(), t) dm = t if ( (self.interval % 60 == 0) or (self.interval % 120 == 0) or (self.interval % 240 == 0) or (self.interval % 300 == 0) or (self.interval % 360 == 0) or (self.interval % 600 == 0) or (self.interval % 720 == 0) or (self.interval % 900 == 0) or (self.interval % 1200 == 0) or (self.interval % 1800 == 0) or (self.interval % 3600 == 0) ): minutes = t.hour * 60 + t.minute period = math.floor(60.0 * minutes / self.interval) nextmin = (period + 1) * self.interval / 60 dm = datetime(t.year,t.month, t.day) + timedelta(minutes=nextmin) elif ( (self.interval % 2 == 0) or (self.interval % 4 == 0) or (self.interval % 5 == 0) or (self.interval % 6 == 0) or (self.interval % 10 == 0) or (self.interval % 12 == 0) or (self.interval % 15 == 0) or (self.interval % 20 == 0) or (self.interval % 30 == 0) ): seconds = t.minute * 60 + t.second period = math.floor(seconds / self.interval) nextsec = (period + 1) * self.interval dm = datetime(t.year,t.month, t.day, t.hour) + timedelta(seconds=nextsec) return dm def nextTime(self, lastTime=None, test=False) -> datetime: """ Calculate and return the time when the next photo must be taken lastTime: time when the last photo has been taken """ logger.debug("Thread %s: Series.nextTime - lastTime: %s", get_ident(), lastTime) next = None curTime = datetime.now() if curTime <= self.end: if self.isSunControlledSeries == True: next = self.nextTimeSunCtrl() else: if curTime < self.start: if self.onDialMarks == True: next = self.nextDialMark(self.start) else: next = self.start else: if self.onDialMarks == True: next = self.nextDialMark(curTime) else: timedif = curTime - self.start timedifSec = timedif.total_seconds() nrint = int(timedifSec / self._interval) next = self.start + timedelta(seconds = (nrint + 1)*self.interval) else: if self.ended is None and test == False: logger.debug("Thread %s: Series.nextTime - Finishing series", get_ident()) dt = datetime.now() dt = datetime(year=dt.year, month=dt.month, day=dt.day, hour=dt.hour, minute=dt.minute) self.ended = dt self.nextStatus("finish") self.persist() #Restore camera controls if CameraCfg().controlsBackup: CameraCfg().controls = copy.deepcopy(CameraCfg().controlsBackup) CameraCfg().controlsBackup = None logger.debug("Thread %s: Series.nextTime - Restored controls backup: %s", get_ident(), CameraCfg().controls.__dict__) wait = None if self.isExposureSeries: #For an exposure series wait for the longest exposure time if self.isExpGainFix: wait = 0.2 + self.expTimeStop / 1000000 raspiCamSrv.camera_pi.Camera().applyControlsForLivestream(wait) if not next: next = datetime.now() logger.debug("Thread %s: Series.nextTime - returning: %s", get_ident(), next.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]) return next def getPreviewList(self): """ return a list with the last n photos of the series """ list = [] if self.curShots: n = self.curShots + 1 cnt = 0 while (cnt < 20 and n >= 0): name = self.name + "_" + str(n).zfill(Series.PHOTODIGITS) + ".jpg" path = self.path + "/" + name if os.path.exists(path): relPath = "photoseries/" + self.name + "/" + name set = {} set["name"] = name set["relPath"] = relPath list.append(set) cnt += 1 n = n - 1 return list def _readLog(self, file: str) -> dict: """ Read the log file and return as dict """ ret = {} with open(file, newline="") as csvFile: reader = csv.DictReader(csvFile, delimiter = ";", quotechar = "'") for row in reader: ret[row["Name"]] = row return ret def _getParamsFromLog(self, log: dict, name: str) -> dict: """ Get parameters for a specific name with float limited to n digits """ ret = {} if name in log: ret = log[name] # Limit number of digits if "AnalogueGain" in ret: ret["AnalogueGain"] = round(float(ret["AnalogueGain"]),4) if "DigitalGain" in ret: ret["DigitalGain"] = round(float(ret["DigitalGain"]),4) if "Lux" in ret: ret["Lux"] = round(float(ret["Lux"]),4) if "LensPosition" in ret: lp = ret["LensPosition"] if len(lp) > 0: if float(ret["LensPosition"]) > 0: ret["FocalDistance"] = round(1.0/float(ret["LensPosition"]), 4) else: ret["FocalDistance"] = 999.999 ret["LensPosition"] = round(float(ret["LensPosition"]),4) else: ret["FocalDistance"] = "0" ret["LensPosition"] = "0" if "ExposureTime" in ret: ret["ExposureTime"] = round(float(ret["ExposureTime"]) / 1000000,4) return ret def getPreviewListHistDetail(self): """ return a list with the last n photos of the series including histogram and details """ log = self._readLog(self.logFile) list = [] if self.curShots: n = self.curShots + 1 cnt = 0 while (cnt < 20 and n >= 0): pureName = self.name + "_" + str(n).zfill(Series.PHOTODIGITS) name = pureName + ".jpg" nameRaw = pureName + ".dng" photoPath = self.path + "/" + name histoPath = self.histogramPath + "/" + name include = False if os.path.exists(photoPath): relPhotoPath = "photoseries/" + self.name + "/" + name include = True else: relPhotoPath = None if os.path.exists(histoPath): relHisroPath = "photoseries/" + self.name + "/" + Series.HISTOGRAMFOLDER + "/" + name include = True else: relHisroPath = None if include: set = {} if self.type == "raw+jpg": set["name"] = nameRaw else: set["name"] = name set["relPhotoPath"] = relPhotoPath set["relHistoPath"] = relHisroPath set["params"] = self._getParamsFromLog(log, pureName) list.append(set) cnt += 1 n = n - 1 return list def logCamCfgCtrlClose(self): """Append camera _cam.json file with closing ] """ if self.camFile: with open(self.camFile, mode='a', encoding='utf-8') as f: f.write("\n]") def logCamCfgCtrl(self, name: str, cfg: dict, ctrl: dict): """Append camera config & controls used for a photo to the _cam.json file name: Name of the photo cfg : camera configuration ctrl: camera controls """ if self.camFile: if not os.path.exists(self.camFile): os.makedirs(self.path, exist_ok=True) Path(self.camFile).touch() new = {} new["name"] = name new["config"] = cfg new["controls"] = ctrl logger.debug("logCamCfgCtrl new: %s", new) newJson = json.dumps(new, default=lambda o: getattr(o, '__dict__', str(o)), indent=4) if self._firstCamEntry: newJson = "[\n" + newJson self._firstCamEntry = False else: newJson = ",\n" + newJson with open(self.camFile, mode='a', encoding='utf-8') as f: f.write(newJson) def logPhoto(self, name: str, ptime: datetime, metadata: dict, seriesMetaData: dict): """Append a log entry for the photo """ if self.started is None: self.started = ptime if self._logHeadlineReq: log = "Name" + ";" log = log + "Time" + ";" log = log + "SensorTimestamp" + ";" log = log + "ExposureTime" + ";" log = log + "AnalogueGain" + ";" log = log + "DigitalGain" + ";" log = log + "Lux" + ";" log = log + "LensPosition" + ";" log = log + "FocusFoM" + ";" log = log + "FrameDuration" + ";" log = log + "SensorTemperature" + ";" log = log + "ColourTemperature" + ";" log = log + "AeLocked" + ";" log = log + "ScalerCrops" + ";" if len(seriesMetaData) > 0: for key in seriesMetaData: log = log + key + ";" f = open(self.logFile, "a") f.write(log + "\n") f.close() self._logHeadlineReq = False log = name + ";" log = log + ptime.isoformat() + ";" if "SensorTimestamp" in metadata: log = log + str(metadata["SensorTimestamp"]) + ";" else: log = log + ";" if "ExposureTime" in metadata: log = log + str(metadata["ExposureTime"]) + ";" else: log = log + ";" if "AnalogueGain" in metadata: log = log + str(metadata["AnalogueGain"]) + ";" else: log = log + ";" if "DigitalGain" in metadata: log = log + str(metadata["DigitalGain"]) + ";" else: log = log + ";" if "Lux" in metadata: log = log + str(metadata["Lux"]) + ";" else: log = log + ";" if "LensPosition" in metadata: log = log + str(metadata["LensPosition"]) + ";" else: log = log + ";" if "FocusFoM" in metadata: log = log + str(metadata["FocusFoM"]) + ";" else: log = log + ";" if "FrameDuration" in metadata: log = log + str(metadata["FrameDuration"]) + ";" else: log = log + ";" if "SensorTemperature" in metadata: log = log + str(metadata["SensorTemperature"]) + ";" else: log = log + ";" if "ColourTemperature" in metadata: log = log + str(metadata["ColourTemperature"]) + ";" else: log = log + ";" if "AeLocked" in metadata: log = log + str(metadata["AeLocked"]) + ";" else: log = log + ";" if "ScalerCrops" in metadata: log = log + str(metadata["ScalerCrops"]) + ";" else: log = log + ";" if len(seriesMetaData) > 0: for val in seriesMetaData.values(): log = log + str(val) + ";" f = open(self.logFile, "a") f.write(log + "\n") f.close() def persist(self): """ Store class dictionary in the config file """ if self.cfgFile: if not os.path.exists(self.cfgFile): os.makedirs(self.path, exist_ok=True) Path(self.cfgFile).touch() f = open(self.cfgFile, "w") #cj = json.loads(json.dumps(self.toJson(), indent=4)) cj = self.toJson() f.write(str(cj)) f.close() def toJson(self): #return json.dumps(self, default=lambda o: o.__dict__) return json.dumps(self, default=lambda o: getattr(o, '__dict__', str(o)), indent=4) @classmethod def checkPhotos(cls, path: str, name: str): """ Analyze photos nnnnnn Return nrPhotos, maxNumber """ nrPhotos = 0 maxNumber = 0 fs = [] try: fs = os.listdir(path) except FileNotFoundError: fs = [] fs.sort() l = len(name) + 1 nl = l + Series.PHOTODIGITS for f in fs: if f.endswith(".jpg"): fn = f[:len(f) - 4] if len(fn) == nl: nums = fn[l:] if nums.isnumeric: nrPhotos += 1 num = int(nums) if num > maxNumber: maxNumber = num return nrPhotos, maxNumber @classmethod def initFromDict(cls, dict:dict): ser = Series() for key, value in dict.items(): if key == "_start" \ or key == "_started" \ or key == "_end" \ or key == "_ended" \ or key == "_sunrise" \ or key == "_sunset" \ or key == "_sunCtrlStart1" \ or key == "_sunCtrlEnd1" \ or key == "_sunCtrlStart2" \ or key == "_sunCtrlEnd2" \ or key == "_sunAzimuthTime" \ or key == "_sunAzimuth1Time" \ or key == "_sunAzimuth2Time" \ or key == "_sunAzimuth3Time" \ or key == "_sunAzimuth4Time": if value is None: setattr(ser, key, value) else: setattr(ser, key, datetime.strptime(value, "%Y-%m-%d %H:%M:%S")) elif key == "_cameraConfig": if value is None: setattr(ser, key, value) else: ccfg = CameraConfig.initFromDict(value) ser.cameraConfig = ccfg elif key == "_cameraControls": if value is None: setattr(ser, key, value) else: cctr = CameraControls.initFromDict(value) ser.cameraControls = cctr else: setattr(ser, key, value) nrPhotos, maxNumber = Series.checkPhotos(ser.path, ser.name) ser.curShots = maxNumber return ser class PhotoSeriesCfg(): _instance = None def __new__(cls): if cls._instance is None: cls._instance = super(PhotoSeriesCfg, cls).__new__(cls) cls._rootPath = None cls._tlSeries = [] cls._curSeries: Series = None return cls._instance @property def rootPath(self) -> str: return self._rootPath @rootPath.setter def rootPath(self, value: str): self._rootPath = value @property def tlSeries(self) -> list: return self._tlSeries @tlSeries.setter def tlSeries(self, value: list): self._tlSeries = value @property def seriesNames(self) -> list: nl = [] for s in self._tlSeries: nl.append(s.name) return nl @property def curSeries(self) -> Series: return self._curSeries @curSeries.setter def curSeries(self, value: Series): self._curSeries = value @property def hasCurSeries(self) -> bool: return self._curSeries is not None def appendSeries(self, s:Series): self._tlSeries.append(s) def nameExists(self, name: str) -> bool: ne = False for s in self._tlSeries: if s.name == name: ne = True break return ne def _initSeriesFromCfg(self, spath: str, name: str) -> Series: """ Initialize a photoseries series from folder information Returns True/False if series is OK/NOK """ logger.debug("_initSeriesFromFolder - path: %s name: %s", spath, name) ser = None cfgFile = spath + "/" + name + "_cfg.json" if os.path.exists(cfgFile): with open(cfgFile) as f: try: sdict = json.load(f) ser = Series.initFromDict(sdict) except Exception: ser = Series() ser.name = name ser.path = spath ser.cfgFile = cfgFile ser.logFile = spath + "/" + ser.logFileName ser.camFile = spath + "/" + ser.camFileName return ser def initFromTlFolder(self): """ Initialize photoseries from file system information """ try: tls = os.listdir(self.rootPath) except FileNotFoundError: tls = [] tls.sort() logger.debug("initFromTlFolder - Found TL series: %s", tls) curSer = None lastSer = None for tl in tls: spath = self.rootPath + "/" + tl if os.path.isdir(spath): ser = self._initSeriesFromCfg(spath, tl) if ser: self.tlSeries.append(ser) lastSer = ser if ser.status == "ACTIVE": curSer = ser if curSer: self.curSeries = curSer else: self.curSeries = lastSer logger.debug("initFromTlFolder - # series: %s", len(self.tlSeries)) def removeCurrentSeries(self): """ Remove the current series and set current series to last one in list """ sp = self.curSeries.path try: if os.path.exists(sp): if os.path.isdir(sp): shutil.rmtree(sp) except Exception as e: logger.error("Failed to delete folder %s. Reason: %s", sp, e) self.tlSeries.remove(self.curSeries) if len(self.tlSeries) > 0: self.curSeries = self.tlSeries[0] else: self.curSeries = None ================================================ FILE: raspiCamSrv/schema.sql ================================================ DROP TABLE IF EXISTS user; DROP TABLE IF EXISTS config; DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS eventactions; CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, issuperuser INTEGER DEFAULT 0 NOT NULL, isinitial INTEGER DEFAULT 1 NOT NULL ); CREATE TABLE IF NOT EXISTS config ( key TEXT NOT NULL, type TEXT NOT NULL, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, date TEXT NOT NULL, minute TEXT NOT NULL, time TEXT NOT NULL, type TEXT NOT NULL, trigger TEXT NOT NULL, triggertype TEXT NOT NULL, triggerparam TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS eventactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, timestamp TEXT NOT NULL, actiontype TEXT NOT NULL, date TEXT NOT NULL, time TEXT NOT NULL, actionduration INTEGER, filename TEXT NOT NULL, fullpath TEXT NOT NULL, FOREIGN KEY(event) REFERENCES events(timestamp) ); CREATE INDEX IF NOT EXISTS events_date_idx ON events( date, minute ); CREATE INDEX IF NOT EXISTS eventactions_type_idx ON eventactions( event, actiontype ); ================================================ FILE: raspiCamSrv/settings.py ================================================ from flask import Blueprint, Response, flash, g, render_template, request, current_app from werkzeug.exceptions import abort from raspiCamSrv.camCfg import CameraCfg, CameraControls, CameraProperties, CameraConfig, ServerConfig, TriggerConfig, TuningConfig, vButton, ActionButton, AiConfig, LiveButton from raspiCamSrv.camCfg import GPIODevice from raspiCamSrv.camera_pi import Camera, CameraEvent from raspiCamSrv.photoseriesCfg import PhotoSeriesCfg from raspiCamSrv.motionDetector import MotionDetector from raspiCamSrv.triggerHandler import TriggerHandler from raspiCamSrv.version import version from raspiCamSrv.db import get_db from gpiozero import Button, RotaryEncoder, MotionSensor, DistanceSensor, LightSensor, LineSensor, DigitalInputDevice from gpiozero import LED, PWMLED, RGBLED, Buzzer, TonalBuzzer, Servo, AngularServo, Motor, DigitalOutputDevice, OutputDevice from raspiCamSrv.gpioDevices import StepperMotor, ServoPWM import os import shutil import ast import time from pathlib import Path import subprocess import json import copy from raspiCamSrv.auth import login_required import logging # Try to import flask_jwt_extended to avoid errors when upgrading to V2.11 from earlier versions try: from flask_jwt_extended import create_access_token except ImportError: pass bp = Blueprint("settings", __name__) logger = logging.getLogger(__name__) @bp.route("/settings") @login_required def main(): g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/serverconfig", methods=("GET", "POST")) @login_required def serverconfig(): logger.debug("serverconfig") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsparams" if request.method == "POST": msg = None restartLiveStream = False if sc.isTriggerRecording: msg = "Please go to 'Trigger' and stop the active process before changing the configuration" if sc.isVideoRecording == True: msg = "Please stop video recording before changing the tuning configuration" if sc.isPhotoSeriesRecording: msg = "Please go to 'Photo Series' and stop the active process before changing the tuning configuration" if sc.noCamera == False: activeCam = int(request.form["activecamera"]) if not sc.secondCamera is None: if activeCam == sc.secondCamera: msg = "Active camera must be different from second camera. Use 'Switch Cameras in Cam/Multi-Cam' to swap the cameras." if not msg: if sc.noCamera == False: photoType = request.form["phototype"] sc.photoType = photoType rawPhotoType = request.form["rawphototype"] sc.rawPhotoType = rawPhotoType videoType = request.form["videotype"] sc.videoType = videoType recordAudio = not request.form.get("recordaudio") is None sc.recordAudio = recordAudio audioSync = request.form["audiosync"] sc.audioSync = audioSync useStereo = not request.form.get("usestereo") is None sc.useStereo = useStereo useCameraAi = not request.form.get("usecameraai") is None if useCameraAi == False: ai = cfg.aiConfig if ai.enable == True: restartLiveStream = True ai.enable = False for key, scfg in cfg.streamingCfg.items(): if "aiconfig" in scfg: ai = scfg["aiconfig"] ai.enable = False sc.useCameraAi = useCameraAi useHist = not request.form.get("showhistograms") is None if not useHist: sc.displayContent = "meta" sc.useHistograms = useHist sc.requireAuthForStreaming = not request.form.get("requireAuthForStreaming") is None # If active camera has changed reset stream size to force adaptation of sensor mode if activeCam != sc.activeCamera: sc.activeCamera = activeCam cfg.liveViewConfig.stream_size = None cfg.photoConfig.stream_size = None cfg.rawConfig.stream_size = None cfg.videoConfig.stream_size = None for cm in cs: if activeCam == cm.num: sc.activeCameraInfo = "Camera " + str(cm.num) + " (" + cm.model + ")" sc.activeCameraModel = cm.model sc.activeCameraIsUsb = cm.isUsb sc.activeCameraUsbDev = cm.usbDev sc.activeCameraHasAi = cm.hasAi break strCfg = cfg.streamingCfg newCamStr = str(activeCam) if newCamStr in strCfg: ncfg = strCfg[newCamStr] if "tuningconfig" in ncfg: cfg.tuningConfig = ncfg["tuningconfig"] else: cfg.tuningConfig = TuningConfig() if "aiconfig" in ncfg: cfg.aiConfig = copy.deepcopy(ncfg["aiconfig"]) else: cfg.aiConfig = AiConfig() else: cfg.tuningConfig = TuningConfig() cfg.aiConfig = AiConfig() Camera.switchCamera() msg = "Camera switched to " + sc.activeCameraInfo logger.debug("serverconfig - active camera set to %s", sc.activeCamera) useUsbCameras = not request.form.get("useusbcameras") is None reloadCamInfoNeeded = False if useUsbCameras != sc.useUsbCameras: logger.debug("serverconfig - useUsbCameras changed to %s", useUsbCameras) if len(sc.piCameras) == 0 and sc.useUsbCameras == True: if sc.isLiveStream == True \ or sc.isLiveStream2 == True \ or sc.isVideoRecording == True \ or sc.isPhotoSeriesRecording == True \ or sc.isTriggerRecording == True \ or sc.isEventhandling == True: msg = "Please stop all active camera processes before changing the USB camera configuration" if not msg: if len(sc.piCameras) == 0: reloadCamInfoNeeded = True else: if sc.activeCameraIsUsb == True: reloadCamInfoNeeded = True if not sc.secondCamera is None: if sc.secondCameraIsUsb == True: reloadCamInfoNeeded = True sc.useUsbCameras = useUsbCameras cfg.setSupportedCameras() useAPI = not request.form.get("useapi") is None sc.useAPI = useAPI sc.locLatitude = float(request.form["loclatitude"]) sc.locLongitude = float(request.form["loclongitude"]) sc.locElevation = float(request.form["locelevation"]) sc.locTzKey = request.form["loctzkey"] sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/General Parameters changed") if reloadCamInfoNeeded: reloadCameraSystem() else: if sc.isLiveStream == True and restartLiveStream: Camera().restartLiveStream() if sc.isLiveStream2 == True and restartLiveStream: if sc.secondCameraHasAi == True: Camera().restartLiveStream2() if msg: flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def reloadCameraSystem(): """Reload the camera system in case of hot plug-in/-out """ logger.debug("reloadCameraSystem") cfg = CameraCfg() sc = cfg.serverConfig Camera._instance = None cfg.cameras = [] cfg.sensorModes = [] cfg.rawFormats = [] cfg.cameraProperties = CameraProperties() sc.noCamera = False sc.supportedCameras = [] sc.usbCamAvailable = False sc.piCameras = [] sc.hasMicrophone = False sc.defaultMic = "" sc.isMicMuted = False sc.recordAudio = False cam = Camera() cfg.setSupportedCameras() cfg.setPiCameras() logger.debug("reloadCameraSystem - done") @bp.route("/reloadCameras", methods=("GET", "POST")) @login_required def reloadCameras(): logger.debug("reloadCameras") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": if sc.isVideoRecording: Camera().stopVideoRecording() if sc.isPhotoSeriesRecording == True: tl = PhotoSeriesCfg() sr = tl.curSeries sr.nextStatus("pause") sr.persist() Camera().stopPhotoSeries() logger.debug("In resetServer - photo series stopped") if sc.isTriggerRecording == True: MotionDetector().stopMotionDetection() sc.isTriggerRecording = False logger.debug("In resetServer - Motion detection stopped") if sc.isEventhandling: TriggerHandler().stop() sc.isEventhandling = False if sc.isLiveStream == True: Camera().stopLiveStream() logger.debug("In resetServer - Live stream stopped") if sc.isLiveStream2 == True: Camera().stopLiveStream2() logger.debug("In resetServer - Live stream 2 stopped") logger.debug("Stopping camera system") time.sleep(3) Camera().stopCameraSystem() Camera.liveViewDeactivated = False Camera.thread = None Camera.thread2 = None Camera.videoThread = None Camera.photoSeriesThread = None reloadCameraSystem() sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Camera system reloaded") los = getLoadConfigOnStart(cfgPath) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/resetServer", methods=("GET", "POST")) @login_required def resetServer(): logger.debug("resetServer") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": if sc.isVideoRecording: Camera().stopVideoRecording() if sc.isPhotoSeriesRecording == True: tl = PhotoSeriesCfg() sr = tl.curSeries sr.nextStatus("pause") sr.persist() Camera().stopPhotoSeries() logger.debug("In resetServer - photo series stopped") if sc.isTriggerRecording == True: MotionDetector().stopMotionDetection() sc.isTriggerRecording = False logger.debug("In resetServer - Motion detection stopped") if sc.isEventhandling: TriggerHandler().stop() sc.isEventhandling = False if sc.isLiveStream == True: Camera().stopLiveStream() logger.debug("In resetServer - Live stream stopped") if sc.isLiveStream2 == True: Camera().stopLiveStream2() logger.debug("In resetServer - Live stream 2 stopped") logger.debug("Stopping camera system") time.sleep(3) Camera().stopCameraSystem() Camera.liveViewDeactivated = False Camera.thread = None Camera.thread2 = None Camera.videoThread = None Camera.photoSeriesThread = None logger.debug("Resetting server configuration") setLoadConfigOnStart(cfgPath, False) photoRoot = sc.photoRoot backupPath = sc.cfgBackupPath prgOutputPath = sc.prgOutputPath database = sc.database actionPath = tc.actionPath cfg = CameraCfg() cfg.cameras = [] cfg.sensorModes = [] cfg.rawFormats = [] cfg.resetActiveCameraSettings() cfg._cameraConfigs = [] cfg.triggerConfig = TriggerConfig() cfg.serverConfig = ServerConfig() sc = cfg.serverConfig tc = cfg.triggerConfig sc.photoRoot = photoRoot sc.cfgBackupPath = backupPath sc.prgOutputPath = prgOutputPath sc.database = database tc.actionPath = actionPath cfg.streamingCfg = {} sc.isVideoRecording = False sc.isAudioRecording = False sc.isTriggerRecording = False sc.isPhotoSeriesRecording = False sc.isLiveStream = False sc.isLiveStream2 = False sc.checkMicrophone() sc.checkEnvironment() if sc.supportsExtMotionDetection == False: cfg.triggerConfig.motionDetectAlgos = ["Mean Square Diff",] sc.curMenu = "settings" Camera.cam = None Camera.cam2 = None Camera.camNum = -1 Camera.camNum2 = -1 Camera.ctrl = None Camera.ctrl2 = None Camera.videoOutput = None Camera.prgVideoOutput = None Camera.photoSeries = None Camera.thread = None Camera.thread2 = None Camera.liveViewDeactivated = False Camera.videoThread = None Camera.photoSeriesThread = None Camera.frame = None Camera.frame2 = None Camera.last_access = 0 Camera.last_access2 = 0 Camera.stopRequested = False Camera.stopRequested2 = False Camera.stopVideoRequested = False Camera.videoDuration = 0 Camera.stopPhotoSeriesRequested = False Camera.event = CameraEvent() Camera.event2 = None Camera._instance = None msg = "Server configuration has been reset to default values" flash(msg) sc.unsavedChanges = False sc.clearChangeLog() los = getLoadConfigOnStart(cfgPath) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/configBackup", methods=("GET", "POST")) @login_required def configBackup(): logger.debug("configBackup") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": msg = "" backupRoot = sc.cfgBackupPath logger.debug("configBackup - backupRoot=%s", backupRoot) if not os.path.exists(backupRoot): os.makedirs(backupRoot, exist_ok=True) if request.form["configbackupname"]: backupName = request.form["configbackupname"] if backupName.strip() == "": msg = "Please enter a valid backup name" else: msg = "Please enter a valid backup name" if msg == "": backupPath = backupRoot + "/" + backupName logger.debug("configBackup - backupPath=%s", backupPath) if os.path.exists(backupPath): msg = "Backup name already exists. Please choose a different name." if msg == "": try: os.makedirs(backupPath, exist_ok=True) # Backup calib_data stc = cfg.stereoCfg src = sc.photoRoot + "/" + stc.calibDataSubPath dst = backupPath + "/static/" + stc.calibDataSubPath copyDir(src, dst) #Backup calib_photos src = sc.photoRoot + "/" + stc.calibPhotosSubPath dst = backupPath + "/static/" + stc.calibPhotosSubPath copyDir(src, dst) #Backup config src = sc.cfgPath dst = backupPath + "/static/" + "config" copyDir(src, dst) #Backup events tc = cfg.triggerConfig src = tc.actionPath dst = backupPath + "/static/" + "events" copyDir(src, dst) #Backup photos src = sc.photoRoot + "/photos" dst = backupPath + "/static/" + "photos" copyDir(src, dst) #Backup photo series ps = PhotoSeriesCfg() src = ps.rootPath dst = backupPath + "/static/" + "photoseries" copyDir(src, dst) # Backup database src = sc.database dst = backupPath + "/instance/" + "database" copyDir(src, dst) except Exception as e: msg = f"Error creating configuration backup: {e}" if msg == "": msg = "Configuration backup created under " + backupPath flash(msg) los = getLoadConfigOnStart(cfgPath) backups = getBackupsList() return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def copyDir(src: str, dst: str): """Recursively copy a directory from src to dst. Args: src (str): Source directory path. dst (str): Destination directory path. """ logger.debug("copyDir - src: %s, dst: %s", src, dst) if not os.path.exists(src): logger.debug("copyDir - Source not found: %s", src) return if not os.path.exists(dst): os.makedirs(dst, exist_ok=True) logger.debug("copyDir - Destination directory created: %s", dst) if os.path.isdir(src) == True: for item in os.listdir(src): s = os.path.join(src, item) d = os.path.join(dst, item) if os.path.isdir(s): copyDir(s, d) else: shutil.copy2(s, d) if os.path.isfile(src) == True: shutil.copy2(src, dst) def restoreDir(src: str, dst: str): """Recursively restore a directory from src to dst. Args: src (str): Source directory path. dst (str): Destination directory path. """ logger.debug("restoreDir - src: %s, dst: %s", src, dst) if os.path.exists(src): if os.path.exists(dst): shutil.rmtree(dst) copyDir(src, dst) else: if os.path.exists(dst): shutil.rmtree(dst) def getBackupsList() -> list: """Get the list of available backups. Returns: list: List of backup names. """ logger.debug("getBackupsList") res = [] cfg = CameraCfg() sc = cfg.serverConfig backupRoot = sc.cfgBackupPath if os.path.exists(backupRoot): for entry in os.listdir(backupRoot): backupPath = backupRoot + "/" + entry if os.path.isdir(backupPath): res.append(entry) logger.debug("getBackupsList - found %s backups", len(res)) return res @bp.route("/configRestore", methods=("GET", "POST")) @login_required def configRestore(): logger.debug("configRestore") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": msg = "" if request.form["configrestorename"]: backupName = request.form["configrestorename"] if backupName.strip() == "": msg = "Please select a valid backup name" else: msg = "Please select a valid backup name" if msg == "": backupRoot = sc.cfgBackupPath backupPath = backupRoot + "/" + backupName logger.debug("configBackup - backupPath=%s", backupPath) try: os.makedirs(backupPath, exist_ok=True) # Restore calib_data stc = cfg.stereoCfg dst = sc.photoRoot + "/" + stc.calibDataSubPath src = backupPath + "/static/" + stc.calibDataSubPath restoreDir(src, dst) #Restore calib_photos dst = sc.photoRoot + "/" + stc.calibPhotosSubPath src = backupPath + "/static/" + stc.calibPhotosSubPath restoreDir(src, dst) #Restore config dst = sc.cfgPath src = backupPath + "/static/" + "config" restoreDir(src, dst) #Restore events tc = cfg.triggerConfig dst = tc.actionPath src = backupPath + "/static/" + "events" restoreDir(src, dst) #Restore photos dst = sc.photoRoot + "/photos" src = backupPath + "/static/" + "photos" restoreDir(src, dst) #Restore photo series ps = PhotoSeriesCfg() dst = ps.rootPath src = backupPath + "/static/" + "photoseries" restoreDir(src, dst) # Restore database dst = sc.database src = backupPath + "/instance/" + "database" + "/raspiCamSrv.sqlite" shutil.copy2(src, dst) except Exception as e: msg = f"Error restoring backup {backupName}: {e}" if msg == "": msg = "Backup restored from " + backupPath flash(msg) los = getLoadConfigOnStart(cfgPath) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/configRemove", methods=("GET", "POST")) @login_required def configRemove(): logger.debug("configRemove") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": msg = "" if request.form["configremovename"]: backupName = request.form["configremovename"] if backupName.strip() == "": msg = "Please select a valid backup name" else: msg = "Please select a valid backup name" if msg == "": backupRoot = sc.cfgBackupPath backupPath = backupRoot + "/" + backupName try: shutil.rmtree(backupPath) except Exception as e: msg = f"Error removing backup {backupName}: {e}" if msg == "": msg = f"Backup {backupName} was removed." flash(msg) los = getLoadConfigOnStart(cfgPath) backups = getBackupsList() return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/serverRestart", methods=("GET", "POST")) @login_required def serverRestart(): logger.debug("serverRestart") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": msg = "" startup_source = sc.detect_startup_source() logger.debug("Startup source detected: %s", startup_source) try: if startup_source == 1: runresult = subprocess.run( ["sudo", "systemctl", "restart", "raspiCamSrv.service"], capture_output=True, text=True ) elif startup_source == 2: runresult = subprocess.run( ["systemctl", "--user", "restart", "raspiCamSrv.service"], capture_output=True, text=True ) elif startup_source == 3: msg = "Please restart the server from the command line." else: msg = "Unable to detect the server startup source. Please restart the server manually." except CalledProcessError as e: logger.error("serverRestart - CalledProcessError: %s", e) msg = "Error restarting server: " + str(e) except Exception as e: logger.error("serverRestart - Exception: %s", e) msg = "Error restarting server: " + str(e) if msg != "": flash(msg) los = getLoadConfigOnStart(cfgPath) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/remove_users", methods=("GET", "POST")) @login_required def remove_users(): logger.debug("In remove_users") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsusers" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() if request.method == "POST": cnt = 0 msg = None if not msg: for user in g.users: if request.form.get("sel_" + str(user["id"])) is not None: if user["id"] == g.user["id"]: msg = "The active user cannot be removed" break else: cnt += 1 if not msg: logger.debug("Request to remove %s users", cnt) if cnt > 0: db = get_db() if cnt < len(g.users): while cnt > 0: logger.debug("cnt: %s", cnt) userDel = None for user in g.users: logger.debug("Trying user %s %s", user["id"], user["username"]) if request.form.get("sel_" + str(user["id"])) is not None: userDel =user["id"] logger.debug("User selected") break else: logger.debug("User not selected") if userDel: logger.debug("Removing user with id %s", userDel) db.execute("DELETE FROM user WHERE id = ?", (userDel,)).fetchone db.commit() g.nrUsers = db.execute("SELECT count(*) FROM user").fetchone()[0] logger.debug("Found %s users", g.nrUsers) g.users = db.execute("SELECT * FROM user").fetchall() for user in g.users: logger.debug("Found user: ID: %s, UserName: %s", user["id"], user["username"]) cnt -= 1 else: msg="At least one user must remain" flash(msg) else: msg="No users were selected" flash(msg) else: flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/register_user", methods=("GET", "POST")) @login_required def register_user(): logger.debug("In register_user") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsusers" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() if request.method == "POST": return render_template("auth/register.html", sc=sc, cp=cp) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/store_config", methods=("GET", "POST")) @login_required def store_config(): logger.debug("In store_config") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": cfgPath = current_app.static_folder + "/config" # Initialize the Photo viewer list sc = cfg.serverConfig sc.pvList = [] sc.updateStreamingClients() cfg.persist(cfgPath) msg = "Configuration stored under " + cfgPath sc.unsavedChanges = False sc.clearChangeLog() flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/load_config", methods=("GET", "POST")) @login_required def load_config(): logger.debug("In load_config") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsconfig" if request.method == "POST": msg = "" # Stop background threads if sc.isVideoRecording: msg = "Please stop video recording before loading the configuration" if msg == "": if sc.isPhotoSeriesRecording == True: tl = PhotoSeriesCfg() sr = tl.curSeries #sr.nextStatus("pause") #sr.persist() Camera().stopPhotoSeries() logger.debug("In load_config - photo series stopped") restartPhotoSeries = True else: restartPhotoSeries = False if sc.isTriggerRecording == True: MotionDetector().stopMotionDetection() sc.isTriggerRecording = False logger.debug("In load_config - Motion detection stopped") restartTriggerRecording = True else: restartTriggerRecording = False if sc.isEventhandling: TriggerHandler().stop() sc.isEventhandling = False logger.debug("In load_config - Eventhandling stopped") restartEventhandling = True else: restartEventhandling = False if sc.isLiveStream == True: Camera().stopLiveStream() logger.debug("In load_config - Live stream stopped") restartLiveStream = True else: restartLiveStream = False if sc.isLiveStream2 == True: Camera().stopLiveStream2() logger.debug("In load_config - Live stream 2 stopped") restartLiveStream2 = True else: restartLiveStream2 = False # Load stored configuration cfg.loadConfig(cfgPath) msg = "Configuration loaded from " + cfgPath cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) # Restart threads if restartLiveStream == True: Camera().restartLiveStream() sc.isLiveStream = True logger.debug("In load_config - Live stream started") if restartLiveStream2 == True: Camera().restartLiveStream2() sc.isLiveStream2 = True logger.debug("In load_config - Live stream 2 started") if restartPhotoSeries == True: Camera().startPhotoSeries(sr) sc.isPhotoSeriesRecording = True logger.debug("In load_config - photo series started") if restartTriggerRecording == True: MotionDetector().startMotionDetection() sc.isTriggerRecording = True logger.debug("In load_config - Motion detection started") if restartEventhandling == True: TriggerHandler().start() sc.isEventhandling = True logger.debug("In load_config - Eventhandling started") sc.unsavedChanges = False sc.clearChangeLog() if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def getLoadConfigOnStart(cfgPath: str) -> bool: logger.debug("getLoadConfigOnStart") res = False if cfgPath: if os.path.exists(cfgPath): fp = cfgPath + "/_loadConfigOnStart.txt" if os.path.exists(fp): res = True logger.debug("getLoadConfigOnStart: %s", res) return res def setLoadConfigOnStart(cfgPath: str, value: bool): logger.debug("setLoadConfigOnStart - value: %s", value) if cfgPath: if not os.path.exists(cfgPath): os.makedirs(cfgPath, exist_ok=True) fp = cfgPath + "/_loadConfigOnStart.txt" if value == True: Path(fp).touch() else: if os.path.exists(fp): os.remove(fp) @bp.route("/loadConfigOnStart", methods=("GET", "POST")) @login_required def loadConfigOnStart(): logger.debug("In loadConfigOnStart") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsconfig" if request.method == "POST": cb = not request.form.get("loadconfigonstartcb") is None setLoadConfigOnStart(cfgPath, cb) los = getLoadConfigOnStart(cfgPath) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/api_config", methods=("GET", "POST")) @login_required def api_config(): logger.debug("In api_config") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsapi" if request.method == "POST": msg = "" sc = cfg.serverConfig jwtAccessTokenExpirationMin = sc.jwtAccessTokenExpirationMin jwtRefreshTokenExpirationDays = sc.jwtRefreshTokenExpirationDays jwtKeyStore = request.form["jwtkeystore"] logger.debug("api_config - jwtKeyStore=%s", jwtKeyStore) if jwtKeyStore != "": if os.path.exists(jwtKeyStore): if os.path.isfile(jwtKeyStore): with open(jwtKeyStore) as f: try: secrets = json.load(f) sc.jwtKeyStore = jwtKeyStore logger.debug("api_config - jwtKeyStore successfully accessed") except Exception as e: msg = f"Error when accessing JWT Secret Key File: {e}" else: sc.jwtKeyStore = jwtKeyStore else: sc.jwtKeyStore = jwtKeyStore else: sc.jwtKeyStore = "" sc.jwtAccessTokenExpirationMin = int(request.form["jwtaccesstokenexpirationmin"]) sc.jwtRefreshTokenExpirationDays = int(request.form["jwtrefreshtokenexpirationdays"]) if msg == "": (secretKey, err, msg) = sc.checkJwtSettings() logger.debug("api_config - secrKey = %s, err = %s, msg = %s", secretKey, err, msg) if not err is None: msg = "ERROR: " + err if msg != "": flash(msg) if sc.API_active == True: if sc.jwtAuthenticationActive == True: if jwtAccessTokenExpirationMin != sc.jwtAccessTokenExpirationMin \ or jwtRefreshTokenExpirationDays != sc.jwtRefreshTokenExpirationDays: sc.jwtAuthenticationActive = False sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/API Settings changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/generate_token", methods=("GET", "POST")) @login_required def generate_token(): logger.debug("In generate_token") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsapi" if request.method == "POST": access_token = create_access_token(identity=g.user['username']) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, access_token=access_token) @bp.route('/vbutton_dimensions', methods=("GET", "POST")) @login_required def vbutton_dimensions(): logger.debug("In vbutton_dimensions") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsvbuttons" if request.method == "POST": msg = "" if request.form["vbuttonsrows"]: vButtonsRows = int(request.form["vbuttonsrows"]) else: msg = "Please enter a valid number of rows" if request.form["vbuttonscols"]: vButtonsCols = int(request.form["vbuttonscols"]) else: msg = "Please enter a valid number of columns" if msg == "": if vButtonsRows == 0 \ or vButtonsCols == 0: sc.vButtonsCols = vButtonsCols sc.vButtonsRows = vButtonsRows sc.vButtons = [] else: vButtons = [] for r in range(0, vButtonsRows): row = [] for c in range(0, vButtonsCols): if r < sc.vButtonsRows and c < sc.vButtonsCols: btn = sc.vButtons[r][c] else: btn = vButton() btn.row = r btn.col = c row.append(btn) vButtons.append(row) sc.vButtonsCols = vButtonsCols sc.vButtonsRows = vButtonsRows sc.vButtons = vButtons sc.vButtonHasCommandLine = not request.form.get("vbuttonhascommandline") is None if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Versatile Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/vbutton_settings', methods=("GET", "POST")) @login_required def vbutton_settings(): logger.debug("In vbutton_settings") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsvbuttons" if request.method == "POST": msg = "" for r in range(0, sc.vButtonsRows): for c in range(0, sc.vButtonsCols): btn = sc.vButtons[r][c] visibleId = f"vbtn_{btn.row}{ btn.col }_visible" btn.isVisible = not request.form.get(visibleId) is None buttonTextKey = f"vbtn_{btn.row}{btn.col}_buttontext" btn.buttonText = request.form[buttonTextKey] buttonExecKey = f"vbtn_{btn.row}{btn.col}_buttonexec" btn.buttonExec = request.form[buttonExecKey] buttonShapeKey = f"vbtn_{btn.row}{btn.col}_shape" btn.buttonShape = request.form[buttonShapeKey] buttonColorKey = f"vbtn_{btn.row}{btn.col}_color" btn.buttonColor = request.form[buttonColorKey] confirmId = f"vbtn_{btn.row}{ btn.col }_confirm" btn.needsConfirm = not request.form.get(confirmId) is None sc.vButtons[r][c] = btn if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Versatile Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/abutton_dimensions', methods=("GET", "POST")) @login_required def abutton_dimensions(): logger.debug("In vbutton_dimensions") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsabuttons" if request.method == "POST": msg = "" if request.form["abuttonsrows"]: aButtonsRows = int(request.form["abuttonsrows"]) else: msg = "Please enter a valid number of rows" if request.form["abuttonscols"]: aButtonsCols = int(request.form["abuttonscols"]) else: msg = "Please enter a valid number of columns" if msg == "": if aButtonsRows == 0 \ or aButtonsCols == 0: sc.aButtonsCols = aButtonsCols sc.aButtonsRows = aButtonsRows sc.aButtons = [] else: aButtons = [] for r in range(0, aButtonsRows): row = [] for c in range(0, aButtonsCols): if r < sc.aButtonsRows and c < sc.aButtonsCols: btn = sc.aButtons[r][c] else: btn = ActionButton() btn.row = r btn.col = c row.append(btn) aButtons.append(row) sc.aButtonsCols = aButtonsCols sc.aButtonsRows = aButtonsRows sc.aButtons = aButtons if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Action Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/abutton_settings', methods=("GET", "POST")) @login_required def abutton_settings(): logger.debug("In abutton_settings") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsabuttons" if request.method == "POST": msg = "" for r in range(0, sc.aButtonsRows): for c in range(0, sc.aButtonsCols): btn = sc.aButtons[r][c] visibleId = f"abtn_{btn.row}{ btn.col }_visible" btn.isVisible = not request.form.get(visibleId) is None buttonTextKey = f"abtn_{btn.row}{btn.col}_buttontext" btn.buttonText = request.form[buttonTextKey] buttonAction = f"abtn_{btn.row}{btn.col}_action" btn.buttonAction = request.form[buttonAction] buttonShapeKey = f"abtn_{btn.row}{btn.col}_shape" btn.buttonShape = request.form[buttonShapeKey] buttonColorKey = f"abtn_{btn.row}{btn.col}_color" btn.buttonColor = request.form[buttonColorKey] confirmId = f"abtn_{btn.row}{ btn.col }_confirm" btn.needsConfirm = not request.form.get(confirmId) is None sc.aButtons[r][c] = btn if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Action Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/lbutton_dimensions', methods=("GET", "POST")) @login_required def lbutton_dimensions(): logger.debug("In vbutton_dimensions") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingslbuttons" if request.method == "POST": msg = "" if request.form["lbuttonsrows"]: lButtonsRows = int(request.form["lbuttonsrows"]) else: msg = "Please enter a valid number of rows" if request.form["lbuttonscols"]: lButtonsCols = int(request.form["lbuttonscols"]) else: msg = "Please enter a valid number of columns" if msg == "": if lButtonsRows == 0 \ or lButtonsCols == 0: sc.lButtonsCols = lButtonsCols sc.lButtonsRows = lButtonsRows sc.lButtons = [] else: lButtons = [] for r in range(0, lButtonsRows): row = [] for c in range(0, lButtonsCols): if r < sc.lButtonsRows and c < sc.lButtonsCols: btn = sc.lButtons[r][c] else: btn = LiveButton() btn.row = r btn.col = c row.append(btn) lButtons.append(row) sc.lButtonsCols = lButtonsCols sc.lButtonsRows = lButtonsRows sc.lButtons = lButtons if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Live Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/lbutton_settings', methods=("GET", "POST")) @login_required def lbutton_settings(): logger.debug("In lbutton_settings") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingslbuttons" if request.method == "POST": msg = "" for r in range(0, sc.lButtonsRows): for c in range(0, sc.lButtonsCols): btn = sc.lButtons[r][c] visibleId = f"lbtn_{btn.row}{ btn.col }_visible" btn.isVisible = not request.form.get(visibleId) is None buttonTextKey = f"lbtn_{btn.row}{btn.col}_buttontext" btn.buttonText = request.form[buttonTextKey] buttonExecKey = f"lbtn_{btn.row}{btn.col}_buttonexec" btn.buttonExec = request.form[buttonExecKey] buttonActionKey = f"lbtn_{btn.row}{btn.col}_action" buttonAction = request.form[buttonActionKey] if buttonAction != "" \ and btn.buttonExec != "": buttonAction = "" msg = f"Live Button {btn.row + 1}/{btn.col + 1}: Please enter either a command or an action, not both." btn.buttonAction = buttonAction buttonShapeKey = f"lbtn_{btn.row}{btn.col}_shape" btn.buttonShape = request.form[buttonShapeKey] buttonColorKey = f"lbtn_{btn.row}{btn.col}_color" btn.buttonColor = request.form[buttonColorKey] confirmId = f"lbtn_{btn.row}{ btn.col }_confirm" btn.needsConfirm = not request.form.get(confirmId) is None btn.isAction = not btn.buttonAction == "" sc.lButtons[r][c] = btn if msg != "": flash(msg) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Live Buttons changed") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/new_device', methods=("GET", "POST")) @login_required def new_device(): logger.debug("In new_device") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = "" deviceId = request.form["newdeviceid"] deviceTypeId = request.form["newdevicetype"] if deviceId.strip() == "": msg = "Device ID cannot be empty" else: for dev in sc.gpioDevices: if dev.id == deviceId: msg = f"Device IDs must be unique! A device with ID {deviceId} exists already." break if msg == "": device = GPIODevice() device.id = deviceId device.type = deviceTypeId for dt in sc.deviceTypes: if dt["type"] == deviceTypeId: sc.curDeviceType = dt device.usage = dt["usage"] device.docUrl = dt["docUrl"] device.isOk = False params = {} for key, value in dt["params"].items(): params[key] = value["value"] device.params = params if "calibration" in dt: device.needsCalibration = True sc.gpioDevices.append(device) sc.curDeviceId = deviceId sc.curDevice = device if msg != "": flash(msg) if not deviceId is None: sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - new device added: {deviceId}") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/select_device', methods=("GET", "POST")) @login_required def select_device(): logger.debug("In select_device") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = "" deviceId = request.form["selectdevice"] for device in sc.gpioDevices: if device.id == deviceId: sc.curDeviceId = deviceId sc.curDevice = device type = device.type for dt in sc.deviceTypes: if dt["type"] == type: sc.curDeviceType = dt break break if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def checkDeviceDeletion(deviceId: str, tc:TriggerConfig) -> str: """ Check whether a device can be deleted The device must not be used in either triggers or actions. Args: deviceId (str): Device ID to be deleted Returns: str: Empty stringif device can be deleted Or message where device occurs """ msg = "" inTrg = [] for trigger in tc.triggers: if trigger.device == deviceId: inTrg.append(trigger.id) inAction = [] for action in tc.actions: if action.device == deviceId: inAction.append(action.id) if len(inTrg) > 0 or len(inAction) > 0: msg = f"Device {deviceId} cannot be deleted because it is used in" if len(inTrg) > 0: msg = msg + " Triggers " + str(inTrg) if len(inAction) > 0: msg = msg + " Actions " + str(inAction) return msg @bp.route('/delete_device', methods=("GET", "POST")) @login_required def delete_device(): logger.debug("In delete_device") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = checkDeviceDeletion(sc.curDeviceId, tc) deviceDel = None if msg == "": deviceDel = sc.curDeviceId idxDel = -1 idx = 0 for device in sc.gpioDevices: if device.id == sc.curDeviceId: idxDel = idx break idx += 1 if idxDel >= 0: dev = sc.curDevice if dev.needsCalibration == True: if dev._deviceStateFile != "": if os.path.exists(dev._deviceStateFile): os.remove(dev._deviceStateFile) del sc.gpioDevices[idxDel] if len(sc.gpioDevices) > 0: sc.curDevice = sc.gpioDevices[0] sc.curDeviceId = sc.curDevice.id for deviceType in sc.deviceTypes: if deviceType["type"] == sc.curDevice.type: sc.curDeviceType = deviceType if not deviceDel is None: sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - device deleted: {deviceDel}") if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def parseTuple(stuple: str) -> tuple[str, tuple]: """ Parse a string which is assumed to be a tuple Args: stuple (str): string to be tuplelized Returns: tuple[str, tuple]: - error - tuplelized string """ rest = stuple err = "" try: tpl = ast.literal_eval(str(stuple)) if type(tpl) is tuple: rest = tpl else: err = f"{stuple} could not be cast to type of tuple!" except Exception as e: err = f"Error parsing {stuple} to tuple: {type(e):{e}}" return err, rest def castType(val:str, tpl:object) ->tuple[str, object]: """ Cast the given value to the type of the given template Args: val (str) : Value to be casted tpl (object): template Returns: tuple[str, object]: - Error message - type-converted value """ err = "" res = val if type(val) is str: try: if type(tpl) is str: pass elif type(tpl) is int: res = int(val) elif type(tpl) is float: res = float(val) elif type(tpl) is bool: if val == "0": res = False elif val == "1": res = True elif val.casefold() == "false": res = False elif val.casefold == "true": res = True else: err = "String does not represent boolean." elif type(tpl) is tuple: l = len(tpl) err, valt = parseTuple(val) if err == "": ll = len(valt) if ll != l: err = f"{val} should be a tuple of length {l}" else: for n in range(0, l): if type(valt[n]) != type(tpl[n]): err = f"{val} : elements of tuple do not have the expected type" break if err == "": res = valt except TypeError as e: err = f"Type error for {val}: {e}" except Exception as e: err = f"{type(e)} error for {val}: {e}" else: err = f"{val} should be a string rather than {type(val)}" return err, res def parseColorTuple(stuple: str) -> tuple: rest = (0, 0, 0) err = "" if stuple.startswith("("): tpl = stuple[1:] if tpl.endswith(")"): tpl = tpl[0: len(tpl) - 1] res = tpl.rsplit(",") if len(res) == 3: for n in range(0, 3): c = res[n] c = c.strip() cnum = c.replace('.','',1).replace(',','',1) if cnum.isdigit() == False: err = "Tuple color values must be numeric." if err == "": rest = (float(res[0]), float(res[1]), float(res[2])) else: err = "Tuple for color must include 3 numeric color values." else: err="Tuple does not end with ')'." else: err="Tuple does not start with '('." return err, rest @bp.route('/device_properties', methods=("GET", "POST")) @login_required def device_properties(): logger.debug("In device_properties") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = "" newParams={} usedPins = "" ok = True try: for key, value in sc.curDeviceType["params"].items(): paramId = f"param_{key}" if value["type"] == "str": val = request.form[paramId] elif value["type"] == "int": vals = request.form[paramId] if vals != "": val = int(vals) else: val = vals elif value["type"] == "float": val = float(request.form[paramId]) elif value["type"] == "floatOrNone": vals = request.form[paramId] if vals == "None": val = None else: vals = vals.strip() if vals.replace('.','',1).replace(',','',1).isdigit() == True: vals = vals.replace(',', '.', 1) val = float(vals) else: msg = f"{key} must be None or float" elif value["type"] == "bool": val = not request.form.get(paramId) is None elif value["type"] == "boolOrNone": vals = request.form[paramId] if vals == "None": val = None else: if vals == "True": val = True elif vals == "False": val = False else: msg = f"{key} must be bool or None" elif value["type"] == "tuple(float)": vals = request.form[paramId] msg, val = parseColorTuple(vals) elif value["type"] == "tuple(int)": vals = request.form[paramId] msg, val = parseTuple(vals) else: val = request.form[paramId] newParams[key] = val if "isPin" in value: if value["isPin"] == True: if usedPins == "": usedPins = f"{val}" else: usedPins += f", {val}" if val == "": ok = False except Exception as e: msg = f"{type(e)}: {e}" if msg == "": sc.curDevice.params = newParams sc.curDevice.usedPins = usedPins sc.curDevice.isOk = ok if sc.isEventhandling == True: msg = "Please restart Event Handling in Trigger/Control for changes to take effect." if msg != "": flash(msg) if not sc.curDeviceId is None: sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - device properties changed for {sc.curDeviceId}") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) def storeResult(result:dict, test:str, testResult:str) -> dict: """ Store a test result in the results dict Since dict keys must be unique, test, which is used as key must be made unique, in order to avoid that duplicate tests are not registered. Args: result (dict) : Results dict test (str) : Test to be registered testResult (str): Test result Returns: dict: Results dict with the test result included """ testu = test n = 1 while testu in result: testu = test + " - " + str(n) n+= 1 result[testu] = testResult return result @bp.route('/test_device', methods=("GET", "POST")) @login_required def test_device(): logger.debug("In test_device") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = "" if sc.isEventhandling == True: msg = "Device test is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice devType = sc.curDeviceType devClass = f"{dev.type}" devArgs = dev.params logger.debug("settings.test_device - devClass=%s", devClass) logger.debug("settings.test_device - devArgs=%s", devArgs) if "testMethods" in devType: devTests = devType["testMethods"] logger.debug("settings.test_device - devTests=%s", devTests) try: logger.debug("settings.test_device -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) dev.setState(devObj) except Exception as e: logger.debug("settings.test_device - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" try: if devObj: devObj.close() except Exception as e: logger.debug("settings.test_device - Error closing %s:%s", devClass, e) if msg == "": for test in devTests: testMethod = test rawTest = test assignValue = None if type(test) == dict: for key,val in test.items(): testMethod = key assignValue = val break elif test.find("=") >= 0: testmethod, assign = test.split("=") if assign[0] == "(": err, assignValue = parseColorTuple(assign) else: assignValue = assign assignValue = castType() logger.debug("settings.test_device - Starting test %s", test) if hasattr(devObj, testMethod): try: attr = getattr(devObj, testMethod) if callable(attr) == True: if assignValue is None: dispTest = f"{devClass}.{testMethod}()" logger.debug("settings.test_device - %s", dispTest) res = attr() result = storeResult(result, dispTest, res) else: dispTest = f"{devClass}.{testMethod}({assignValue})" logger.debug("settings.test_device - %s", dispTest) res = attr(assignValue) result = storeResult(result, dispTest, res) else: if assignValue: dispTest = f"{devClass}.{testMethod}={assignValue}" logger.debug("settings.test_device - %s.%s=%s",devClass, testMethod, assignValue) setattr(devObj, testMethod, assignValue) result = storeResult(result, dispTest, "OK") else: dispTest = f"{devClass}.{testMethod}" result = storeResult(result, dispTest, attr) logger.debug("settings.test_device - %s.%s=%s",devClass, testMethod, result[dispTest]) dev.trackState(devObj) except Exception as e: result = storeResult(result, testMethod, f"{type(e)} : {e}") logger.debug("settings.test_device - Exception %s, %s", type(e), e) else: result = storeResult(result, testMethod, f"Class {devClass} has no method {testMethod}") if "testStepDuration" in devType: dur = devType["testStepDuration"] time.sleep(dur) if "testDuration" in devType: dur = devType["testDuration"] time.sleep(dur) try: if devObj: devObj.close() msg = f"Test completed, {devClass} closed." except Exception as e: logger.debug("settings.test_device - Error closing %s:%s", devClass, e) else: msg = f"No test methods specified for device type {dev.type}" if msg != "": flash(msg) logger.debug("settings.test_device - result %s", result) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/calibrate_device', methods=("GET", "POST")) @login_required def calibrate_device(): logger.debug("In calibrate_device") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" if request.method == "POST": msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice if dev.needsCalibration == True: dev.isCalibrating = True devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.calibrate_device -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) dev.setState(devObj) if hasattr(devObj, "value"): setattr(devObj,"value", 0.0) dev.trackState(devObj) #result["value"] = getattr(devObj, "value") result = dev.getUncalibratedState() dev.isCalibrating = True sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - device calibration started: {sc.curDeviceId}") except Exception as e: msg = f"Error while instantiating class {devClass}: {type(e)} {e}" else: msg = f"Device {dev.id} does not need calibration." if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/calibrate_fbwd', methods=("GET", "POST")) @login_required def calibrate_fbwd(): logger.debug("In calibrate_fbwd reqest.method=%s", request.method) g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice dev.isCalibrating = True devType = sc.curDeviceType if "calibration" in devType: logger.debug("settings.calibrate_fbwd - calibrating") calibration = devType["calibration"] method = "" params = "" if "fbwd" in calibration: adjust = calibration["fbwd"] logger.debug("settings.calibrate_fbwd - calibrating method=%s", adjust) if "method" in adjust: method = adjust["method"] if "params" in adjust: params = adjust["params"] if method != "": devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.calibrate_fbwd -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) except Exception as e: logger.debug("settings.calibrate_fbwd - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" if msg == "": dev.setState(devObj) if hasattr(devObj, method): try: attr = getattr(devObj, method) if callable(attr) == True: logger.debug("settings.calibrate_fbwd - calling %s.%s(**%s)", devClass, method, params) res = attr(**params) else: msg = f"{devClass}.{method} is not callable." except Exception as e: msg = f"Error calling {devClass}.{method}: {type(e)} : {e}" dev.trackState(devObj) if hasattr(devObj, "value"): try: #result["value"] = getattr(devObj, "value") result = dev.getUncalibratedState() except Exception as e: msg = f"Property Error {devClass}.value: {type(e)} : {e}" if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/calibrate_bwd', methods=("GET", "POST")) @login_required def calibrate_bwd(): logger.debug("In calibrate_bwd reqest.method=%s", request.method) g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice dev.isCalibrating = True devType = sc.curDeviceType if "calibration" in devType: logger.debug("settings.calibrate_bwd - calibrating") calibration = devType["calibration"] method = "" params = "" if "bwd" in calibration: adjust = calibration["bwd"] logger.debug("settings.calibrate_bwd - calibrating method=%s", adjust) if "method" in adjust: method = adjust["method"] if "params" in adjust: params = adjust["params"] if method != "": devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.calibrate_bwd -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) except Exception as e: logger.debug("settings.calibrate_bwd - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" if msg == "": dev.setState(devObj) if hasattr(devObj, method): try: attr = getattr(devObj, method) if callable(attr) == True: logger.debug("settings.calibrate_bwd - calling %s.%s(**%s)", devClass, method, params) res = attr(**params) else: msg = f"{devClass}.{method} is not callable." except Exception as e: msg = f"Error calling {devClass}.{method}: {type(e)} : {e}" dev.trackState(devObj) if hasattr(devObj, "value"): try: #result["value"] = getattr(devObj, "value") result = dev.getUncalibratedState() except Exception as e: msg = f"Property Error {devClass}.value: {type(e)} : {e}" if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/docalibrate', methods=("GET", "POST")) @login_required def docalibrate(): logger.debug("In docalibrate") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice dev.isCalibrating = True devType = sc.curDeviceType if "calibration" in devType: logger.debug("settings.docalibrate - calibrating") calibration = devType["calibration"] method = "" params = "" if "calibrate" in calibration: adjust = calibration["calibrate"] logger.debug("settings.docalibrate - calibrating method=%s", adjust) if "method" in adjust: # Calibration by calling a method or setting an attribute method = adjust["method"] if "params" in adjust: params = adjust["params"] if "param" in adjust: # Calibration by setting a parameter to a specific value param = adjust["param"] logger.debug("settings.docalibrate - Setting parameter %s to current value", param) newParams={} for key, value in sc.curDevice.params.items(): logger.debug("settings.docalibrate - key:%s value:%s", key, value) val = value if key == param: state = sc.curDevice.getUncalibratedState() if "value" in state: val = state["value"] logger.debug("settings.docalibrate - value:%s", val) newParams[key] = val logger.debug("settings.docalibrate - newParams:%s", newParams) sc.curDevice.params = newParams dev.isCalibrating = False sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - device calibrated: {sc.curDeviceId}") method = "value" params = {} params["value"] = 0 if method != "": devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.docalibrate -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) except Exception as e: logger.debug("settings.docalibrate - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" if msg == "": dev.setState(devObj) if hasattr(devObj, method): try: attr = getattr(devObj, method) if callable(attr) == True: logger.debug("settings.docalibrate - calling %s.%s(**%s)", devClass, method, params) res = attr(**params) else: if "value" in params: value = params["value"] logger.debug("settings.docalibrate - calling %s.%s", devClass, method) setattr(devObj,"value", value) else: msg = f"'value' not not in {params}." except Exception as e: msg = f"Error calling {devClass}.{method}: {type(e)} : {e}" dev.trackState(devObj) if hasattr(devObj, "value"): try: result["value"] = getattr(devObj, "value") except Exception as e: msg = f"Property Error {devClass}.value: {type(e)} : {e}" dev.isCalibrating = False sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Devices - device calibrated: {sc.curDeviceId}") if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/calibrate_fwd', methods=("GET", "POST")) @login_required def calibrate_fwd(): logger.debug("In calibrate_fwd reqest.method=%s", request.method) g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice dev.isCalibrating = True devType = sc.curDeviceType if "calibration" in devType: logger.debug("settings.calibrate_fwd - calibrating") calibration = devType["calibration"] method = "" params = "" if "fwd" in calibration: adjust = calibration["fwd"] logger.debug("settings.calibrate_fwd - calibrating method=%s", adjust) if "method" in adjust: method = adjust["method"] if "params" in adjust: params = adjust["params"] if method != "": devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.calibrate_fwd -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) except Exception as e: logger.debug("settings.calibrate_fwd - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" if msg == "": dev.setState(devObj) if hasattr(devObj, method): try: attr = getattr(devObj, method) if callable(attr) == True: logger.debug("settings.calibrate_fwd - calling %s.%s(**%s)", devClass, method, params) res = attr(**params) else: msg = f"{devClass}.{method} is not callable." except Exception as e: msg = f"Error calling {devClass}.{method}: {type(e)} : {e}" dev.trackState(devObj) if hasattr(devObj, "value"): try: #result["value"] = getattr(devObj, "value") result = dev.getUncalibratedState() except Exception as e: msg = f"Property Error {devClass}.value: {type(e)} : {e}" if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route('/calibrate_ffwd', methods=("GET", "POST")) @login_required def calibrate_ffwd(): logger.debug("In calibrate_ffwd reqest.method=%s", request.method) g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" sc.lastSettingsTab = "settingsdevices" msg = "" if sc.isEventhandling == True: msg = "Device calibration is not possible while Event Handling is active. Go to Trigger/Control and press Stop." if msg == "": dev = sc.curDevice dev.isCalibrating = True devType = sc.curDeviceType if "calibration" in devType: logger.debug("settings.calibrate_ffwd - calibrating") calibration = devType["calibration"] method = "" params = "" if "ffwd" in calibration: adjust = calibration["ffwd"] logger.debug("settings.calibrate_ffwd - calibrating method=%s", adjust) if "method" in adjust: method = adjust["method"] if "params" in adjust: params = adjust["params"] if method != "": devClass = f"{dev.type}" devArgs = dev.params try: logger.debug("settings.calibrate_ffwd -instantiating %s(**%s)", devClass, devArgs) devObj = globals()[devClass](**devArgs) except Exception as e: logger.debug("settings.calibrate_ffwd - Error while instantiating %s:%s, %s", devClass, type(e), e) msg = f"Error while instantiating class {devClass}: {type(e)} {e}" if msg == "": dev.setState(devObj) if hasattr(devObj, method): try: attr = getattr(devObj, method) if callable(attr) == True: logger.debug("settings.calibrate_ffwd - calling %s.%s(**%s)", devClass, method, params) res = attr(**params) else: msg = f"{devClass}.{method} is not callable." except Exception as e: msg = f"Error calling {devClass}.{method}: {type(e)} : {e}" dev.trackState(devObj) if hasattr(devObj, "value"): try: #result["value"] = getattr(devObj, "value") result = dev.getUncalibratedState() except Exception as e: msg = f"Property Error {devClass}.value: {type(e)} : {e}" if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/versionCheckEnabled", methods=("GET", "POST")) @login_required def versionCheckEnabled(): logger.debug("versionCheckEnabled") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsupdate" if request.method == "POST": msg = "" versionCheckEnabled = not request.form.get("versioncheckenabledcb") is None if sc.versionCheckEnabled != versionCheckEnabled: sc.versionCheckEnabled = versionCheckEnabled sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Update: Check for Updates changed to {sc.versionCheckEnabled}") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/serverUpdate", methods=("GET", "POST")) @login_required def serverUpdate(): logger.debug("serverUpdate") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsupdate" if request.method == "POST": msg = "" if sc.versionCurrent == sc.versionLatest: msg = "You are already using the latest version." else: try: result = subprocess.run( ["git", "fetch", "origin", "main", "--depth=1"], capture_output=True, text=True ) result = subprocess.run( ["git", "reset", "--hard", "origin/main"], capture_output=True, text=True ) sc.updateDone = True msg = "raspiCamSrv updated successfully. Please restart the server to apply the update." except CalledProcessError as e: logger.error("serverUpdate - CalledProcessError: %s", e) msg = "Error updating server: " + str(e) except Exception as e: logger.error("serverUpdate - Exception: %s", e) msg = "Error updating server: " + str(e) if msg != "": flash(msg) return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/updateIgnoreLatest", methods=("GET", "POST")) @login_required def updateIgnoreLatest(): logger.debug("updateIgnoreLatest") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsupdate" if request.method == "POST": msg = "" sc.versionCheckFrom = sc.versionLatest sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Update: Ignored latest version {sc.versionLatest}") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/versionCheckIntervalHours", methods=("GET", "POST")) @login_required def versionCheckIntervalHours(): logger.debug("versionCheckIntervalHours") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsupdate" if request.method == "POST": msg = "" intvl = int(request.form["versioncheckintervalhours"]) sc.versionCheckIntervalHours = intvl sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Update: Version Check Interval changed to {sc.versionCheckIntervalHours} hours") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) @bp.route("/versionCheckNow", methods=("GET", "POST")) @login_required def versionCheckNow(): logger.debug("versionCheckNow") g.hostname = request.host g.version = version cam = Camera() cfg = CameraCfg() cs = cfg.cameras sc = cfg.serverConfig tc = cfg.triggerConfig # Check connection and access of microphone sc.checkMicrophone() cp = cfg.cameraProperties sc.curMenu = "settings" cfgPath = current_app.static_folder + "/config" los = getLoadConfigOnStart(cfgPath) result = {} backups = getBackupsList() sc.lastSettingsTab = "settingsupdate" if request.method == "POST": msg = "" sc.getLatestVersion(now=True) sc.unsavedChanges = True sc.addChangeLogEntry(f"Settings/Update: Version Check Interval changed to {sc.versionCheckIntervalHours} hours") return render_template("settings/main.html", sc=sc, tc=tc, cp=cp, cs=cs, los=los, result=result, backups=backups) ================================================ FILE: raspiCamSrv/static/w3.css ================================================ /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ html { box-sizing: border-box } *, *:before, *:after { box-sizing: inherit } /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ html { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100% } body { margin: 0 } article, aside, details, figcaption, figure, footer, header, main, menu, nav, section { display: block } summary { display: list-item } audio, canvas, progress, video { display: inline-block } progress { vertical-align: baseline } audio:not([controls]) { display: none; height: 0 } [hidden], template { display: none } a { background-color: transparent } a:active, a:hover { outline-width: 0 } abbr[title] { border-bottom: none; text-decoration: underline; text-decoration: underline dotted } b, strong { font-weight: bolder } dfn { font-style: italic } mark { background: #ff0; color: #000 } small { font-size: 80% } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline } sub { bottom: -0.25em } sup { top: -0.5em } figure { margin: 1em 40px } img { border-style: none } code, kbd, pre, samp { font-family: monospace, monospace; font-size: 1em } hr { box-sizing: content-box; height: 0; overflow: visible } button, input, select, textarea, optgroup { font: inherit; margin: 0 } optgroup { font-weight: bold } button, input { overflow: visible } button, select { text-transform: none } button, [type=button], [type=reset], [type=submit] { -webkit-appearance: button } button::-moz-focus-inner, [type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner { border-style: none; padding: 0 } button:-moz-focusring, [type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring { outline: 1px dotted ButtonText } fieldset { border: 1px solid #c0c0c0; margin: 0 2px; padding: .35em .625em .75em } legend { color: inherit; display: table; max-width: 100%; padding: 0; white-space: normal } textarea { overflow: auto } [type=checkbox], [type=radio] { padding: 0 } [type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button { height: auto } [type=search] { -webkit-appearance: textfield; outline-offset: -2px } [type=search]::-webkit-search-decoration { -webkit-appearance: none } ::-webkit-file-upload-button { -webkit-appearance: button; font: inherit } /* End extract */ html, body { font-family: Verdana, sans-serif; font-size: 15px; line-height: 1.5 } html { overflow-x: hidden } h1 { font-size: 36px } h2 { font-size: 30px } h3 { font-size: 24px } h4 { font-size: 20px } h5 { font-size: 18px } h6 { font-size: 16px } .w3-serif { font-family: serif } .w3-sans-serif { font-family: sans-serif } .w3-cursive { font-family: cursive } .w3-monospace { font-family: monospace } h1, h2, h3, h4, h5, h6 { font-family: "Segoe UI", Arial, sans-serif; font-weight: 400; margin: 10px 0 } .w3-wide { letter-spacing: 4px } hr { border: 0; border-top: 1px solid #eee; margin: 20px 0 } .w3-image { max-width: 100%; height: auto } img { vertical-align: middle } a { color: inherit } .w3-table, .w3-table-all { border-collapse: collapse; border-spacing: 0; width: 100%; display: table } .w3-table-all { border: 1px solid #ccc } .w3-bordered tr, .w3-table-all tr { border-bottom: 1px solid #ddd } .w3-striped tbody tr:nth-child(even) { background-color: #f1f1f1 } .w3-table-all tr:nth-child(odd) { background-color: #fff } .w3-table-all tr:nth-child(even) { background-color: #f1f1f1 } .w3-hoverable tbody tr:hover, .w3-ul.w3-hoverable li:hover { background-color: #ccc } .w3-centered tr th, .w3-centered tr td { text-align: center } .w3-table td, .w3-table th, .w3-table-all td, .w3-table-all th { padding: 8px 8px; display: table-cell; text-align: left; vertical-align: top } .w3-table th:first-child, .w3-table td:first-child, .w3-table-all th:first-child, .w3-table-all td:first-child { padding-left: 16px } .w3-btn, .w3-button { border: none; display: inline-block; padding: 8px 16px; vertical-align: middle; overflow: hidden; text-decoration: none; color: inherit; background-color: inherit; text-align: center; cursor: pointer; white-space: nowrap } .w3-btn:hover { box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19) } .w3-btn, .w3-button { -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none } .w3-disabled, .w3-btn:disabled, .w3-button:disabled { cursor: not-allowed; opacity: 0.3 } .w3-disabled *, :disabled * { pointer-events: none } .w3-btn.w3-disabled:hover, .w3-btn:disabled:hover { box-shadow: none } .w3-badge, .w3-tag { background-color: #000; color: #fff; display: inline-block; padding-left: 8px; padding-right: 8px; text-align: center } .w3-badge { border-radius: 50% } .w3-ul { list-style-type: none; padding: 0; margin: 0 } .w3-ul li { padding: 8px 16px; border-bottom: 1px solid #ddd } .w3-ul li:last-child { border-bottom: none } .w3-tooltip, .w3-display-container { position: relative } .w3-tooltip .w3-text { display: none } .w3-tooltip:hover .w3-text { display: inline-block } .w3-ripple:active { opacity: 0.5 } .w3-ripple { transition: opacity 0s } .w3-input { padding: 8px; display: block; border: none; border-bottom: 1px solid #ccc; width: 100% } .w3-select { padding: 9px 0; width: 100%; border: none; border-bottom: 1px solid #ccc } .w3-dropdown-click, .w3-dropdown-hover { position: relative; display: inline-block; cursor: pointer } .w3-dropdown-hover:hover .w3-dropdown-content { display: block } .w3-dropdown-hover:first-child, .w3-dropdown-click:hover { background-color: #ccc; color: #000 } .w3-dropdown-hover:hover>.w3-button:first-child, .w3-dropdown-click:hover>.w3-button:first-child { background-color: #ccc; color: #000 } .w3-dropdown-content { cursor: auto; color: #000; background-color: #fff; display: none; position: absolute; min-width: 160px; margin: 0; padding: 0; z-index: 1 } .w3-check, .w3-radio { width: 24px; height: 24px; position: relative; top: 6px } .w3-sidebar { height: 100%; width: 200px; background-color: #fff; position: fixed !important; z-index: 1; overflow: auto } .w3-bar-block .w3-dropdown-hover, .w3-bar-block .w3-dropdown-click { width: 100% } .w3-bar-block .w3-dropdown-hover .w3-dropdown-content, .w3-bar-block .w3-dropdown-click .w3-dropdown-content { min-width: 100% } .w3-bar-block .w3-dropdown-hover .w3-button, .w3-bar-block .w3-dropdown-click .w3-button { width: 100%; text-align: left; padding: 8px 16px } .w3-main, #main { transition: margin-left .4s } .w3-modal { z-index: 3; display: none; padding-top: 100px; position: fixed; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgb(0, 0, 0); background-color: rgba(0, 0, 0, 0.4) } .w3-modal-content { margin: auto; background-color: #fff; position: relative; padding: 0; outline: 0; width: 600px } .w3-bar { width: 100%; overflow: hidden } .w3-center .w3-bar { display: inline-block; width: auto } .w3-bar .w3-bar-item { padding: 8px 16px; float: left; width: auto; border: none; display: block; outline: 0 } .w3-bar .w3-dropdown-hover, .w3-bar .w3-dropdown-click { position: static; float: left } .w3-bar .w3-button { white-space: normal } .w3-bar-block .w3-bar-item { width: 100%; display: block; padding: 8px 16px; text-align: left; border: none; white-space: normal; float: none; outline: 0 } .w3-bar-block.w3-center .w3-bar-item { text-align: center } .w3-block { display: block; width: 100% } .w3-responsive { display: block; overflow-x: auto } .w3-container:after, .w3-container:before, .w3-panel:after, .w3-panel:before, .w3-row:after, .w3-row:before, .w3-row-padding:after, .w3-row-padding:before, .w3-cell-row:before, .w3-cell-row:after, .w3-clear:after, .w3-clear:before, .w3-bar:before, .w3-bar:after { content: ""; display: table; clear: both } .w3-col, .w3-half, .w3-third, .w3-twothird, .w3-threequarter, .w3-quarter { float: left; width: 100% } .w3-col.s1 { width: 8.33333% } .w3-col.s2 { width: 16.66666% } .w3-col.s3 { width: 24.99999% } .w3-col.s4 { width: 33.33333% } .w3-col.s5 { width: 41.66666% } .w3-col.s6 { width: 49.99999% } .w3-col.s7 { width: 58.33333% } .w3-col.s8 { width: 66.66666% } .w3-col.s9 { width: 74.99999% } .w3-col.s10 { width: 83.33333% } .w3-col.s11 { width: 91.66666% } .w3-col.s12 { width: 99.99999% } @media (min-width:601px) { .w3-col.m1 { width: 8.33333% } .w3-col.m2 { width: 16.66666% } .w3-col.m3, .w3-quarter { width: 24.99999% } .w3-col.m4, .w3-third { width: 33.33333% } .w3-col.m5 { width: 41.66666% } .w3-col.m6, .w3-half { width: 49.99999% } .w3-col.m7 { width: 58.33333% } .w3-col.m8, .w3-twothird { width: 66.66666% } .w3-col.m9, .w3-threequarter { width: 74.99999% } .w3-col.m10 { width: 83.33333% } .w3-col.m11 { width: 91.66666% } .w3-col.m12 { width: 99.99999% } } @media (min-width:993px) { .w3-col.l1 { width: 8.33333% } .w3-col.l2 { width: 16.66666% } .w3-col.l3 { width: 24.99999% } .w3-col.l4 { width: 33.33333% } .w3-col.l5 { width: 41.66666% } .w3-col.l6 { width: 49.99999% } .w3-col.l7 { width: 58.33333% } .w3-col.l8 { width: 66.66666% } .w3-col.l9 { width: 74.99999% } .w3-col.l10 { width: 83.33333% } .w3-col.l11 { width: 91.66666% } .w3-col.l12 { width: 99.99999% } } .w3-rest { overflow: hidden } .w3-stretch { margin-left: -16px; margin-right: -16px } .w3-content, .w3-auto { margin-left: auto; margin-right: auto } .w3-content { max-width: 980px } .w3-auto { max-width: 1140px } .w3-cell-row { display: table; width: 100% } .w3-cell { display: table-cell } .w3-cell-top { vertical-align: top } .w3-cell-middle { vertical-align: middle } .w3-cell-bottom { vertical-align: bottom } .w3-hide { display: none !important } .w3-show-block, .w3-show { display: block !important } .w3-show-inline-block { display: inline-block !important } @media (max-width:1205px) { .w3-auto { max-width: 95% } } @media (max-width:600px) { .w3-modal-content { margin: 0 10px; width: auto !important } .w3-modal { padding-top: 30px } .w3-dropdown-hover.w3-mobile .w3-dropdown-content, .w3-dropdown-click.w3-mobile .w3-dropdown-content { position: relative } .w3-hide-small { display: none !important } .w3-mobile { display: block; width: 100% !important } .w3-bar-item.w3-mobile, .w3-dropdown-hover.w3-mobile, .w3-dropdown-click.w3-mobile { text-align: center } .w3-dropdown-hover.w3-mobile, .w3-dropdown-hover.w3-mobile .w3-btn, .w3-dropdown-hover.w3-mobile .w3-button, .w3-dropdown-click.w3-mobile, .w3-dropdown-click.w3-mobile .w3-btn, .w3-dropdown-click.w3-mobile .w3-button { width: 100% } } @media (max-width:768px) { .w3-modal-content { width: 500px } .w3-modal { padding-top: 50px } } @media (min-width:993px) { .w3-modal-content { width: 900px } .w3-hide-large { display: none !important } .w3-sidebar.w3-collapse { display: block !important } } @media (max-width:992px) and (min-width:601px) { .w3-hide-medium { display: none !important } } @media (max-width:992px) { .w3-sidebar.w3-collapse { display: none } .w3-main { margin-left: 0 !important; margin-right: 0 !important } .w3-auto { max-width: 100% } } .w3-top, .w3-bottom { position: fixed; width: 100%; z-index: 1 } .w3-top { top: 0 } .w3-bottom { bottom: 0 } .w3-overlay { position: fixed; display: none; width: 100%; height: 100%; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 2 } .w3-display-topleft { position: absolute; left: 0; top: 0 } .w3-display-topright { position: absolute; right: 0; top: 0 } .w3-display-bottomleft { position: absolute; left: 0; bottom: 0 } .w3-display-bottomright { position: absolute; right: 0; bottom: 0 } .w3-display-middle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); -ms-transform: translate(-50%, -50%) } .w3-display-left { position: absolute; top: 50%; left: 0%; transform: translate(0%, -50%); -ms-transform: translate(-0%, -50%) } .w3-display-right { position: absolute; top: 50%; right: 0%; transform: translate(0%, -50%); -ms-transform: translate(0%, -50%) } .w3-display-topmiddle { position: absolute; left: 50%; top: 0; transform: translate(-50%, 0%); -ms-transform: translate(-50%, 0%) } .w3-display-bottommiddle { position: absolute; left: 50%; bottom: 0; transform: translate(-50%, 0%); -ms-transform: translate(-50%, 0%) } .w3-display-container:hover .w3-display-hover { display: block } .w3-display-container:hover span.w3-display-hover { display: inline-block } .w3-display-hover { display: none } .w3-display-position { position: absolute } .w3-circle { border-radius: 50% } .w3-round-small { border-radius: 2px } .w3-round, .w3-round-medium { border-radius: 4px } .w3-round-large { border-radius: 8px } .w3-round-xlarge { border-radius: 16px } .w3-round-xxlarge { border-radius: 32px } .w3-row-padding, .w3-row-padding>.w3-half, .w3-row-padding>.w3-third, .w3-row-padding>.w3-twothird, .w3-row-padding>.w3-threequarter, .w3-row-padding>.w3-quarter, .w3-row-padding>.w3-col { padding: 0 8px } .w3-container, .w3-panel { padding: 0.01em 16px } .w3-panel { margin-top: 16px; margin-bottom: 16px } .w3-grid { display: grid } .w3-grid-padding { display: grid; gap: 16px } .w3-flex { display: flex } .w3-text-center { text-align: center } .w3-text-bold, .w3-bold { font-weight: bold } .w3-text-italic, .w3-italic { font-style: italic } .w3-code, .w3-codespan { font-family: Consolas, "courier new"; font-size: 16px } .w3-code { width: auto; background-color: #fff; padding: 8px 12px; border-left: 4px solid #4CAF50; word-wrap: break-word } .w3-codespan { color: crimson; background-color: #f1f1f1; padding-left: 4px; padding-right: 4px; font-size: 110% } .w3-card, .w3-card-2 { box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12) } .w3-card-4, .w3-hover-shadow:hover { box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.2), 0 4px 20px 0 rgba(0, 0, 0, 0.19) } .w3-spin { animation: w3-spin 2s infinite linear } @keyframes w3-spin { 0% { transform: rotate(0deg) } 100% { transform: rotate(359deg) } } .w3-animate-fading { animation: fading 10s infinite } @keyframes fading { 0% { opacity: 0 } 50% { opacity: 1 } 100% { opacity: 0 } } .w3-animate-opacity { animation: opac 0.8s } @keyframes opac { from { opacity: 0 } to { opacity: 1 } } .w3-animate-top { position: relative; animation: animatetop 0.4s } @keyframes animatetop { from { top: -300px; opacity: 0 } to { top: 0; opacity: 1 } } .w3-animate-left { position: relative; animation: animateleft 0.4s } @keyframes animateleft { from { left: -300px; opacity: 0 } to { left: 0; opacity: 1 } } .w3-animate-right { position: relative; animation: animateright 0.4s } @keyframes animateright { from { right: -300px; opacity: 0 } to { right: 0; opacity: 1 } } .w3-animate-bottom { position: relative; animation: animatebottom 0.4s } @keyframes animatebottom { from { bottom: -300px; opacity: 0 } to { bottom: 0; opacity: 1 } } .w3-animate-zoom { animation: animatezoom 0.6s } @keyframes animatezoom { from { transform: scale(0) } to { transform: scale(1) } } .w3-animate-input { transition: width 0.4s ease-in-out } .w3-animate-input:focus { width: 100% !important } .w3-opacity, .w3-hover-opacity:hover { opacity: 0.60 } .w3-opacity-off, .w3-hover-opacity-off:hover { opacity: 1 } .w3-opacity-max { opacity: 0.25 } .w3-opacity-min { opacity: 0.75 } .w3-greyscale-max, .w3-grayscale-max, .w3-hover-greyscale:hover, .w3-hover-grayscale:hover { filter: grayscale(100%) } .w3-greyscale, .w3-grayscale { filter: grayscale(75%) } .w3-greyscale-min, .w3-grayscale-min { filter: grayscale(50%) } .w3-sepia { filter: sepia(75%) } .w3-sepia-max, .w3-hover-sepia:hover { filter: sepia(100%) } .w3-sepia-min { filter: sepia(50%) } .w3-tiny { font-size: 10px !important } .w3-small { font-size: 12px !important } .w3-medium { font-size: 15px !important } .w3-large { font-size: 18px !important } .w3-xlarge { font-size: 24px !important } .w3-xxlarge { font-size: 36px !important } .w3-xxxlarge { font-size: 48px !important } .w3-jumbo { font-size: 64px !important } .w3-left-align { text-align: left !important } .w3-right-align { text-align: right !important } .w3-justify { text-align: justify !important } .w3-center { text-align: center !important } .w3-border-0 { border: 0 !important } .w3-border { border: 1px solid #ccc !important } .w3-border-top { border-top: 1px solid #ccc !important } .w3-border-bottom { border-bottom: 1px solid #ccc !important } .w3-border-left { border-left: 1px solid #ccc !important } .w3-border-right { border-right: 1px solid #ccc !important } .w3-topbar { border-top: 6px solid #ccc !important } .w3-bottombar { border-bottom: 6px solid #ccc !important } .w3-leftbar { border-left: 6px solid #ccc !important } .w3-rightbar { border-right: 6px solid #ccc !important } .w3-section, .w3-code { margin-top: 16px !important; margin-bottom: 16px !important } .w3-margin { margin: 16px !important } .w3-margin-top { margin-top: 16px !important } .w3-margin-bottom { margin-bottom: 16px !important } .w3-margin-left { margin-left: 16px !important } .w3-margin-right { margin-right: 16px !important } .w3-padding-small { padding: 4px 8px !important } .w3-padding { padding: 8px 16px !important } .w3-padding-large { padding: 12px 24px !important } .w3-padding-16 { padding-top: 16px !important; padding-bottom: 16px !important } .w3-padding-24 { padding-top: 24px !important; padding-bottom: 24px !important } .w3-padding-32 { padding-top: 32px !important; padding-bottom: 32px !important } .w3-padding-48 { padding-top: 48px !important; padding-bottom: 48px !important } .w3-padding-64 { padding-top: 64px !important; padding-bottom: 64px !important } .w3-padding-top-64 { padding-top: 64px !important } .w3-padding-top-48 { padding-top: 48px !important } .w3-padding-top-32 { padding-top: 32px !important } .w3-padding-top-24 { padding-top: 24px !important } .w3-left { float: left !important } .w3-right { float: right !important } .w3-button:hover { color: #000 !important; background-color: #ccc !important } .w3-transparent, .w3-hover-none:hover { background-color: transparent !important } .w3-hover-none:hover { box-shadow: none !important } .w3-rtl { direction: rtl } .w3-ltr { direction: ltr } /* Colors */ .w3-amber, .w3-hover-amber:hover { color: #000 !important; background-color: #ffc107 !important } .w3-aqua, .w3-hover-aqua:hover { color: #000 !important; background-color: #00ffff !important } .w3-blue, .w3-hover-blue:hover { color: #fff !important; background-color: #2196F3 !important } .w3-light-blue, .w3-hover-light-blue:hover { color: #000 !important; background-color: #87CEEB !important } .w3-brown, .w3-hover-brown:hover { color: #fff !important; background-color: #795548 !important } .w3-cyan, .w3-hover-cyan:hover { color: #000 !important; background-color: #00bcd4 !important } .w3-blue-grey, .w3-hover-blue-grey:hover, .w3-blue-gray, .w3-hover-blue-gray:hover { color: #fff !important; background-color: #607d8b !important } .w3-green, .w3-hover-green:hover { color: #fff !important; background-color: #4CAF50 !important } .w3-light-green, .w3-hover-light-green:hover { color: #000 !important; background-color: #8bc34a !important } .w3-indigo, .w3-hover-indigo:hover { color: #fff !important; background-color: #3f51b5 !important } .w3-khaki, .w3-hover-khaki:hover { color: #000 !important; background-color: #f0e68c !important } .w3-lime, .w3-hover-lime:hover { color: #000 !important; background-color: #cddc39 !important } .w3-orange, .w3-hover-orange:hover { color: #000 !important; background-color: #ff9800 !important } .w3-deep-orange, .w3-hover-deep-orange:hover { color: #fff !important; background-color: #ff5722 !important } .w3-pink, .w3-hover-pink:hover { color: #fff !important; background-color: #e91e63 !important } .w3-purple, .w3-hover-purple:hover { color: #fff !important; background-color: #9c27b0 !important } .w3-deep-purple, .w3-hover-deep-purple:hover { color: #fff !important; background-color: #673ab7 !important } .w3-red, .w3-hover-red:hover { color: #fff !important; background-color: #f44336 !important } .w3-sand, .w3-hover-sand:hover { color: #000 !important; background-color: #fdf5e6 !important } .w3-teal, .w3-hover-teal:hover { color: #fff !important; background-color: #009688 !important } .w3-yellow, .w3-hover-yellow:hover { color: #000 !important; background-color: #ffeb3b !important } .w3-white, .w3-hover-white:hover { color: #000 !important; background-color: #fff !important } .w3-black, .w3-hover-black:hover { color: #fff !important; background-color: #000 !important } .w3-grey, .w3-hover-grey:hover, .w3-gray, .w3-hover-gray:hover { color: #000 !important; background-color: #9e9e9e !important } .w3-light-grey, .w3-hover-light-grey:hover, .w3-light-gray, .w3-hover-light-gray:hover { color: #000 !important; background-color: #f1f1f1 !important } .w3-dark-grey, .w3-hover-dark-grey:hover, .w3-dark-gray, .w3-hover-dark-gray:hover { color: #fff !important; background-color: #616161 !important } .w3-asphalt, .w3-hover-asphalt:hover { color: #fff !important; background-color: #343a40 !important } .w3-crimson, .w3-hover-crimson:hover { color: #fff !important; background-color: #a20025 !important } .w3-cobalt, w3-hover-cobalt:hover { color: #fff !important; background-color: #0050ef !important } .w3-emerald, .w3-hover-emerald:hover { color: #fff !important; background-color: #008a00 !important } .w3-olive, .w3-hover-olive:hover { color: #fff !important; background-color: #6d8764 !important } .w3-paper, .w3-hover-paper:hover { color: #000 !important; background-color: #f8f9fa !important } .w3-sienna, .w3-hover-sienna:hover { color: #fff !important; background-color: #a0522d !important } .w3-taupe, .w3-hover-taupe:hover { color: #fff !important; background-color: #87794e !important } .w3-danger { color: #fff !important; background-color: #dd0000 !important } .w3-note { color: #000 !important; background-color: #fff599 !important } .w3-info { color: #fff !important; background-color: #0a6fc2 !important } .w3-warning { color: #000 !important; background-color: #ffb305 !important } .w3-success { color: #fff !important; background-color: #008a00 !important } .w3-pale-red, .w3-hover-pale-red:hover { color: #000 !important; background-color: #ffdddd !important } .w3-pale-green, .w3-hover-pale-green:hover { color: #000 !important; background-color: #ddffdd !important } .w3-pale-yellow, .w3-hover-pale-yellow:hover { color: #000 !important; background-color: #ffffcc !important } .w3-pale-blue, .w3-hover-pale-blue:hover { color: #000 !important; background-color: #ddffff !important } .w3-text-amber, .w3-hover-text-amber:hover { color: #ffc107 !important } .w3-text-aqua, .w3-hover-text-aqua:hover { color: #00ffff !important } .w3-text-blue, .w3-hover-text-blue:hover { color: #2196F3 !important } .w3-text-light-blue, .w3-hover-text-light-blue:hover { color: #87CEEB !important } .w3-text-brown, .w3-hover-text-brown:hover { color: #795548 !important } .w3-text-cyan, .w3-hover-text-cyan:hover { color: #00bcd4 !important } .w3-text-blue-grey, .w3-hover-text-blue-grey:hover, .w3-text-blue-gray, .w3-hover-text-blue-gray:hover { color: #607d8b !important } .w3-text-green, .w3-hover-text-green:hover { color: #4CAF50 !important } .w3-text-light-green, .w3-hover-text-light-green:hover { color: #8bc34a !important } .w3-text-indigo, .w3-hover-text-indigo:hover { color: #3f51b5 !important } .w3-text-khaki, .w3-hover-text-khaki:hover { color: #b4aa50 !important } .w3-text-lime, .w3-hover-text-lime:hover { color: #cddc39 !important } .w3-text-orange, .w3-hover-text-orange:hover { color: #ff9800 !important } .w3-text-deep-orange, .w3-hover-text-deep-orange:hover { color: #ff5722 !important } .w3-text-pink, .w3-hover-text-pink:hover { color: #e91e63 !important } .w3-text-purple, .w3-hover-text-purple:hover { color: #9c27b0 !important } .w3-text-deep-purple, .w3-hover-text-deep-purple:hover { color: #673ab7 !important } .w3-text-red, .w3-hover-text-red:hover { color: #f44336 !important } .w3-text-sand, .w3-hover-text-sand:hover { color: #fdf5e6 !important } .w3-text-teal, .w3-hover-text-teal:hover { color: #009688 !important } .w3-text-yellow, .w3-hover-text-yellow:hover { color: #d2be0e !important } .w3-text-white, .w3-hover-text-white:hover { color: #fff !important } .w3-text-black, .w3-hover-text-black:hover { color: #000 !important } .w3-text-grey, .w3-hover-text-grey:hover, .w3-text-gray, .w3-hover-text-gray:hover { color: #757575 !important } .w3-text-light-grey, .w3-hover-text-light-grey:hover, .w3-text-light-gray, .w3-hover-text-light-gray:hover { color: #f1f1f1 !important } .w3-text-dark-grey, .w3-hover-text-dark-grey:hover, .w3-text-dark-gray, .w3-hover-text-dark-gray:hover { color: #3a3a3a !important } .w3-border-amber, .w3-hover-border-amber:hover { border-color: #ffc107 !important } .w3-border-aqua, .w3-hover-border-aqua:hover { border-color: #00ffff !important } .w3-border-blue, .w3-hover-border-blue:hover { border-color: #2196F3 !important } .w3-border-light-blue, .w3-hover-border-light-blue:hover { border-color: #87CEEB !important } .w3-border-brown, .w3-hover-border-brown:hover { border-color: #795548 !important } .w3-border-cyan, .w3-hover-border-cyan:hover { border-color: #00bcd4 !important } .w3-border-blue-grey, .w3-hover-border-blue-grey:hover, .w3-border-blue-gray, .w3-hover-border-blue-gray:hover { border-color: #607d8b !important } .w3-border-green, .w3-hover-border-green:hover { border-color: #4CAF50 !important } .w3-border-light-green, .w3-hover-border-light-green:hover { border-color: #8bc34a !important } .w3-border-indigo, .w3-hover-border-indigo:hover { border-color: #3f51b5 !important } .w3-border-khaki, .w3-hover-border-khaki:hover { border-color: #f0e68c !important } .w3-border-lime, .w3-hover-border-lime:hover { border-color: #cddc39 !important } .w3-border-orange, .w3-hover-border-orange:hover { border-color: #ff9800 !important } .w3-border-deep-orange, .w3-hover-border-deep-orange:hover { border-color: #ff5722 !important } .w3-border-pink, .w3-hover-border-pink:hover { border-color: #e91e63 !important } .w3-border-purple, .w3-hover-border-purple:hover { border-color: #9c27b0 !important } .w3-border-deep-purple, .w3-hover-border-deep-purple:hover { border-color: #673ab7 !important } .w3-border-red, .w3-hover-border-red:hover { border-color: #f44336 !important } .w3-border-sand, .w3-hover-border-sand:hover { border-color: #fdf5e6 !important } .w3-border-teal, .w3-hover-border-teal:hover { border-color: #009688 !important } .w3-border-yellow, .w3-hover-border-yellow:hover { border-color: #ffeb3b !important } .w3-border-white, .w3-hover-border-white:hover { border-color: #fff !important } .w3-border-black, .w3-hover-border-black:hover { border-color: #000 !important } .w3-border-grey, .w3-hover-border-grey:hover, .w3-border-gray, .w3-hover-border-gray:hover { border-color: #9e9e9e !important } .w3-border-light-grey, .w3-hover-border-light-grey:hover, .w3-border-light-gray, .w3-hover-border-light-gray:hover { border-color: #f1f1f1 !important } .w3-border-dark-grey, .w3-hover-border-dark-grey:hover, .w3-border-dark-gray, .w3-hover-border-dark-gray:hover { border-color: #616161 !important } .w3-border-pale-red, .w3-hover-border-pale-red:hover { border-color: #ffe7e7 !important } .w3-border-pale-green, .w3-hover-border-pale-green:hover { border-color: #e7ffe7 !important } .w3-border-pale-yellow, .w3-hover-border-pale-yellow:hover { border-color: #ffffcc !important } .w3-border-pale-blue, .w3-hover-border-pale-blue:hover { border-color: #e7ffff !important } ================================================ FILE: raspiCamSrv/stereoCam.py ================================================ from raspiCamSrv.camera_pi import Camera from raspiCamSrv.camCfg import CameraCfg from raspiCamSrv.camCfg import StereoConfig from _thread import get_ident import time import threading import logging import cv2 import numpy as np import os from datetime import datetime logger = logging.getLogger(__name__) class StereoEvent(object): """An Event-like class that signals all active clients when a new stereo frame is available. """ def __init__(self): #logger.debug("Thread %s: StereoEvent.__init__", get_ident()) self.events = {} def wait(self): """Invoked from each client's thread to wait for the next frame.""" #logger.debug("Thread %s: StereoEvent.wait", get_ident()) ident = get_ident() if ident not in self.events: # this is a new client # add an entry for it in the self.events dict # each entry has two elements, a threading.Event() and a timestamp self.events[ident] = [threading.Event(), time.time()] #logger.debug("Thread %s: StereoEvent.wait - Event ident: %s added to events dict. time:%s", get_ident(), ident, self.events[ident][1]) #for ident, event in self.events.items(): #logger.debug("Thread %s: StereoEvent.wait - Event ident: %s Flag: %s Time: %s (Flag False -> blocking)", get_ident(), ident, self.events[ident][0].is_set(), event[1]) return self.events[ident][0].wait() def set(self): """Invoked by StereoCam when a new frame is available.""" #logger.debug("Thread %s: StereoEvent.set", get_ident()) now = time.time() remove = None for ident, event in self.events.items(): if not event[0].isSet(): # if this client's event is not set, then set it # also update the last set timestamp to now event[0].set() event[1] = now #logger.debug("Thread %s: StereoEvent.set - Event ident: %s Flag: False -> True (unblock/notify)", get_ident(), ident) else: # if the client's event is already set, it means the client # did not process a previous frame # if the event stays set for more than 5 seconds, then assume # the client is gone and remove it #logger.debug("Thread %s: StereoEvent.set - Event ident: %s Flag: True (Last image not processed).", get_ident(), ident) if now - event[1] > 5: #logger.debug("Thread %s: StereoEvent.set - Event ident: %s too old; marked for removal.", get_ident(), ident) remove = ident if remove: del self.events[remove] #logger.debug("Thread %s: StereoEvent.set - Event ident: %s removed.", get_ident(), ident) def clear(self): """Invoked from each client's thread after a frame was processed.""" ident = get_ident() if ident in self.events: self.events[get_ident()][0].clear() #logger.debug("Thread %s: StereoEvent.clear - Flag set to False -> blocking.", get_ident()) class StereoCam(): """ Class for stereo camera handling. """ logger.debug("Thread %s: StereoCam - setting class variables", get_ident()) _instance = None def __new__(cls): logger.debug("Thread %s: StereoCam.__new__", get_ident()) if cls._instance is None: logger.debug("Thread %s: StereoCam.__new__ - Instantiating Class", get_ident()) cls._instance = super(StereoCam, cls).__new__(cls) cls.sThread = None cls.sThreadStop = False cls.pThread = None cls.pThreadStop = False cls.stereoFrameA = None cls.stereoFrame = None cls.event = StereoEvent() cls.camL = None cls.camR = None cls.leftStereoMap_x = None cls.leftStereoMap_y = None cls.rightStereoMap_x = None cls.rightStereoMap_y = None cls.last_access = 0 # time of last client access to a stereo frame # Variables for video generation cls.recordFilename = None cls.recordIdx = None cls.frameSize = None cls.framerate = 20 cls.recordingStart = None cls.recordingActive = False cls.video = None return cls._instance def get_stereoFrame(self): # logger.debug("Thread %s: StereoCam.get_stereoFrame", get_ident()) self.last_access = time.time() self.event.wait() # logger.debug("Thread %s: StereoCam.get_stereoFrame - waiting done", get_ident()) self.event.clear() return self.stereoFrame def _frameToStream(self, frame): """ Convert frame to bytestream""" # logger.debug("Thread %s: StereoCam._frameToStream", get_ident()) frameb = None (stat, frame_jpg) = cv2.imencode(".jpg", frame) if stat == True: frame_jpg_arr = np.array(frame_jpg) frameb = frame_jpg_arr.tobytes() return frameb def _stereoBM(self, stc:StereoConfig, left, right): """StereoBM algorithm for stereo image processing """ # Create a Stereo Block Matching (SBM) object sbm = cv2.StereoBM_create( numDisparities=stc.bm_numDisparitiesFactor * 16, blockSize=stc.bm_blockSize) # Compute the Disparity Map disparity = sbm.compute(left, right) # Normalize the Disparity Map for visualization disp_norm = cv2.normalize( disparity, None, alpha=0, beta= 255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U ) return disp_norm def _stereoSGBM(self, stc: StereoConfig, left, right): """StereoSGBM algorithm for stereo image processing""" # Create a Stereo Block Matching (SBM) object sgbm = cv2.StereoSGBM_create( minDisparity=stc.sgbm_minDisparity, numDisparities=stc.sgbm_numDisparitiesFactor * 16, blockSize=stc.sgbm_blockSize, P1=stc.sgbm_P1, P2=stc.sgbm_P2, disp12MaxDiff=stc.sgbm_disp12MaxDiff, preFilterCap=stc.sgbm_preFilterCap, uniquenessRatio=stc.sgbm_uniquenessRatio, speckleWindowSize=stc.sgbm_speckleWindowSize, speckleRange=stc.sgbm_speckleRange, mode=stc.sgbm_mode, ) # Compute the Disparity Map disparity = sgbm.compute(left, right) # Normalize the Disparity Map for visualization disp_norm = cv2.normalize( disparity, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U, ) return disp_norm def _3DVideo(self, stc: StereoConfig, left, right): """create 3D video from stereo images""" if stc.applyCalibRectify == True: left = cv2.remap( left, self.leftStereoMap_x, self.leftStereoMap_y, cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0, ) right = cv2.remap( right, self.rightStereoMap_x, self.rightStereoMap_y, cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0, ) v3d = right.copy() v3d[:, :, 0] = right[:, :, 0] v3d[:, :, 1] = right[:, :, 1] v3d[:, :, 2] = left[:, :, 2] # output = Left_nice+Right_nice # v3d = cv2.resize(v3d, (700, 700)) return v3d def _processStereoImage(self, left, right): """ Process stereo image """ # logger.debug("Thread %s: StereoCam._processStereoImage", get_ident()) cfg = CameraCfg() stc = cfg.stereoCfg if stc.intent == "DepthMap": # Convert to grayscale left_gray = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY) # logger.debug("Thread %s: StereoCam._processStereoImage - left image converted to grayscale", get_ident()) right_gray = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY) # logger.debug("Thread %s: StereoCam._processStereoImage - right image converted to grayscale", get_ident()) if stc.applyCalibRectify == True: # logger.debug("Thread %s: StereoCam._processStereoImage - shape(leftStereoMap_x): %s shape(leftStereoMap_y): %s", get_ident(), self.leftStereoMap_x.shape, self.leftStereoMap_y.shape) left_rect = cv2.remap( left_gray, self.leftStereoMap_x, self.leftStereoMap_y, cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0, ) # logger.debug("Thread %s: StereoCam._processStereoImage - done", get_ident()) # logger.debug("Thread %s: StereoCam._processStereoImage - shape(rightStereoMap_x): %s shape(rightStereoMap_y): %s", get_ident(), self.rightStereoMap_x.shape, self.rightStereoMap_y.shape) right_rect = cv2.remap( right_gray, self.rightStereoMap_x, self.rightStereoMap_y, cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0, ) # logger.debug("Thread %s: StereoCam._processStereoImage - done", get_ident()) else: left_rect = left_gray right_rect = right_gray if stc.intentAlgo == "StereoBM": # Use StereoBM for depth map disp = self._stereoBM(stc, left_rect, right_rect) if stc.intentAlgo == "StereoSGBM": # Use StereoSGBM for depth map disp = self._stereoSGBM(stc, left_rect, right_rect) elif stc.intent == "3DVideo": # Create 3D video from stereo images disp = self._3DVideo(stc, left, right) else: logger.error("Thread %s: StereoCam._processStereoImage - Unknown stereo intent: %s", get_ident(), stc.intent) return # Convert to stream self.stereoFrameA = disp self.stereoFrame = self._frameToStream(disp) # Signal that a new stereo frame is available self.event.set() def _stereoThread(self): """ Stereo camera thread """ logger.debug("Thread %s: StereoCam._stereoThread", get_ident()) cam = Camera() cfg = CameraCfg() sc = cfg.serverConfig left = None right = None stop = False while not stop: if not cfg.serverConfig.isLiveStream: cam.startLiveStream() if not cfg.serverConfig.isLiveStream2: cam.startLiveStream2() try: # Just to keep the live stream running frame, frameRaw = cam.get_frame() left = cam.getLeftImageForStereo() # logger.debug("Thread %s: StereoCam._stereoThread - got left live view buffer", get_ident()) frame2, frame2Raw = cam.get_frame2() right = cam.getRightImageForStereo() # logger.debug("Thread %s: StereoCam._stereoThread - got right live view buffer", get_ident()) self._processStereoImage(left, right) if self.recordingActive == True: self._recordStereo() if self.sThreadStop: logger.debug("Thread %s: StereoCam._stereoThread - stop requested", get_ident()) stop = True # if there hasn't been any clients asking for frames in # the last 10 seconds then stop the thread if time.time() - self.last_access > 10: stop = True logger.debug("Thread %s: StereoCam._stereoThread - Stopping camera thread due to inactivity.", get_ident()) break except Exception as e: logger.error("Exception in _stereoThread: %s", e) stop = True self.sThread = None sc.isStereoCamActive = False def startStereoCam(self): """ Start stereo camera processing """ logger.debug("Thread %s: StereoCam.startStereoCam", get_ident()) cfg = CameraCfg() sc = cfg.serverConfig stc = cfg.stereoCfg # Load calibration params if stc.applyCalibRectify == True: dataPath = sc.photoRoot + "/" + stc.calibDataSubPath dataFile = dataPath + stc.calibDataFile logger.debug("Thread %s: StereoCam.startStereoCam - Reading calibData from %s", get_ident(), dataFile) calibData = cv2.FileStorage(dataFile,cv2.FILE_STORAGE_READ,) logger.debug("Thread %s: StereoCam.startStereoCam - calibData read from %s", get_ident(), dataFile) self.leftStereoMap_x = calibData.getNode("Left_Stereo_Map_x").mat() self.leftStereoMap_y = calibData.getNode("Left_Stereo_Map_y").mat() self.rightStereoMap_x = calibData.getNode("Right_Stereo_Map_x").mat() self.rightStereoMap_y = calibData.getNode("Right_Stereo_Map_y").mat() calibData.release() logger.debug("Thread %s: StereoCam.startStereoCam - Stereo_Maps extracted", get_ident()) sc = CameraCfg().serverConfig self.last_access = time.time() if self.sThread is None: sc.error = None if not CameraCfg().serverConfig.isLiveStream: Camera().startLiveStream() if not CameraCfg().serverConfig.isLiveStream2: Camera().startLiveStream2() if not sc.error: logger.debug("Thread %s: StereoCam.startStereoCam - starting new thread", get_ident()) self.sThread = threading.Thread(target=self._stereoThread, daemon=True) self.sThread.start() logger.debug("Thread %s: StereoCam.startStereoCam - thread started", get_ident()) else: logger.debug("Thread %s: StereoCam.startStereoCam - not started", get_ident()) def stopStereoCam(self): """ Stop stereo camera processing """ logger.debug("Thread %s: StereoCam.stopStereoCam", get_ident()) if self.sThread is None: logger.debug("Thread %s: StereoCam.stopStereoCam - thread was not active", get_ident()) else: logger.debug("Thread %s: StereoCam.stopStereoCam - stopping thread", get_ident()) self.sThreadStop = True cnt = 0 while self.sThread: time.sleep(0.01) cnt += 1 if cnt > 500: logger.error("Stereo thread did not stop within 5 sec") if self.sThread.is_alive(): cnt = 0 else: self.sThread = None # raise TimeoutError("Stereo thread did not stop within 5 sec") self.sThreadStop = False self.leftStereoMap_x = None self.leftStereoMap_y = None self.rightStereoMap_x = None self.rightStereoMap_y = None logger.debug( "Thread %s: StereoCam.stopStereoCam: Thread has stopped", get_ident() ) def _takeCalibPhotoThread(self): """ Taking photos for camera calibration """ logger.debug("Thread %s: StereoCam._takeCalibPhotoThread camL: %s, camR: %s", get_ident(), self.camL, self.camR) cam = Camera() cfg = CameraCfg() stc = cfg.stereoCfg sc = cfg.serverConfig left = None right = None stop = False found = 0 while not stop: if not cfg.serverConfig.isLiveStream: cam.startLiveStream() if not cfg.serverConfig.isLiveStream2: cam.startLiveStream2() try: # Get the left and right images # Call get_frame, Just to keep the live stream running frame, frameRaw = cam.get_frame() imgL = cam.getLeftImageForStereo() # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - got left image", get_ident()) if self.camR is not None: frame2, frame2Raw = cam.get_frame2() imgR = cam.getRightImageForStereo() # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - got right image", get_ident()) # Convert images to grayscale grayL = cv2.cvtColor(imgL, cv2.COLOR_BGR2GRAY) if self.camR is not None: grayR = cv2.cvtColor(imgR, cv2.COLOR_BGR2GRAY) # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - converted to grayscale", get_ident()) # Find the chess board corners if stc.calibPatternIdx == 0: # Chessboard pattern # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - looking for chessboard corners", get_ident()) retL, cornersL = cv2.findChessboardCorners(grayL, stc.calibPatternSize, None) # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - done chessboard corners - retL=%s", get_ident(), retL) retR = True if self.camR is not None: retR, cornersR = cv2.findChessboardCorners(grayR, stc.calibPatternSize, None) # logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - done chessboard corners - retR=%s", get_ident(), retR) else: logger.error("Thread %s: StereoCam._takeCalibPhotoThread - unknown calibration pattern", get_ident()) raise ValueError("Unknown calibration pattern") # If corners are detected, refine them and save the images if (retL == True) and (retR == True): found += 1 count = stc.getNextPhotoIdx() + 1 fn = "img%03d.png" % count fnC = "img%03d_corners.png" % count fpL = stc.calibPhotosPath + self.camL + "/" + fn fpLC = stc.calibPhotosPath + self.camL + "/" + fnC fsL = stc.calibPhotosSubPath + self.camL + "/" + fn fsLC = stc.calibPhotosSubPath + self.camL + "/" + fnC logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Saving image to %s", get_ident(), fpL) cv2.imwrite(fpL, imgL) logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Image saved: %s", get_ident(), fsL) # Refine the corner positions criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) cv2.cornerSubPix(grayL, cornersL, (11, 11), (-1, -1), criteria) # Create overlay images cv2.drawChessboardCorners(imgL, stc.calibPatternSize, cornersL, retL) cv2.imwrite(fpLC, imgL) logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Image with corners saved: %s", get_ident(), fsLC) if self.camL in stc.calibPhotos: stc.calibPhotos[self.camL].insert(count-1, fsL) stc.calibPhotosCrn[self.camL].insert(count - 1, fsLC) else: stc.calibPhotos[self.camL] = [fsL] stc.calibPhotosCrn[self.camL] = [fsLC] stc.calibPhotosIdx[self.camL] = count - 1 stc.calibPhotosCount[self.camL] = len(stc.calibPhotos[self.camL]) if self.camR is not None: fpR = stc.calibPhotosPath + self.camR + "/" + fn fsR = stc.calibPhotosSubPath + self.camR + "/" + fn fpRC = stc.calibPhotosPath + self.camR + "/" + fnC fsRC = stc.calibPhotosSubPath + self.camR + "/" + fnC logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Saving image to %s", get_ident(), fpR) cv2.imwrite(fpR, imgR) logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Image saved: %s", get_ident(), fsR) # Refine the corner positions cv2.cornerSubPix(grayR, cornersR, (11, 11), (-1, -1), criteria) # Create overlay images cv2.drawChessboardCorners(imgR, stc.calibPatternSize, cornersR, retR) cv2.imwrite(fpRC, imgR) logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Image with corners saved: %s", get_ident(), fsRC) if self.camR in stc.calibPhotos: stc.calibPhotos[self.camR].insert(count - 1, fsR) stc.calibPhotosCrn[self.camR].insert(count - 1, fsRC) else: stc.calibPhotos[self.camR] = [fsR] stc.calibPhotosCrn[self.camR] = [fsRC] stc.calibPhotosIdx[self.camR] = count - 1 stc.calibPhotosCount[self.camR] = len(stc.calibPhotos[self.camR]) sc.unsavedChanges = True sc.addChangeLogEntry(f"Calibration photo(s) added") stc.calibPhotoRecordingMsg = f"{len(stc.calibPhotos[self.camL])} of {stc.calibPhotosTarget} Calibration photo(s) taken: {fn}" time.sleep(2) else: if found > 0: if self.camR is None: stc.calibPhotoRecordingMsg = "No or not all chessboard corners found." else: stc.calibPhotoRecordingMsg = "No or not all chessboard corners found on both cameras." if stc.calibPhotosCount[self.camL] >= stc.calibPhotosTarget: stc.calibPhotosOK[self.camL] = True logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Target number of calibration photos reached for camera %s", get_ident(), self.camL) if self.camR is not None: if stc.calibPhotosCount[self.camR] >= stc.calibPhotosTarget: stc.calibPhotosOK[self.camR] = True logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Target number of calibration photos reached for camera %s", get_ident(), self.camR) if stc.isCalibPhotosOK(self.camL, self.camR): logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - Target number of calibration photos reached for both cameras", get_ident()) stop = True stc.calibPhotoRecordingMsg = "Target number of calibration photos reached." time.sleep(2) if self.pThreadStop: logger.debug("Thread %s: StereoCam._takeCalibPhotoThread - stop requested", get_ident()) stop = True stc.calibPhotoRecordingMsg = "" except Exception as e: logger.error("Exception in _takeCalibPhotoThread: %s", e) stc.calibPhotoRecordingMsg = f"Error while taking photos for calibration: {e}." stop = True self.pThread = None stc.calibPhotoRecording = False stc.calibPhotoRecordingMsg = "" def takeCalibrationPhotos(self, camL: str, camR: str): """ Take calibration photos for camera calibration """ logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - camL=%s, camR=%s", get_ident(), camL, camR) cfg = CameraCfg() sc = cfg.serverConfig stc = cfg.stereoCfg if stc.isCalibPhotosOK(camL, camR) == False: logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - isCalibPhotosOK= %s", get_ident(), stc.isCalibPhotosOK(camL, camR)) if self.pThread is None: self.camL = camL self.camR = camR sc.error = None if not stc.calibPhotoRecording: Camera().startLiveStream() if not CameraCfg().serverConfig.isLiveStream2: Camera().startLiveStream2() if not sc.error: logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - starting new thread", get_ident()) self.pThread = threading.Thread(target=self._takeCalibPhotoThread, daemon=True) self.pThread.start() stc.calibPhotoRecording = True logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - thread started", get_ident()) else: logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - not started", get_ident()) else: logger.debug("Thread %s: StereoCam.takeCalibrationPhotos - isCalibPhotosOK= %s", get_ident(), stc.isCalibPhotosOK(camL, camR)) def stoptakeCalibrationPhotos(self): """ Stop taking calibration photos """ logger.debug("Thread %s: StereoCam.stoptakeCalibrationPhotos", get_ident()) cfg = CameraCfg() stc = cfg.stereoCfg if self.pThread is None: logger.debug("Thread %s: StereoCam.stoptakeCalibrationPhotos - thread was not active", get_ident()) else: logger.debug("Thread %s: StereoCam.stoptakeCalibrationPhotos - stopping thread", get_ident()) self.pThreadStop = True cnt = 0 while self.pThread: time.sleep(0.01) cnt += 1 if cnt > 500: logger.error("takeCalibPhotoThread did not stop within 5 sec") if self.pThread.is_alive(): cnt = 0 else: self.pThread = None # raise TimeoutError("Stereo thread did not stop within 5 sec") self.pThreadStop = False stc.calibPhotoRecording = False logger.debug("Thread %s: StereoCam.stoptakeCalibrationPhotos: Thread has stopped", get_ident()) def calibrateCameras(self, camL: str, camR: str): """ Calibrate the stereo cameras Source: https://learnopencv.com/camera-calibration-using-opencv/ """ logger.debug("Thread %s: StereoCam.calibrateCameras - camL=%s, camR=%s", get_ident(), camL, camR) cfg = CameraCfg() sc = cfg.serverConfig stc = cfg.stereoCfg # Termination criteria for refining the detected corners criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) logger.debug("Thread %s: StereoCam.calibrateCameras - Termination criteria set: %s", get_ident(), criteria) # Prepare 3D object points, like (0,0,0), (1,0,0), (2,0,0) ....,(9,6,0) objp = np.zeros((stc.calibPatternSize[0] * stc.calibPatternSize[1], 3), np.float32) objp[:, :2] = np.mgrid[0:stc.calibPatternSize[0], 0:stc.calibPatternSize[1]].T.reshape(-1, 2) # logger.debug("Thread %s: StereoCam.calibrateCameras - 3D Object points prepared: %s", get_ident(), objp) # Initialize lists for 2D image points and 3D object points img_ptsL = [] img_ptsR = [] obj_pts = [] # Process all prepared calibration images for i in range(0, len(stc.calibPhotos[camL])): # Read images logger.debug("Thread %s: StereoCam.calibrateCameras - Loading image %s/%s", get_ident(), i + 1, len(stc.calibPhotos[camL])) pathL = sc.photoRoot + "/" + stc.calibPhotos[camL][i] imgL = cv2.imread(pathL) pathR = sc.photoRoot + "/" + stc.calibPhotos[camR][i] imgR = cv2.imread(pathR) logger.debug("Thread %s: StereoCam.calibrateCameras - Left and right image loaded", get_ident()) # Convert to grayscale imgL_gray = cv2.cvtColor(imgL, cv2.COLOR_BGR2GRAY) imgR_gray = cv2.cvtColor(imgR, cv2.COLOR_BGR2GRAY) logger.debug("Thread %s: StereoCam.calibrateCameras - Images converted to grayscale", get_ident()) outputL = imgL.copy() outputR = imgR.copy() logger.debug("Thread %s: StereoCam.calibrateCameras - Images copied", get_ident()) # Find chessboard corners retL, cornersL = cv2.findChessboardCorners(imgL_gray, stc.calibPatternSize, None) retR, cornersR = cv2.findChessboardCorners(imgR_gray, stc.calibPatternSize, None) logger.debug("Thread %s: StereoCam.calibrateCameras - Corners found: L=%s, R=%s", get_ident(), retL, retR) # logger.debug("Thread %s: StereoCam.calibrateCameras - Corners Left: %s", get_ident(), cornersL) # logger.debug("Thread %s: StereoCam.calibrateCameras - Corners Right: %s", get_ident(), cornersR) if retR and retL: # If found, add object points, image points (after refining them) obj_pts.append(objp) logger.debug("Thread %s: StereoCam.calibrateCameras - object points appended", get_ident()) cornersRefL = cv2.cornerSubPix(imgL_gray, cornersL, (11, 11), (-1, -1), criteria) cornersRefR = cv2.cornerSubPix(imgR_gray, cornersR, (11, 11), (-1, -1), criteria) # logger.debug("Thread %s: StereoCam.calibrateCameras - Refined corners Left: %s", get_ident(), cornersL) # logger.debug("Thread %s: StereoCam.calibrateCameras - Refined corners Right: %s", get_ident(), cornersR) img_ptsL.append(cornersRefL) img_ptsR.append(cornersRefR) logger.debug("Thread %s: StereoCam.calibrateCameras - image points appended", get_ident()) # Calibrate left camera logger.debug("Thread %s: StereoCam.calibrateCameras - Calibrating left camera.", get_ident()) retL, mtxL, distL, rvecsL, tvecsL = cv2.calibrateCamera( obj_pts, img_ptsL, imgL_gray.shape[::-1], None, None ) stc.calibRmsReproError[camL] = retL # logger.debug("Thread %s: StereoCam.calibrateCameras - Camera matrix: \n%s", get_ident(), mtxL) # logger.debug("Thread %s: StereoCam.calibrateCameras - Distortion Coeff: \n%s", get_ident(), distL) # logger.debug("Thread %s: StereoCam.calibrateCameras - Rotation vectors: \n%s", get_ident(), rvecsL) # logger.debug("Thread %s: StereoCam.calibrateCameras - Translation vectors: \n%s", get_ident(), tvecsL) logger.debug("Thread %s: StereoCam.calibrateCameras - Optimizing camera matrix.", get_ident()) hL, wL = imgL_gray.shape[:2] new_mtxL, roiL = cv2.getOptimalNewCameraMatrix(mtxL, distL, (wL, hL), 1, (wL, hL)) stc._calibCameraOK[camL] = True # logger.debug("Thread %s: StereoCam.calibrateCameras - OptimizedCamera matrix: \n%s", get_ident(), new_mtxL) # Calibrate right camera logger.debug("Thread %s: StereoCam.calibrateCameras - Calibrating right camera.", get_ident()) retR, mtxR, distR, rvecsR, tvecsR = cv2.calibrateCamera( obj_pts, img_ptsR, imgR_gray.shape[::-1], None, None ) stc.calibRmsReproError[camR] = retR hR, wR = imgR_gray.shape[:2] new_mtxR, roiR = cv2.getOptimalNewCameraMatrix(mtxR, distR, (wR, hR), 1, (wR, hR)) stc._calibCameraOK[camR] = True logger.debug("Thread %s: StereoCam.calibrateCameras - Stereo calibration started.", get_ident()) flags = 0 flags |= cv2.CALIB_FIX_INTRINSIC # Here we fix the intrinsic camara matrixes so that only Rot, Trns, Emat and Fmat are calculated. # Hence intrinsic parameters are the same criteria_stereo = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) # This step is performed to transformation between the two cameras and calculate Essential and Fundamenatl matrix retS, new_mtxL, distL, new_mtxR, distR, Rot, Trns, Emat, Fmat = cv2.stereoCalibrate( obj_pts, img_ptsL, img_ptsR, new_mtxL, distL, new_mtxR, distR, imgL_gray.shape[::-1], criteria_stereo, flags, ) stc.calibStereoOK = True # Once we know the transformation between the two cameras we can perform stereo rectification # StereoRectify function logger.debug("Thread %s: StereoCam.calibrateCameras - Stereo rectification started.", get_ident()) rectify_scale = stc.rectifyScale # if 0 image croped, if 1 image not croped rect_l, rect_r, proj_mat_l, proj_mat_r, Q, roiL, roiR = cv2.stereoRectify( new_mtxL, distL, new_mtxR, distR, imgL_gray.shape[::-1], Rot, Trns, rectify_scale, (0, 0), ) # Use the rotation matrixes for stereo rectification and camera intrinsics for undistorting the image # Compute the rectification map (mapping between the original image pixels and # their transformed values after applying rectification and undistortion) for left and right camera frames Left_Stereo_Map = cv2.initUndistortRectifyMap( new_mtxL, distL, rect_l, proj_mat_l, imgL_gray.shape[::-1], cv2.CV_16SC2 ) Right_Stereo_Map = cv2.initUndistortRectifyMap( new_mtxR, distR, rect_r, proj_mat_r, imgR_gray.shape[::-1], cv2.CV_16SC2 ) stc.stereoRectifyOK = True dataPath = sc.photoRoot + "/" + stc.calibDataSubPath dataFile = dataPath + stc.calibDataFile os.makedirs(dataPath, exist_ok=True) logger.debug("Thread %s: StereoCam.calibrateCameras - Saving parameters to %s", get_ident(), dataFile) cv_file = cv2.FileStorage(dataFile, cv2.FILE_STORAGE_WRITE) cv_file.write("Left_Stereo_Map_x", Left_Stereo_Map[0]) cv_file.write("Left_Stereo_Map_y", Left_Stereo_Map[1]) cv_file.write("Right_Stereo_Map_x", Right_Stereo_Map[0]) cv_file.write("Right_Stereo_Map_y", Right_Stereo_Map[1]) cv_file.release() stc.calibDataOK = True stc.calibDate = datetime.now() logger.debug("Thread %s: StereoCam.calibrateCameras - Success", get_ident()) def startRecordStereo(self, fnRaw) -> str: """ Start recording stereo video Input: fnRaw: Filename without extension Return Filename for video file """ logger.debug("Thread %s: StereoCam.startRecordStereo", get_ident()) done = False err = "" camCfg = CameraCfg() sc = camCfg.serverConfig try: if self.recordingActive == False: self.recordFilename = fnRaw + ".mp4" fp = sc.photoRoot + "/" + "photos/" + "camera_S" os.makedirs(fp, exist_ok=True) save_path = fp + "/" + self.recordFilename self.frameSize = CameraCfg().liveViewConfig.stream_size logger.debug("Thread %s: StereoCam.startRecordStereo - video path:%s", get_ident(), save_path) # fourcc = cv2.VideoWriter_fourcc(*'mp4v') fourcc = cv2.VideoWriter_fourcc(*'avc1') logger.debug("Thread %s: StereoCam.startRecordStereo - fps:%s framesize:%s", get_ident(), self.framerate, self.frameSize) self.video = cv2.VideoWriter(save_path, fourcc, self.framerate, self.frameSize) assert self.video.isOpened() self.recordingActive = True sc.isStereoCamRecording = True self.recordIdx = 0 # Create placeholder image imgFilename = fnRaw + ".jpg" img_path = fp + "/" + imgFilename cv2.imwrite(img_path, self.stereoFrameA) self._recordStereo() done = True except AssertionError as e: logger.error("Thread %s: StereoCam - AssertionError when starting recording: %s", get_ident(), e) err = f"AssertionError: {e}" except Exception as e: logger.error("Thread %s: StereoCam - Exception when starting recording: %s", get_ident(), e) err = f"Exception: {e}" return (done, self.recordFilename, err) def stopRecordStereo(self): """ Stop recording stereo """ logger.debug("Thread %s: StereoCam.stopRecordStereo", get_ident()) camCfg = CameraCfg() sc = camCfg.serverConfig if self.recordingActive == True: self.video.release() logger.debug("Thread %s: StereoCam.stopRecordStereo - video released with %s frames", get_ident(), self.recordIdx) self.recordingActive = False sc.isStereoCamRecording = False def _recordStereo(self): """ Record stereo as series of png - add new frame """ if self.recordingActive == True: logger.debug("Thread %s: StereoCam._recordStereo - recordIdx:%s", get_ident(), self.recordIdx) if len(self.stereoFrameA.shape) == 2: framergb = cv2.cvtColor(self.stereoFrameA, cv2.COLOR_YUV2RGB_I420) elif len(self.stereoFrameA.shape) == 3: if self.stereoFrameA.shape[2] == 4: framergb = cv2.cvtColor(self.stereoFrameA, cv2.COLOR_RGBA2RGB) else: framergb = self.stereoFrameA else: framergb = self.stereoFrameA self.video.write(framergb) self.recordIdx += 1 ================================================ FILE: raspiCamSrv/sun.py ================================================ """ Module for calculation of sun path properties - Sunrise / Sunset times (Based on code from https://en.wikipedia.org/wiki/Sunrise_equation) - Solar position (azimuth, elevation) dependent on time of day - Time(s) when sun has a specific azimuth (e.g. for controlling camera direction) All calculations are based on the local time of the configured timezone, which is determined by the longitude and the timezone key. The timezone key is used to determine the UTC offset and daylight saving time rules for the location. The calculations take into account the elevation of the location, which affects the sunrise and sunset times. The solar position is calculated using standard astronomical formulas, and the times for specific azimuths are found using a numerical root-finding method (bisection). """ import logging from datetime import datetime, timedelta, timezone, tzinfo from math import acos, asin, ceil, cos, degrees, fmod, radians, sin, sqrt from time import time from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) class Sun(): def __init__(self, latitude: float, longitude: float, elevation: float, timezone: str): """Constructor for sun class Args: - `latitude (float)` : Latitude - `longitude (float)` : Longitude - `elevation (float)` : Elevation - `timezone (str)` : Time Zone """ logger.debug('Sun - Creating Sun object with latitude=%s, longitude=%s, elevation=%s, timezone=%s', latitude, longitude, elevation, timezone) self._latitude = latitude self._longitude = longitude self._elevation = elevation self._timezone = timezone def _ts2human(self, ts: float, debugtz: tzinfo) -> str: return str(datetime.fromtimestamp(ts, debugtz)) def _j2ts(self, j: float) -> float: return (j - 2440587.5) * 86400 def _ts2j(self, ts: float) -> float: return ts / 86400.0 + 2440587.5 def _j2human(self, j: float, debugtz: tzinfo) -> str: ts = self._j2ts(j) return f'{ts} = {self._ts2human(ts, debugtz)}' def _deg2human(self, deg: float) -> str: x = int(deg * 3600.0) num = f'∠{deg:.3f}°' rad = f'∠{radians(deg):.3f}rad' human = f'∠{x // 3600}°{x // 60 % 60}′{x % 60}″' return f'{rad} = {human} = {num}' def _calc( self, current_timestamp: float, f: float, l_w: float, elevation: float = 0.0, *, debugtz: tzinfo = None, ) -> tuple: logger.debug(f'Latitude f = {self._deg2human(f)}') logger.debug(f'Longitude l_w = {self._deg2human(l_w)}') logger.debug(f'Now ts = {self._ts2human(current_timestamp, debugtz)}') J_date = self._ts2j(current_timestamp) logger.debug(f'Julian date j_date = {J_date:.3f} days') # Julian day # TODO: ceil ? n = ceil(J_date - (2451545.0 + 0.0009) + 69.184 / 86400.0) logger.debug(f'Julian day n = {n:.3f} days') # Mean solar time J_ = n + 0.0009 - l_w / 360.0 logger.debug(f'Mean solar time J_ = {J_:.9f} days') # Solar mean anomaly # M_degrees = 357.5291 + 0.98560028 * J_ # Same, but looks ugly M_degrees = fmod(357.5291 + 0.98560028 * J_, 360) M_radians = radians(M_degrees) logger.debug(f'Solar mean anomaly M = {self._deg2human(M_degrees)}') # Equation of the center C_degrees = 1.9148 * sin(M_radians) + 0.02 * sin(2 * M_radians) + 0.0003 * sin(3 * M_radians) # The difference for final program result is few milliseconds # https://www.astrouw.edu.pl/~jskowron/pracownia/praca/sunspot_answerbook_expl/expl-4.html # e = 0.01671 # C_degrees = \ # degrees(2 * e - (1 / 4) * e ** 3 + (5 / 96) * e ** 5) * sin(M_radians) \ # + degrees(5 / 4 * e ** 2 - (11 / 24) * e ** 4 + (17 / 192) * e ** 6) * sin(2 * M_radians) \ # + degrees(13 / 12 * e ** 3 - (43 / 64) * e ** 5) * sin(3 * M_radians) \ # + degrees((103 / 96) * e ** 4 - (451 / 480) * e ** 6) * sin(4 * M_radians) \ # + degrees((1097 / 960) * e ** 5) * sin(5 * M_radians) \ # + degrees((1223 / 960) * e ** 6) * sin(6 * M_radians) logger.debug(f'Equation of the center C = {self._deg2human(C_degrees)}') # Ecliptic longitude # L_degrees = M_degrees + C_degrees + 180.0 + 102.9372 # Same, but looks ugly L_degrees = fmod(M_degrees + C_degrees + 180.0 + 102.9372, 360) logger.debug(f'Ecliptic longitude L = {self._deg2human(L_degrees)}') Lambda_radians = radians(L_degrees) # Solar transit (julian date) J_transit = 2451545.0 + J_ + 0.0053 * sin(M_radians) - 0.0069 * sin(2 * Lambda_radians) logger.debug(f'Solar transit time J_trans = {self._j2human(J_transit, debugtz)}') # Declination of the Sun sin_d = sin(Lambda_radians) * sin(radians(23.4397)) # cos_d = sqrt(1-sin_d**2) # exactly the same precision, but 1.5 times slower cos_d = cos(asin(sin_d)) # Hour angle some_cos = (sin(radians(-0.833 - 2.076 * sqrt(elevation) / 60.0)) - sin(radians(f)) * sin_d) / (cos(radians(f)) * cos_d) try: w0_radians = acos(some_cos) except ValueError: return None, None, some_cos > 0.0 w0_degrees = degrees(w0_radians) # 0...180 logger.debug(f'Hour angle w0 = {self._deg2human(w0_degrees)}') j_rise = J_transit - w0_degrees / 360 j_set = J_transit + w0_degrees / 360 logger.debug(f'Sunrise j_rise = {self._j2human(j_rise, debugtz)}') logger.debug(f'Sunset j_set = {self._j2human(j_set, debugtz)}') logger.debug(f'Day length {w0_degrees / (180 / 24):.3f} hours') return self._j2ts(j_rise), self._j2ts(j_set), None def sunTimezone(self) -> str: """## Return the timezone key used for sun calculations ### Returns: - `str`: Timezone key """ return self._timezone def sunrise_sunset(self, time: datetime) -> tuple[datetime, datetime]: """Determine sunrise and sunset for a specific date Args: - `time (datetime)`: Date for which to determine sunrise Returns: - `datetime`: time of sunrise """ timeTS = datetime.timestamp(time) sunriseTS, sunsetTS, err = self._calc( timeTS, self._latitude, self._longitude, self._elevation, debugtz=ZoneInfo(self._timezone) ) sunrise = datetime.fromtimestamp(sunriseTS, ZoneInfo(self._timezone)) sunset = datetime.fromtimestamp(sunsetTS, ZoneInfo(self._timezone)) return sunrise, sunset def _day_of_year(self, dt: datetime) -> int: """Day number in the year (1. January = 1).""" return dt.timetuple().tm_yday def _equation_of_time(self, N: int) -> float: """Equation of time in minutes for day number N. Approximation formula according to Spencer (1971). """ B = radians(360 / 365 * (N - 81)) E = 9.87 * sin(2 * B) - 7.53 * cos(B) - 1.5 * sin(B) return E # Minuten def _declination(self, N: int) -> float: """Solar declination in degrees for day number N.""" return -23.45 * cos(radians(360 / 365 * (N + 10))) def solar_position( self, dt: datetime, log: bool = True, ) -> dict: """Calculate solar azimuth and elevation. Parameters ---------- dt : datetime - Local date/time Returns ------- dict with: azimuth - Azimuth in degrees (0 = North, 90 = East, 180 = South, 270 = West) elevation - Sun elevation in degrees (negative = below horizon) hour_angle - Hour angle in degrees declination - Declination in degrees solar_time - True solar time as a string """ if log: logger.debug("sun.solar_position - Calculating solar position for datetime: %s", dt) dt = dt.astimezone(ZoneInfo(self._timezone)) N = self._day_of_year(dt) local_time_hours = dt.hour + dt.minute / 60 + dt.second / 3600 utc_offset = dt.utcoffset().total_seconds() / 3600 # Time correction: longitude correction + equation of time ref_longitude = utc_offset * 15 # Reference meridian of the timezone longitude_correction = (self._longitude - ref_longitude) * 4 / 60 # Hours E_hours = self._equation_of_time(N) / 60 # True solar time (TST) solar_time = local_time_hours + longitude_correction + E_hours # Hour angle H (0 = noon, negative = morning, positive = afternoon) H = (solar_time - 12) * 15 # Degrees # Declination delta = self._declination(N) # Auxiliary values in radians phi = radians(self._latitude) delta_r = radians(delta) H_r = radians(H) # Solar elevation sin_alpha = ( sin(phi) * sin(delta_r) + cos(phi) * cos(delta_r) * cos(H_r) ) sin_alpha = max(-1.0, min(1.0, sin_alpha)) alpha = degrees(asin(sin_alpha)) # Azimuth cos_alpha = cos(radians(alpha)) if cos_alpha < 1e-10: azimuth = 0.0 else: cos_A = (sin(delta_r) - sin(phi) * sin_alpha) / ( cos(phi) * cos_alpha ) cos_A = max(-1.0, min(1.0, cos_A)) A = degrees(acos(cos_A)) # Afternoon: Azimuth > 180 azimuth = 360 - A if H > 0 else A # True solar time as a readable string solar_h = int(solar_time) % 24 solar_m = int((solar_time - int(solar_time)) * 60) solar_s = int(((solar_time - int(solar_time)) * 60 - solar_m) * 60) solar_time_str = f"{solar_h:02d}:{solar_m:02d}:{solar_s:02d}" if log: logger.debug("sun.solar_position - Calculated solar position: azimuth=%.2f°, elevation=%.2f°, hour_angle=%.2f°, declination=%.2f°, solar_time=%s", azimuth, alpha, H, delta, solar_time_str) return { "azimuth": round(azimuth, 2), "elevation": round(alpha, 2), "hour_angle": round(H, 2), "declination": round(delta, 2), "solar_time": solar_time_str, } def _get_az(self, base: datetime, minutes_from_midnight: float) -> float: """Azimuth at a specific minute of the day.""" dt = base + timedelta(minutes=minutes_from_midnight) return self.solar_position(dt, log=False)["azimuth"] def _az_diff(self, base: datetime, minutes: float, target_azimuth: float) -> float: """Differenz zwischen aktuellem und Ziel-Azimut, zirkulaer normiert.""" diff = self._get_az(base, minutes) - target_azimuth # Zirkulaere Normierung: -180 bis +180 while diff > 180: diff -= 360 while diff < -180: diff += 360 return diff def _bisect(self, base: datetime, t_lo: float, t_hi: float, target_azimuth: float, tol: float = 0.5) -> float | None: """Bisektionsverfahren zur Nullstellensuche.""" f_lo = self._az_diff(base, t_lo, target_azimuth) f_hi = self._az_diff(base, t_hi, target_azimuth) if f_lo * f_hi > 0: return None for _ in range(40): # max. 40 Iterations -> about 0.001 Min. Precision t_mid = (t_lo + t_hi) / 2 f_mid = self._az_diff(base, t_mid, target_azimuth) if abs(t_hi - t_lo) < tol / 60: return t_mid if f_lo * f_mid <= 0: t_hi, f_hi = t_mid, f_mid else: t_lo, f_lo = t_mid, f_mid return (t_lo + t_hi) / 2 def find_times_for_azimuth( self, date: datetime, target_azimuth: float, min_elevation: float = 0.0, ) -> list[dict]: """ Calculate the time(s) when the sun has a specific azimuth. This method uses a numerical root-finding approach (bisection method) to find the time(s) when the sun's azimuth matches the target value. Method: Numerical root-finding (bisection method) The sign behavior of (azimuth(t) - target) is used to narrow down the roots to hour- or minute-level accuracy. Parameters ---------- date : datetime - Date (time is ignored) target_azimuth : float - Target azimuth in degrees (0-360) min_elevation : float - Minimum sun elevation (default: 0 = above horizon) Returns ------- List of dicts with: time - datetime object time_str - Time as HH:MM:SS azimuth - Actual azimuth at the time elevation - Sun elevation in degrees side - "Morning" or "Afternoon" """ logger.debug("sun.find_times_for_azimuth - Finding times for target azimuth %s° on date %s with min elevation %s°", target_azimuth, date, min_elevation) results = [] date = date.astimezone(ZoneInfo(self._timezone)) base = date.replace(hour=0, minute=0, second=0, microsecond=0) utc_offset = date.utcoffset().total_seconds() / 3600 # Sampling every 15 minutes for the entire day step = 15 # Minutes samples = [(t, self._az_diff(base, t, target_azimuth)) for t in range(0, 1440 + step, step)] # Look for sign changes -> potential roots found_times = set() for i in range(len(samples) - 1): t0, d0 = samples[i] t1, d1 = samples[i + 1] if d0 == 0.0: # Exact match at t0 found_times.add(round(t0 * 60)) # Round to nearest second elif d1 == 0.0: # Exact match at t1 found_times.add(round(t1 * 60)) # Round to nearest second else: if d0 * d1 < 0: t_exact = self._bisect(base, t0, t1, target_azimuth) if t_exact is not None: # Round to the nearest second t_rounded_sec = round(t_exact * 60) if t_rounded_sec not in found_times: found_times.add(t_rounded_sec) # Prepare results for t_sec in sorted(found_times): dt_result = base + timedelta(seconds=t_sec) pos = self.solar_position(dt_result, log=False) logger.debug("sun.find_times_for_azimuth - Found potential time: %s with azimuth %.2f° and elevation %.2f°", dt_result, pos["azimuth"], pos["elevation"]) if pos["elevation"] < min_elevation: continue if abs(pos["azimuth"] - target_azimuth) > 0.5: continue h = dt_result.hour m = dt_result.minute s = dt_result.second results.append({ "time": dt_result, "time_str": f"{h:02d}:{m:02d}:{s:02d}", "azimuth": round(pos["azimuth"], 2), "elevation": round(pos["elevation"], 2), "side": "Morning" if pos["hour_angle"] < 0 else "Afternoon", }) logger.debug("sun.find_times_for_azimuth - Found %s", results) return results if __name__ == "__main__": # Example usage latitude = 48.85827 longitude = 2.29451 elevation = 52.0 tz = "Europe/Paris" dt_str = "2026-03-24 13:05" dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M") print(f"latitude: {latitude}, longitude: {longitude}, elevation: {elevation}m") print(f"Timezone: {tz}") print(f"Time: {dt}") sun = Sun(latitude=latitude, longitude=longitude, elevation=elevation, timezone=tz) sunrise, sunset = sun.sunrise_sunset(dt) print(f"Sunrise: {sunrise}, Sunset: {sunset}") print("\n Solar position:") pos = sun.solar_position(dt) print(pos) azimuth = pos["azimuth"] print(f"\n Times when sun has azimuth {azimuth}°:") times = sun.find_times_for_azimuth(date=dt, target_azimuth=azimuth, min_elevation=0.0) for t in times: print(t) ================================================ FILE: raspiCamSrv/templates/auth/login.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Log In{% endblock %} {% endblock %} {% block content %}

Log In

{% endblock %} ================================================ FILE: raspiCamSrv/templates/auth/password.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Change Password{% endblock %} {% endblock %} {% block content %}

Change Password

{% endblock %} ================================================ FILE: raspiCamSrv/templates/auth/register.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Register{% endblock %} {% endblock %} {% block content %}

Register

{% endblock %} ================================================ FILE: raspiCamSrv/templates/base.html ================================================ {% block title %}{% endblock %} - raspiCamSrv {% if sc.curMenu == "live" %} {% elif sc.curMenu == "trigger" %} {% else %} {% endif %}
raspiCamSrv {% if sc.canUpdate == True %} {% else %} {% endif %} {{ g.version }}
{% block header %}{% endblock %}
{% if g.user %}
{% if sc.unsavedChanges %} Save changes {% endif %}
{{ g.user['username'] }} | {% if sc.noCamera == False %} {{ sc.activeCameraInfo}} {% else %} No Camera available {% endif %} | {{ g.hostname }}
{% else %}
{{ sc.activeCameraInfo}} | {{ g.hostname }}
{% endif %}
{% if g.user %} {% if sc.noCamera == False %} {% if sc.curMenu == "live" %} Live {% else %} Live {% endif %} {% if sc.curMenu == "config" %} Config {% else %} Config {% endif %} {% endif %} {% if sc.curMenu == "info" %} Info {% else %} Info {% endif %} {% if sc.noCamera == False %} {% if sc.curMenu == "photos" %} Photos {% else %} Photos {% endif %} {% if sc.curMenu == "photoseries" %} Photo Series {% else %} Photo Series {% endif %} {% endif %} {% if sc.curMenu == "trigger" %} Trigger {% else %} Trigger {% endif %} {% if sc.noCamera == False %} {% if sc.curMenu == "webcam" %} Cam {% else %} Cam {% endif %} {% endif %} {% if sc.curMenu == "console" %} Console {% else %} Console {% endif %} {% if sc.curMenu == "settings" %} Settings {% else %} Settings {% endif %} {% if sc.curMenu == "live" %} Log Out {% else %} Log Out {% endif %}
{% if sc.noCamera == False %} {% if sc.supportedCameras|length() > 1 %} {% if sc.isVideoRecording2 %} Recording active {% else %} Recording active {% endif %} {% if sc.isLiveStream2 == True %} {% if sc.isStereoCamActive == True %} Recording active {% else %} Recording active {% endif %} {% else %} Recording active {% endif %} {% endif %} {% if sc.isEventhandling %} {% if sc.isEventsWaiting %} Recording active {% else %} Recording active {% endif %} {% else %} Recording active {% endif %} {% if sc.isTriggerRecording %} {% if sc.isTriggerWaiting %} Recording paused {% elif sc.isTriggerTesting %} Recording testing {% else %} Recording active {% endif %} {% else %} Recording active {% endif %} {% if sc.isPhotoSeriesRecording %} Recording active {% else %} Recording active {% endif %} {% if sc.isAudioRecording %} Recording active {% else %} Recording active {% endif %} {% if sc.isVideoRecording %} Recording active {% else %} Recording active {% endif %} {% if sc.isLiveStream %} {% if sc.isStereoCamActive == True %} Recording active {% else %} Recording active {% endif %} {% else %} Recording active {% endif %} {% else %} {% if sc.isEventhandling %} {% if sc.isEventsWaiting %} Recording active {% else %} Recording active {% endif %} {% else %} Recording active {% endif %} {% endif %}
{% else %}
{% if g.nrUsers == 0 %} {% if sc.curMenu == "register" %} Register {% else %} Register {% endif %} {% endif %} {% if sc.curMenu == "login" %} Log In {% else %} Log In {% endif %} {% if sc.curMenu == "password" %} Change Password {% else %} Change Password {% endif %}
{% endif %}
{% block content %}{% endblock %}
{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %}
================================================ FILE: raspiCamSrv/templates/config/main.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Camera Configurations{% endblock %} {% endblock %} {% block content %}
{% set ignoreLastConfigTab = False %} {% if sc.activeCameraIsUsb == False %} {% if sc.lastConfigTab == "cfgtuning" %} {% else %} {% endif %} {% if sc.activeCameraHasAi == True and sc.useCameraAi == True %} {% if sc.lastConfigTab == "cfgai" %} {% else %} {% endif %} {% else %} {% if sc.lastConfigTab == "cfgai" %} {% set ignoreLastConfigTab = True %} {% endif %} {% endif %} {% else %} {% if sc.lastConfigTab == "cfgai" or sc.lastConfigTab == "cfgtuning" %} {% set ignoreLastConfigTab = True %} {% endif %} {% endif %} {% if (sc.lastConfigTab == "cfglive") or (ignoreLastConfigTab == True) %} {% else %} {% endif %} {% if sc.lastConfigTab == "cfgphoto" %} {% else %} {% endif %} {% if sc.lastConfigTab == "cfgraw" %} {% else %} {% endif %} {% if sc.lastConfigTab == "cfgvideo" %} {% else %} {% endif %}
If checked, Stream Size for all other configurations will be adjusted so that the aspect ratio is identical with the current configuration. {% if sc.activeCameraIsUsb == False %} {% if sc.syncAspectRatio == true %} {% else %} {% endif %} {% else %} {% if sc.syncAspectRatio == true %} {% else %} {% endif %} {% endif %}
Online help from GitHub Online help
{% if sc.lastConfigTab == "cfgtuning" %}
{% else %} {% if sc.lastConfigTab == "cfgai" and sc.activeCameraHasAi == True and sc.useCameraAi == True %}
{% else %} {% if sc.lastConfigTab == "cfglive" or ignoreLastConfigTab == True %}
{% else %} {% if sc.lastConfigTab == "cfgphoto" %}
{% else %} {% if sc.lastConfigTab == "cfgraw" %}
{% else %} {% if sc.lastConfigTab == "cfgvideo" %}
{% else %} {% endblock %} ================================================ FILE: raspiCamSrv/templates/console/console.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Console{% endblock %} {% endblock %} {% block content %}
{% if sc.lastConsoleTab == "versbuttons" %} {% else %} {% endif %} {% if sc.lastConsoleTab == "actionbuttons" %} {% else %} {% endif %}
Online help from GitHub Online help
{% if sc.lastConsoleTab == "versbuttons" %}
{% else %} {% if sc.lastConsoleTab == "actionbuttons" %}
{% else %} {% endblock %} ================================================ FILE: raspiCamSrv/templates/home/index.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Live{% endblock %} {% endblock %} {% block content %}
{% if sc.isLiveStream %} Camera Live View {% else %} {% if sc.isVideoRecording %} {% if sc.recordAudio == False %} Placeholder for video recording {% else %} Placeholder for video recording {% endif %} {% elif sc.isPhotoSeriesRecording %} Placeholder for video recording {% endif %} {% endif %}
{% if sc.lastLiveTab == "focus" %} {% else %} {% endif %} {% if sc.lastLiveTab == "zoom" %} {% else %} {% endif %} {% if sc.lastLiveTab == "autoexposure" %} {% else %} {% endif %} {% if sc.lastLiveTab == "exposure" %} {% else %} {% endif %} {% if sc.lastLiveTab == "image" %} {% else %} {% endif %} {% if sc.lastLiveTab == "control" %} {% else %} {% endif %}
Online help from GitHub Online help
{% if sc.lastLiveTab == "focus" %}
{% else %} {% if sc.lastLiveTab == "zoom" %}
{% else %} {% if sc.lastLiveTab == "autoexposure" %}
{% else %} {% if sc.lastLiveTab == "exposure" %}
{% else %} {% if sc.lastLiveTab == "image" %}
{% else %} {% if sc.lastLiveTab == "control" %}
{% else %}
{% if sc.isDisplayHidden == false and sc.displayPhoto != None %} {% endif %}
{% if sc.isVideoRecording or sc.isPhotoSeriesRecording %}
{% else %}
{% endif %} {% if sc.isVideoRecording or sc.isPhotoSeriesRecording %}
{% else %}
{% endif %} {% if sc.isPhotoSeriesRecording %}
{% else %} {% if sc.isVideoRecording == false %}
{% endif %} {% if sc.isVideoRecording == true %}
{% endif %} {% endif %}

{{ sc.displayBufferIndex }}

{% if sc.isDisplayHidden == true and sc.displayPhoto != None %}
{% endif %} {% if sc.isDisplayHidden == false and sc.displayPhoto != None %}
{% endif %}
{% if sc.displayBufferCount > 0 or sc.displayPhoto != None %}
{% endif %}

{{ sc.displayFile }}

{% if sc.isDisplayBufferIn() == false %}
{% else %}
{% endif %}
{% if (sc.isDisplayBufferIn() == true and sc.isDisplayBufferFirst() == false) or sc.isDisplayBufferIn() == flase %}
{% else %}
{% endif %}
{% if (sc.isDisplayBufferIn() == true and sc.isDisplayBufferLast() == false) or sc.isDisplayBufferIn() == flase %}
{% else %}
{% endif %}
{% if sc.isDisplayHidden == false and sc.displayMeta != None %} {% if sc.displayContent == "meta" %}

Metadata

{% for prop in sc.displayMeta %} {% if loop.index >= sc.displayMetaFirst and loop.index < sc.displayMetaLast and prop !="PispStatsOutput" %} {% if prop|string() == "AfState" %} {% if sc.displayMeta[prop]|string() == "0" %} {% elif sc.displayMeta[prop]|string() == "1" %} {% elif sc.displayMeta[prop]|string() == "2" %} {% elif sc.displayMeta[prop]|string() == "3" %} {% else %} {% endif %} {% elif prop|string() == "AfPauseState" %} {% if sc.displayMeta[prop]|string() == "0" %} {% elif sc.displayMeta[prop]|string() == "1" %} {% elif sc.displayMeta[prop]|string() == "2" %} {% else %} {% endif %} {% elif prop|string() == "LensPosition" %} {% set fdr = (1000.0/sc.displayMeta[prop])|int() %} {% set fd = fdr/1000.0 %} {% elif prop|string() == "ExposureTime" %} {% if sc.displayMeta[prop] < 1000000 %} {% set rt = (1000000/sc.displayMeta[prop])|int() %} {% else %} {% endif %} {% else %} {% endif %} {% endif%} {% endfor %}
Property Value
{% if prop|string() == "Camera" %} Camera by which the photo was taken.
This information is not contained in the metadata provided by Picamera2.
{% endif %} {% if prop|string() == "SensorTimestamp" %} The time this frame was produced by the sensor, measured in nanoseconds since the system booted. The time is sampled on the camera start of frame interrupt, which occurs as the first pixel of the new frame is written out by the sensor. This control appears only in captured image metadata and is read-only. {% endif %} {% if prop|string() == "ColourCorrectionMatrix" %} The 3×3 matrix used within the image signal processor (ISP) to convert the raw camera colours to sRGB. This control appears only in captured image metadata and is read-only. {% endif %} {% if prop|string() == "FocusFoM" %} Reports a Figure of Merit (FoM) to indicate how in-focus the frame is. A larger FocusFoM value indicates a more in-focus frame. This singular value may be based on a combination of statistics gathered from multiple focus regions within an image. The number of focus regions and method of combination is platform dependent. In this respect, it is not necessarily aimed at providing a way to implement a focus algorithm by the application, rather an indication of how in-focus a frame is. {% endif %} {% if prop|string() == "ColourTemperature" %} An estimate of the colour temperature (in Kelvin) of the current image. It is only available in captured image metadata, and is read-only {% endif %} {% if prop|string() == "ColourGains" %} Pair of numbers where the first is the red gain (the gain applied to red pixels by the AWB algorithm) and the second is the blue gain. Setting these numbers disables AWB.
Range: [0.0,32.0]
{% endif %} {% if prop|string() == "AeLocked" %} Report the lock status of a running AE algorithm.
If the AE algorithm is locked the value shall be set to true, if it's converging it shall be set to false. If the AE algorithm is not running the control shall not be present in the metadata control list.
{% endif %} {% if prop|string() == "Lux" %} An estimate of the brightness (in lux) of the scene. It is available only in captured image metadata and is read-only. {% endif %} {% if prop|string() == "FrameDuration" %} The amount of time (in microseconds) since the previous camera frame. This value is only available in captured image metadata and is read-only. To change the camera’s framerate, the "FrameDurationLimits" control should be used. {% endif %} {% if prop|string() == "SensorBlackLevels" %} The black levels of the raw sensor image. This control appears only in captured image metadata and is read-only. One value is reported for each of the four Bayer channels, scaled up as if the full pixel range were 16 bits (so 4096 represents a black level of 16 in 10- bit raw data). {% endif %} {% if prop|string() == "DigitalGain" %} The amount of digital gain applied to an image. Digital gain is used automatically when the sensor’s analogue gain control cannot go high enough, and so this value is only reported in captured image metadata. It cannot be set directly - users should set the AnalogueGain instead and digital gain will be used when needed. {% endif %} {% if prop|string() == "AnalogueGain" %} Analogue gain applied by the sensor. {% endif %} {% if prop|string() == "ScalerCrop" %} The scaler crop rectangle determines which part of the image received from the sensor is cropped and then scaled to produce an output image of the correct size. It can be used to implement digital pan and zoom. The coordinates are always given from within the full sensor resolution. {% endif %} {% if prop|string() == "ExposureTime" %} Exposure time used for the sensor, measured in microseconds.
In brackets for exposure times < 1s: rounded reciprocal value
{% endif %} {% if prop|string() == "AfState" %} Reports the current state of the Autofocus (AF) algorithm in conjunction with the reported AfMode value and (in continuous AF mode) the AfPauseState value.
If the AfMode is set to AfModeManual, then the AfState will always report AfStateIdle (0) (even if the lens is subsequently moved). Changing to the AfModeManual state does not initiate any lens movement.
If the AfMode is set to AfModeAuto then the AfState will report AfStateIdle (0). However, if AfModeAuto and AfTriggerStart are sent together then AfState will omit AfStateIdle (0) and move straight to AfStateScanning (1) (and start a scan).
If the AfMode is set to AfModeContinuous then the AfState will initially report AfStateScanning (1).
{% endif %} {% if prop|string() == "AfPauseState" %} Only applicable in continuous (AfModeContinuous) mode, this reports whether the algorithm is currently running (0), paused (2) or pausing (1) (that is, will pause as soon as any in-progress scan completes).
Any change to AfMode will cause AfPauseStateRunning (0) to be reported.
{% endif %} {% if prop|string() == "SensorTemperature" %} Temperature measure from the camera sensor in Celsius. This is typically obtained by a thermal sensor present on-die or in the camera module. The range of reported temperatures is device dependent.
The SensorTemperature control will only be returned in metadata if a themal sensor is present.
{% endif %} {% if prop|string() == "LensPosition" %} Position of the lens. The units are dioptres (reciprocal of the distance in metres).
In brackets: focal distance in m.
{% endif %} {{ prop|string() }}
{{ sc.displayMeta[prop]|string() }} (Idle){{ sc.displayMeta[prop]|string() }} (Scanning){{ sc.displayMeta[prop]|string() }} (Focused){{ sc.displayMeta[prop]|string() }} (Failed){{ sc.displayMeta[prop]|string() }}{{ sc.displayMeta[prop]|string() }} (Running){{ sc.displayMeta[prop]|string() }} (Pausing){{ sc.displayMeta[prop]|string() }} (Paused){{ sc.displayMeta[prop]|string() }}{{ sc.displayMeta[prop]|string() }} ({{ fd|string() }} m){{ sc.displayMeta[prop]|string() }} (1/{{ rt|string() }} s){{ sc.displayMeta[prop]|string() }}{{ sc.displayMeta[prop]|string() }}
{% else %}
{% endif %}

{% if sc.useHistograms == True %} {% if sc.displayContent == "meta" %}
{% else %}
{% endif %} {% endif %}
{% if sc.displayContent == "meta" %} {% if sc.displayMetaFirst == 0 %}
{% else %}
{% endif %} {% endif %}
{% if sc.displayContent == "meta" %} {% if sc.displayMetaLast < 999 %}
{% else %}
{% endif %} {% endif %}
{% endif %}
{% endblock %} ================================================ FILE: raspiCamSrv/templates/home/liveDirectPanel.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Live - Direct Control{% endblock %} {% endblock %} {% block content %}
Online help from GitHub Online help

 

{% if sc.activeCameraIsUsb == False or "LensPosition" in cc.usbCamControls %} {% if cc.include_lensPosition == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.001 %} {% set max = 999.0 %} {% else %} {% set min = cc.usbCamControls["LensPosition"]["min"] | float %} {% set max = cc.usbCamControls["LensPosition"]["max"] | float %} {% endif %} {% set minVal = ((min/max)**(1.0/3.0))|round(3, "common") %} {% set val = ((cc.focalDistance/max)**(1.0/3.0))|round(3, "common") %} {% endif %} {% endif %}
{{ val }}
{% if sc.activeCameraIsUsb == False or "ExposureTime" in cc.usbCamControls %} {% if cc.include_exposureTime == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 10.0 %} {% set default = 0.0 %} {% else %} {% set min = cc.usbCamControls["ExposureTime"]["min"] | float %} {% set max = cc.usbCamControls["ExposureTime"]["max"] | float %} {% set default = cc.usbCamControls["ExposureTime"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.exposureTimeSec) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "ExposureValue" in cc.usbCamControls %} {% if cc.include_exposureValue == True %} {% if sc.activeCameraIsUsb == False %} {% set min = -8.0 %} {% set max = 8.0 %} {% set default = 0.0 %} {% else %} {% set min = cc.usbCamControls["ExposureValue"]["min"] | float %} {% set max = cc.usbCamControls["ExposureValue"]["max"] | float %} {% set default = cc.usbCamControls["ExposureValue"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.exposureValue) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "AnalogueGain" in cc.usbCamControls %} {% if cc.include_analogueGain == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 1.0 %} {% set max = 99.0 %} {% set default = 1.0 %} {% else %} {% set min = cc.usbCamControls["AnalogueGain"]["min"] | float %} {% set max = cc.usbCamControls["AnalogueGain"]["max"] | float %} {% set default = cc.usbCamControls["AnalogueGain"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.analogueGain) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "ColourGainRed" in cc.usbCamControls %} {% if cc.include_colourGains == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 32.0 %} {% set default = 0.0 %} {% else %} {% set min = cc.usbCamControls["ColourGainRed"]["min"] | float %} {% set max = cc.usbCamControls["ColourGainRed"]["max"] | float %} {% set default = cc.usbCamControls["ColourGainRed"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.colourGains[0]) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "ColourGainBlue" in cc.usbCamControls %} {% if cc.include_colourGains == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 32.0 %} {% set default = 0.0 %} {% else %} {% set min = cc.usbCamControls["ColourGainBlue"]["min"] | float %} {% set max = cc.usbCamControls["ColourGainBlue"]["max"] | float %} {% set default = cc.usbCamControls["ColourGainBlue"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.colourGains[1]) %} {% endif %} {% endif %}
{{ val }}
{{ val }}
{{ val }}
{{ val }}
{{ val }}
Camera Live View
{% if sc.activeCameraIsUsb == False or "Sharpness" in cc.usbCamControls %} {% if cc.include_sharpness == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 32.0 %} {% set default = 1.0 %} {% else %} {% set min = cc.usbCamControls["Sharpness"]["min"] | float %} {% set max = cc.usbCamControls["Sharpness"]["max"] | float %} {% set default = cc.usbCamControls["Sharpness"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.sharpness) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "Contrast" in cc.usbCamControls %} {% if cc.include_contrast == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 32.0 %} {% set default = 1.0 %} {% else %} {% set min = cc.usbCamControls["Contrast"]["min"] | float %} {% set max = cc.usbCamControls["Contrast"]["max"] | float %} {% set default = cc.usbCamControls["Contrast"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.contrast) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "Saturation" in cc.usbCamControls %} {% if cc.include_saturation == True %} {% if sc.activeCameraIsUsb == False %} {% set min = 0.0 %} {% set max = 32.0 %} {% set default = 1.0 %} {% else %} {% set min = cc.usbCamControls["Saturation"]["min"] | float %} {% set max = cc.usbCamControls["Saturation"]["max"] | float %} {% set default = cc.usbCamControls["Saturation"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.saturation) %} {% endif %} {% endif %} {% if sc.activeCameraIsUsb == False or "Brightness" in cc.usbCamControls %} {% if cc.include_brightness == True %} {% if sc.activeCameraIsUsb == False %} {% set min = -1.0 %} {% set max = 1.0 %} {% set default = 0.0 %} {% else %} {% set min = cc.usbCamControls["Brightness"]["min"] | float %} {% set max = cc.usbCamControls["Brightness"]["max"] | float %} {% set default = cc.usbCamControls["Brightness"]["default"] | float %} {% endif %} {% set val = sc.ctrlValToSliderPos(min, max, default, cc.brightness) %} {% endif %} {% endif %}
{{ val }}
{{ val }}
{{ val }}
{{ val }}
{% set max = sc.zoomFactor %}
{{ sc.zoomFactor }}
{% endblock %} ================================================ FILE: raspiCamSrv/templates/images/main.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Photos{% endblock %} {% endblock %} {% block content %}
Online help from GitHub Online help

Camera:  From:  To:  
 
 
 
 
 

{% for entry in sc.pvList %} {% set urlMini=url_for('static', filename=entry['path']) %} {% set urlDetail=url_for('static', filename=entry['detailPath']) %} {% set file=entry['file'] %} {% set name=entry['name'] %} {% set type=entry['type'] %} {% set sel=entry['sel'] %} {% endfor %}
{% if sel == True %} {% else %} {% endif %}
{{ name }}

{{ file }}

{% endblock %} ================================================ FILE: raspiCamSrv/templates/info/info.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Information{% endblock %} {% endblock %} {% block content %}
{% if sc.noCamera == False %} {% if sm|length <= 5 %} {% set btnWidth = 100 // (sm|length) %} {% for mode in sm %} {% endfor %} {% else %} {% for mode in sm %} {% if loop.index <= 4 %} {% endif %} {% endfor %} {% endif %} {% else %} {% if cs|length() > 0 %} {% endif %} {% endif %}
Online help from GitHub {% if sc.NoCamera == True %} {% set noCam = 1 %} {% else %} {% set noCam = 0 %} {% endif %} Online help

Hardware and OS

Model {{sc.raspiModelFull}}
Board Revision {{sc.boardRevision}}
Kernel Version {{sc.kernelVersion}}
Debian Version {{sc.debianVersion}}

Processes

Environment {{sc.environmentInfo}}
Server Process {{sc.startupInfo}}
WSGI Server {{sc.wsgiInfo}}
Process Info {{sc.processInfo}}
FFmpeg Info {{sc.ffmpegProcessInfo}}
raspiCamSrv Start {{sc.serverStartTimeStr}}

Software Stack

Python {{sc.pythonInfo}}
Flask {{sc.flaskInfo}}
libcamera {{sc.libcameraInfo}}
Picamera2 {{sc.picamera2Info}}
OpenCV {{sc.openCvInfo}}
numpy {{sc.numpyInfo}}
matplotlib {{sc.matplotlibInfo}}
flask_jwt_extended {{sc.flask_jwt_extended}}
imx500-all {{sc.imx500Info}}
munkres {{sc.munkresInfo}}
gunicorn {{sc.gunicornInfo}}
{% if sc.noCamera == False %}

Streaming Clients

{% for cli in sc.streamingClients %} {% set ip = cli["ipaddr"] %} {% endfor %} {% if sc.streamingClients|length == 0 %} {% endif %}
IP Address Streams
{{ ip }} {{ sc.streamingClientStreams(ip) }}
None
{% endif %}
{% for mode in sm %} {% endfor %} {% endblock %} ================================================ FILE: raspiCamSrv/templates/media_viewer.html ================================================ {{ filename }}
{% if media_type == "video" %} {% else %} {{ filename }} {% endif %}
================================================ FILE: raspiCamSrv/templates/photoseries/main.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Photo Series{% endblock %} {% endblock %} {% block content %}
{% if sc.lastPhotoSeriesTab == "series" %} {% else %} {% endif %} {% if sc.lastPhotoSeriesTab == "tldetails" %} {% else %} {% endif %} {% if sc.activeCameraIsUsb == False %} {% if sc.lastPhotoSeriesTab == "exposure" %} {% else %} {% endif %} {% if sc.lastPhotoSeriesTab == "focusstack" %} {% else %} {% endif %} {% endif %}
Online help from GitHub Online help
{% if sc.lastPhotoSeriesTab == "series" %}
{% else %} {% if sc.lastPhotoSeriesTab == "tldetails" %}
{% else %} {% if sc.lastPhotoSeriesTab == "exposure" %}
{% else %} {% if sc.lastPhotoSeriesTab == "focusstack" %}
{% else %} {% if sr.status == "ACTIVE" and sr.showPreview == True and sc.isPhotoSeriesRecording %} {% endif %} {% endblock %} ================================================ FILE: raspiCamSrv/templates/settings/main.html ================================================ {% extends 'base.html' %} {% block header %} {% block title %}Server Settings{% endblock %} {% endblock %} {% block content %}
{% if sc.lastSettingsTab == "settingsparams" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingsconfig" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingsusers" %} {% else %} {% set activeuser = g.user %} {% if activeuser["issuperuser"] == 1 %} {% endif %} {% endif %} {% if sc.lastSettingsTab == "settingsapi" %} {% else %} {% if sc.useAPI == True %} {% endif %} {% endif %} {% if sc.lastSettingsTab == "settingsvbuttons" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingsabuttons" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingslbuttons" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingsdevices" %} {% else %} {% endif %} {% if sc.lastSettingsTab == "settingsupdate" %} {% else %} {% endif %}
Online help from GitHub {% if sc.NoCamera == True %} {% set noCam = 1 %} {% else %} {% set noCam = 0 %} {% endif %} Online help
{% if sc.lastSettingsTab == "settingsparams" %}
{% else %} {% if sc.lastSettingsTab == "settingsconfig" %}
{% else %} {% if sc.lastSettingsTab == "settingsusers" %}
{% else %} {% if sc.useAPI == True %} {% if sc.lastSettingsTab == "settingsapi" %}
{% else %} {% endif %} {% if sc.lastSettingsTab == "settingsvbuttons" %}
{% else %}