diff --git a/README.md b/README.md index 06a499d..6150568 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ nodes: - id: camera git: ssh://git@gitea.liria.cl:5001/liria/dora-webcam.git tag: v0.1.0 - build: pip install . + build: uv pip install . path: dora-webcam inputs: tick: dora/timer/millis/33 @@ -26,24 +26,30 @@ nodes: - image ``` -Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own -managed environment. +Run the dataflow with `--uv` (`dora run dataflow.yml --uv`): the build command is +`uv pip install .`, so the node gets its own managed environment and never installs into +an ambient one. -### Runnable dataflows +### Runnable dataflow -Two dataflows live here, run one at a time — dora has no cross-dataflow wiring, so each -declares the camera itself: +[`dataflow.yml`](dataflow.yml) is the camera on its own, and is what CI runs: -Run either with `dora run --uv`: +```bash +dora run dataflow.yml --uv +``` -| File | Contents | -|---|---| -| [`dataflow.yml`](dataflow.yml) | The camera alone | -| [`dataflow_view.yml`](dataflow_view.yml) | The camera into `dora-image-view` | +Nothing consumes the frames, so to actually see them set `PREVIEW`: -`dataflow_view.yml` is the end-to-end check of the pair: it wires this node's `image` -output into the viewer's `image` input, so a window opens showing the webcam. It fetches -the viewer over the network, which is why CI validates and runs `dataflow.yml` only. +```bash +PREVIEW=true dora run dataflow.yml --uv +``` + +A window opens showing the frames as they are published. Press `q` to stop. That is +enough to check a camera, an encoding or a flip without wiring a second node — which is +why there is one dataflow here rather than one per consumer. + +For a real pipeline, wire `image` into a viewer such as `dora-image-view` from your own +dataflow. ## Output @@ -73,6 +79,7 @@ compressed encoding, `width` and `height` are the image's dimensions, not the le | `ENCODING` | `bgr8` | One of `bgr8`, `rgb8`, `yuv420`, `jpeg`/`jpg`/`jpe`, `png`, `bmp`, `webp`. | | `JPEG_QUALITY` | `95` | Quality 0-100, used only by the JPEG encodings. | | `FLIP` | unset | `VERTICAL`, `HORIZONTAL` or `BOTH`. | +| `PREVIEW` | unset | `true` opens a window of the published frames; `q` stops the node. | | `CAPTURE_FRAMES` | `0` | Stop after this many frames. 0 runs until the tick closes. | Resizing only happens when both `IMAGE_WIDTH` and `IMAGE_HEIGHT` are set. @@ -94,8 +101,8 @@ Every dependency ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos so the node installs with no system packages and no compiler. **Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a -V4L2 camera. On a bare Linux image `opencv-python` also needs the libGL shared libraries -(`libgl1`, `libglib2.0-0`), which it links at import time. +V4L2 camera. `PREVIEW` also needs a display. On a bare Linux image `opencv-python` needs +the libGL shared libraries (`libgl1`, `libglib2.0-0`), which it links at import time. ## Test diff --git a/dataflow.yml b/dataflow.yml index 6e0a046..bd947cd 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -6,11 +6,12 @@ strict_types: true nodes: - id: camera path: dora-webcam - build: pip install . + build: uv pip install . inputs: tick: dora/timer/millis/33 outputs: - image - # Nothing consumes the frames here; the point is that the node opens, encodes - # and publishes. dataflow_view.yml is the pair that shows them. + # Nothing consumes the frames: the point is that the node opens, encodes and + # publishes. Set PREVIEW=true to watch them in a local window instead of + # wiring a viewer node. diff --git a/dataflow_view.yml b/dataflow_view.yml deleted file mode 100644 index c499cac..0000000 --- a/dataflow_view.yml +++ /dev/null @@ -1,23 +0,0 @@ -# The camera into the viewer: the end-to-end check of the pair. -# -# Not run by CI. It fetches dora-image-view over the network, and it needs a -# tag to exist in that repository; CI validates and runs dataflow.yml, which -# needs neither. -strict_types: true - -nodes: - - id: camera - path: dora-webcam - build: pip install . - inputs: - tick: dora/timer/millis/33 - outputs: - - image - - - id: view - git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git - tag: v0.1.0 - build: pip install . - path: dora-image-view - inputs: - image: camera/image diff --git a/dora-node.yml b/dora-node.yml index 3feabe1..7819196 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -13,10 +13,10 @@ keywords: [opencv, camera, webcam, video, capture, image, v4l2] runtime: python entrypoint: dora-webcam -# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and -# gives the node its own managed venv under `.dora/python-envs/`, so the -# dependencies it sees at runtime are the ones installed here. -build: pip install . +# Run the dataflow with --uv, which gives the node its own managed venv under +# `.dora/python-envs/`, so what it imports at runtime is what was installed +# here. Without --uv this needs a virtualenv to install into. +build: uv pip install . dora: ">=1.0" inputs: @@ -59,6 +59,12 @@ env: description: Quality 0-100, used only by the JPEG encodings. FLIP: description: Flip the frame. One of VERTICAL, HORIZONTAL or BOTH. Unset means no flip. + PREVIEW: + type: bool + description: >- + Open a local window showing the frames as they are published, so a camera + can be checked without wiring a viewer. Press q to stop the node. Ignored + under CI, where there is no display. CAPTURE_FRAMES: type: int default: 0 @@ -76,7 +82,7 @@ example: | - id: camera git: ssh://git@gitea.liria.cl:5001/liria/dora-webcam.git tag: v0.1.0 - build: pip install . + build: uv pip install . path: dora-webcam inputs: tick: dora/timer/millis/33 diff --git a/dora_webcam/main.py b/dora_webcam/main.py index b2c4f93..6f2b837 100644 --- a/dora_webcam/main.py +++ b/dora_webcam/main.py @@ -1,8 +1,8 @@ """A dora webcam source: captures frames and publishes them as std/media/v1/Image. Each `tick` reads one frame, optionally flips and resizes it, encodes it, and -sends it. The node stops when the tick input closes, or after CAPTURE_FRAMES -frames if that is set. +sends it. The node stops when the tick input closes, after CAPTURE_FRAMES +frames if that is set, or when `q` is pressed in the preview window. """ import argparse @@ -11,7 +11,7 @@ import os import cv2 from dora import Node -from dora_webcam import camera, encode, image +from dora_webcam import camera, encode, image, preview FLIPS = { "VERTICAL": 0, @@ -43,6 +43,8 @@ def main() -> None: # 0 runs until the tick closes; a positive count is what lets CI run the # dataflow to completion instead of killing it on a timeout. limit = _int("CAPTURE_FRAMES") or 0 + # Never under CI: there is no display there, and imshow would fail. + previewing = os.getenv("PREVIEW", "").lower() in ("1", "true") and not ci node = Node(args.name) sent = 0 @@ -66,6 +68,11 @@ def main() -> None: if width and height and (frame.shape[1], frame.shape[0]) != (width, height): frame = cv2.resize(frame, (width, height)) + # After the flip and the resize, so what is on screen is what is + # published rather than what came off the sensor. + if previewing and preview.show(frame): + break + # Taken before encoding: a compressed payload's byte count is not # its pixel dimensions. frame_height, frame_width = frame.shape[:2] @@ -87,6 +94,9 @@ def main() -> None: finally: capture.release() + if previewing: + preview.close() + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( diff --git a/dora_webcam/preview.py b/dora_webcam/preview.py new file mode 100644 index 0000000..4b0558b --- /dev/null +++ b/dora_webcam/preview.py @@ -0,0 +1,36 @@ +"""An optional local preview window. + +A source node showing its own frames overlaps with what a viewer node does, and +that is the point: it means checking that a camera works needs this repository +only, not a second node wired into a dataflow. It is off unless asked for, and +never opens under CI. +""" + +import cv2 +import numpy as np + +WINDOW = "dora-webcam" + + +def show(frame: np.ndarray) -> bool: + """Display one frame and report whether the viewer asked to quit. + + Parameters + ---------- + frame : np.ndarray + The captured BGR frame, before encoding, which is what a person wants + to look at. + + Returns + ------- + bool + True when `q` was pressed, which stops the node. + + """ + cv2.imshow(WINDOW, frame) + return cv2.waitKey(1) & 0xFF == ord("q") + + +def close() -> None: + """Tear the window down, if one was ever opened.""" + cv2.destroyAllWindows()