diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3685aba..4c6e842 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,13 @@ jobs: - name: Run black check run: black --check plugins/ tests/ utils/ + # Scoped to this file on purpose: test_pipelines.py raises on import when + # gst-launch-1.0 is missing, which would fail collection for the whole dir. + - name: Run g2g backend tests + run: | + pip install pytest numpy + pytest tests/test_g2g_backend.py -q + build: needs: lint strategy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916a008..6efccd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,7 @@ For pipeline testing: ```bash gst-inspect-1.0 python # Verify all elements load -gst-launch-1.0 filesrc location=data/people.mp4 num-buffers=5 \ +python pyml-launch.py filesrc location=data/people.mp4 num-buffers=5 \ ! decodebin ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=onnx model-name=yolo11n.onnx device=cpu \ diff --git a/DESIGN_TODO.md b/DESIGN_TODO.md new file mode 100644 index 0000000..b7e693e --- /dev/null +++ b/DESIGN_TODO.md @@ -0,0 +1,53 @@ +# DESIGN_TODO + +A terse catalogue of open tasks only. Gaps on the host side of the glass2glass +Python-element host are tracked in that repo's `DESIGN_TODO.md`, under +"Python-element host", not here. + +## g2g backend coverage + +- **How many README pipelines run under `PYML_BACKEND=g2g` needs measuring.** + Run `tests/test_pipelines.py` under each backend and compare: one that passes + on gst and fails on g2g is a gap, one that fails on both is the environment. + Only the error categories count as gaps. `pipeline error: Hardware(Other)` is + how g2g reports a hosted element raising, so each needs its log in + `tests/logs` read to name the cause. Known so far: `pyml_kafkasink` calls + `Gst.Pad` APIs directly and dies on `Gst.init`, `demo_soccer`'s engine raises + `TypeError: MLEngine.__init__() got an unexpected keyword argument 'device'`, + and `pyml_streammux` is refused with `pyelement: more than one input links + here, but it is not a registered muxer`. The suite wants the GPU for about 20 + minutes per backend, so run one backend at a time on a 6 GB card and leave the + machine otherwise idle, including between backends: a model still resident + from the previous run fails the next one at preroll. + +- **Eleven elements have no per-frame seam, so they cannot run on g2g at all.** + `alert`, `tracker`, `vad`, `clap`, `overlay_counter`, `kafkasink`, + `streammux`, `streamdemux`, `coalescehistory` and `llm_remote` subclass a + GStreamer base directly. `stablediffusion` is hosted but fills in neither + `process_frames` nor `process_payload`. Reparenting a family onto one of those + two seams in `backend/core.py` is what makes its pipelines runnable. + `overlay_counter` inherits `overlay`, which the launcher rewrites to g2g's + native `analyticsoverlay`, so the plain overlay line works regardless. + +- **A hosted element's properties are only checked once its class loads.** The + g2g host takes any name it does not read itself and hands it to the Python + class, which is the only thing that knows the real set, so a typo fails at + pipeline start rather than at parse. `gst-inspect` on `pyelement` lists the + host's own properties and says the rest come from the class. + +## Elements + +- **`WhisperSpeechTTS.do_generate_speech` returns a `(1, n)` array**, which + `soundfile` rejects with `LibsndfileError: Format not recognised`, so the + element emits no audio. `CoquiTTS` returns 1-D and is fine. Pre-existing on + both backends. + +- **`AnomalyEngine._transform` is assigned only in `do_load_model`**, so + `_get_transform` raises `AttributeError` on an engine whose model never + loaded. Pre-existing on both backends. + +- **An engine that fails to load its model keeps running with `model=None`**, + so the first frame raises somewhere further on instead of naming what went + wrong. The README caption line wants `gptqmodel` for its AWQ model; without it + `CaptionQwen` logs the load failure, then dies on `captioning returned None`. + Failing at load time would name the missing package. diff --git a/README.md b/README.md index fa8563c..63e358b 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,13 @@ If using uv, ensure uv uses the **system** Python (not a downloaded one): curl -LsSf https://astral.sh/uv/install.sh | sh uv venv --python /usr/bin/python3 --system-site-packages source .venv/bin/activate -uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 uv sync ``` +Do not pre-install torch from the PyTorch CUDA index here. `uv sync` resolves torch +from `uv.lock` (PyPI), which on Linux already pulls the CUDA wheels, and it will +replace anything installed beforehand. + #### ONNX Runtime For CPU inference: @@ -557,6 +560,46 @@ gst-inspect-1.0 my_detector ## Using GStreamer Python ML Elements +## Running a pipeline + +`pyml-launch` runs every pipeline in this README. Run it from the checkout with +any Python: it re-runs itself under `.venv` if it finds one, so the elements see +torch and the rest. Installing the package also puts a `pyml-launch` on `PATH`. + +```bash +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale \ + ! video/x-raw,width=640,height=480 \ + ! pyml_yolo model-name=yolo11m device=cuda:0 track=True \ + ! pyml_overlay ! videoconvert ! autovideosink +``` + +### Choosing the backend + +The ML elements run under GStreamer or under +[glass2glass](https://gitlab.collabora.com/glass2glass/glass2glass), selected by +`PYML_BACKEND`. It defaults to `gst`, which is why the examples leave it off. +Prefix any of them with `PYML_BACKEND=g2g` to run the same line under +glass2glass instead. + +Under `gst` it runs GStreamer with `GST_PLUGIN_PATH` pointing at this checkout. +Under `g2g` it runs `g2g-launch-py`, rewriting the three things g2g spells +differently: a `pyml_*` element becomes `pyelement` plus the module and class to +host, `pyml_overlay` becomes g2g's native `analyticsoverlay`, and a raw-video +caps filter with no format gains `format=RGBA`. An element it cannot map is an +error, not a silent pass-through. + +`analyticsoverlay` has its own properties (`show-label`, `show-track`, +`show-score`, `show-trail`, `trail-length`, `thickness`, `mask-alpha`), so +`pyml_overlay`'s do not carry over; pass none and set them on the g2g side. + +Build `g2g-launch-py` from a glass2glass checkout and put it on `PATH`, or point +`G2G_LAUNCH` at it: + +```bash +PYO3_PYTHON=$(which python) cargo build --release -p g2g-python --features ml \ + --bin g2g-launch-py +``` + ## Pipelines Below are some sample pipelines for the various elements in this project. @@ -564,7 +607,7 @@ Below are some sample pipelines for the various elements in this project. ### Classification ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_classifier model-name=resnet18 device=cuda ! videoconvert ! autovideosink +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_classifier model-name=resnet18 device=cuda ! videoconvert ! autovideosink ``` @@ -577,7 +620,7 @@ improving steady-state throughput at the cost of a longer first-frame warm-up. #### Classification with torch.compile ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale \ ! video/x-raw,width=640,height=480 \ ! pyml_classifier model-name=resnet18 device=cuda compile=True \ ! videoconvert ! autovideosink @@ -586,7 +629,7 @@ GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoc #### Object detection with torch.compile ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale \ ! video/x-raw,width=640,height=480 \ ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda compile=True \ ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink @@ -608,42 +651,42 @@ ssdlite320_mobilenet_v3_large ##### fasterrcnn -`GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink` +`python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink` ##### fasterrcnn/kafka a) run pipeline from host ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! pyml_kafkasink schema-file=data/pyml_object_detector.json broker=localhost:29092 topic=test-kafkasink-topic +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! pyml_kafkasink schema-file=data/pyml_object_detector.json broker=localhost:29092 topic=test-kafkasink-topic ``` b) run pipeline from docker ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! pyml_kafkasink schema-file=data/pyml_object_detector.json broker=kafka:9092 topic=test-kafkasink-topic +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda batch-size=4 ! pyml_kafkasink schema-file=data/pyml_object_detector.json broker=kafka:9092 topic=test-kafkasink-topic ``` #### maskrcnn ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! pyml_maskrcnn device=cuda batch-size=4 model-name=maskrcnn_resnet50_fpn ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin ! videoconvert ! videoscale ! pyml_maskrcnn device=cuda batch-size=4 model-name=maskrcnn_resnet50_fpn ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink ``` #### yolo with tracking ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! pyml_yolo model-name=yolo11m device=cuda:0 track=True ! pyml_overlay ! videoconvert ! autovideosink +python pyml-launch.py filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! pyml_yolo model-name=yolo11m device=cuda:0 track=True ! pyml_overlay ! videoconvert ! autovideosink ``` ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480,format=RGB ! pyml_streammux name=mux filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480,format=RGB ! mux. mux. ! pyml_yolo model-name=yolo11m device=cuda:0 track=True ! pyml_streamdemux name=demux demux. ! queue ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false demux. ! queue ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false +python pyml-launch.py filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480,format=RGB ! pyml_streammux name=mux filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480,format=RGB ! mux. mux. ! pyml_yolo model-name=yolo11m device=cuda:0 track=True ! pyml_streamdemux name=demux demux. ! queue ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false demux. ! queue ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false ``` ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! demo_soccer model-name=yolo11m device=cuda:0 ! pyml_overlay ! videoconvert ! autovideosink +python pyml-launch.py filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! demo_soccer model-name=yolo11m device=cuda:0 ! pyml_overlay ! videoconvert ! autovideosink ``` @@ -665,7 +708,7 @@ Use `input-format=nchw` because YOLO expects channels-first input, and bounding boxes before handing off to `pyml_overlay`. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=onnx model-name=yolo11m.onnx device=cpu \ @@ -679,7 +722,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ Use `pyml_inference` to test any ONNX model and inspect raw output: ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=onnx model-name=yolo11m.onnx device=cpu \ @@ -701,7 +744,7 @@ This produces `yolo11m_openvino_model/yolo11m.xml` and `yolo11m.bin`. ##### YOLO11m OpenVINO object detection with overlay ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=openvino \ @@ -728,7 +771,7 @@ This produces `yolo11m_saved_model/yolo11m_float32.tflite`. TFLite models expect NHWC input (default), so `input-format` does not need to be set. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=tflite \ @@ -749,7 +792,7 @@ yolo export model=yolo11m.pt format=saved_model ##### YOLO11m TensorFlow object detection with overlay ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=tensorflow \ @@ -767,7 +810,7 @@ Set `engine-name=tinygrad` for lightweight GPU/CPU inference with automatic kern ##### ResNet18 classification with tinygrad on GPU ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_classifier model-name=resnet18 device=cuda engine-name=tinygrad \ @@ -777,7 +820,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ##### tinygrad on CPU ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_classifier model-name=resnet18 device=cpu engine-name=tinygrad \ @@ -792,7 +835,7 @@ models and TorchVision models (auto-compiled via Relay). Set `engine-name=tvm`. ##### TorchVision model compiled with TVM ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_classifier model-name=resnet18 device=cuda engine-name=tvm \ @@ -802,7 +845,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ##### Pre-compiled TVM model (.so) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=tvm model-name=compiled_model.so device=cuda \ @@ -815,7 +858,7 @@ MLX is designed for Apple Silicon (M1/M2/M3/M4). Supports SafeTensors, `.npz` we and mlx-lm text generation. Set `engine-name=mlx`. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_classifier model-name=resnet18 device=gpu engine-name=mlx \ @@ -828,7 +871,7 @@ Meta ExecuTorch runs `.pte` models for on-device inference. Export a model with `torch.export` + ExecuTorch, then set `engine-name=executorch`. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_inference engine-name=executorch model-name=model.pte device=cpu \ @@ -841,7 +884,7 @@ GGUF quantized LLM inference via llama-cpp-python. Set `engine-name=llamacpp` and point to a `.gguf` model file. ``` -gst-launch-1.0 filesrc location=data/prompt_for_llm.txt \ +python pyml-launch.py filesrc location=data/prompt_for_llm.txt \ ! pyml_llm engine-name=llamacpp model-name=model.gguf device=cpu \ ! fakesink ``` @@ -852,7 +895,7 @@ HuggingFace Candle (Rust) inference via Python bindings. Supports SafeTensors mo Set `engine-name=candle`. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_inference engine-name=candle model-name=model.safetensors device=cpu \ @@ -865,7 +908,7 @@ Google JAX with XLA compilation. Supports Flax checkpoints and HuggingFace model Set `engine-name=jax` for JIT-compiled inference on GPU, TPU, or CPU. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=224,height=224" \ ! pyml_classifier model-name=resnet18 device=cpu engine-name=jax \ @@ -881,7 +924,7 @@ Set `engine-name=migraphx` and point to an ONNX model file. Requires ROCm and ##### YOLO11m MiGraphX object detection with overlay ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=migraphx model-name=yolo11m.onnx device=gpu \ @@ -893,7 +936,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ##### MiGraphX on CPU (reference target) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=migraphx model-name=yolo11m.onnx device=cpu \ @@ -910,7 +953,7 @@ Set `engine-name=iree` and point to a pre-compiled `.vmfb` or an `.onnx` model ##### IREE on AMD GPU (ROCm/HIP) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=iree model-name=yolo11m.onnx device=hip \ @@ -920,7 +963,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ##### IREE on Vulkan (any GPU vendor) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=iree model-name=yolo11m.onnx device=vulkan \ @@ -931,7 +974,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ``` # Pre-compile: iree-compile model.mlir --iree-hal-target-device=hip -o model.vmfb -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=iree model-name=model.vmfb device=hip \ @@ -947,7 +990,7 @@ Set `engine-name=ncnn` and point to an NCNN `.param` file (`.bin` must be alongs ##### NCNN on Vulkan GPU ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=ncnn model-name=yolo11m.param device=vulkan \ @@ -957,7 +1000,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ ##### NCNN on CPU ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_inference engine-name=ncnn model-name=yolo11m.param device=cpu \ @@ -970,7 +1013,7 @@ The ONNX engine supports AMD GPUs via ROCm execution providers. Set `device=rocm to use MIGraphXExecutionProvider (preferred) or ROCMExecutionProvider as fallback. ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=onnx model-name=yolo11m.onnx device=rocm \ @@ -984,7 +1027,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ For AMD Ryzen AI laptops with on-chip NPU, set `device=npu`: ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector engine-name=onnx model-name=yolo11m.onnx device=npu \ @@ -1003,7 +1046,7 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or ``` ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda \ @@ -1013,7 +1056,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ With `torch.compile` and Triton for AMD GPU kernel optimization: ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=640" \ ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda compile=True \ @@ -1032,7 +1075,7 @@ yolo11m-pose (best accuracy) #### YOLO pose with skeleton visualization (rendered on frame) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_yolo_pose model-name=yolo11n-pose device=cuda \ @@ -1042,7 +1085,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### YOLO pose with bounding box overlay (metadata only, no in-element rendering) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_yolo_pose model-name=yolo11n-pose device=cuda visualize=false \ @@ -1063,7 +1106,7 @@ Available colormaps: `inferno` (default), `jet`, `viridis`, `plasma`, `magma` #### DepthAnything V2 with inferno colormap ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_depth model-name=depth-anything/Depth-Anything-V2-Small-hf device=cuda \ @@ -1073,7 +1116,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### DepthAnything V2 with jet colormap ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_depth model-name=depth-anything/Depth-Anything-V2-Small-hf device=cuda colormap=jet \ @@ -1083,7 +1126,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### Depth with reduced compute via frame-stride ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_depth model-name=depth-anything/Depth-Anything-V2-Small-hf device=cuda frame-stride=2 \ @@ -1093,7 +1136,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### Depth with original video side-by-side (tee) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! tee name=t \ @@ -1117,7 +1160,7 @@ google/siglip-large-patch16-384 (SigLIP large) #### CLIP with custom labels ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_clip model-name=openai/clip-vit-base-patch32 device=cuda \ @@ -1128,7 +1171,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### SigLIP (better zero-shot accuracy than CLIP) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_clip model-name=google/siglip-base-patch16-224 device=cuda \ @@ -1139,7 +1182,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### CLIP with threshold (only report labels above 20% confidence) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue \ ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_clip model-name=openai/clip-vit-base-patch32 device=cuda \ @@ -1152,13 +1195,13 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### Standalone VAD with metadata (pass-through, speech probability attached to buffers) ``` -GST_DEBUG=4 gst-launch-1.0 pulsesrc ! audio/x-raw,format=S16LE,rate=16000,channels=1 ! pyml_vad threshold=0.7 ! fakesink +python pyml-launch.py pulsesrc ! audio/x-raw,format=S16LE,rate=16000,channels=1 ! pyml_vad threshold=0.7 ! fakesink ``` #### VAD gating before transcription (mute silent audio, reduce Whisper latency) ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! audioresample ! audio/x-raw,format=S16LE,rate=16000,channels=1 ! pyml_vad threshold=0.6 gate=true ! pyml_whispertranscribe device=cuda language=ko ! fakesink +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! audioresample ! audio/x-raw,format=S16LE,rate=16000,channels=1 ! pyml_vad threshold=0.6 gate=true ! pyml_whispertranscribe device=cuda language=ko ! fakesink ``` ### Transcription @@ -1166,39 +1209,39 @@ GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english #### transcription with initial prompt set ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko initial_prompt = "Air Traffic Control은, radar systems를, weather conditions에, flight paths를, communication은, unexpected weather conditions가, continuous training을, dedication과, professionalism" ! fakesink +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko initial_prompt = "Air Traffic Control은, radar systems를, weather conditions에, flight paths를, communication은, unexpected weather conditions가, continuous training을, dedication과, professionalism" ! fakesink ``` #### translation to English ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! fakesink +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! fakesink ``` #### demucs audio separation ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! audioresample ! pyml_demucs device=cuda ! wavenc ! filesink location=separated_vocals.wav +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! audioresample ! pyml_demucs device=cuda ! wavenc ! filesink location=separated_vocals.wav ``` #### coquitts ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_coquitts device=cuda ! audioconvert ! wavenc ! filesink location=output_audio.wav +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_coquitts device=cuda ! audioconvert ! wavenc ! filesink location=output_audio.wav ``` #### whisperspeechtts ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_whisperspeechtts device=cuda ! audioconvert ! wavenc ! filesink location=output_audio.wav +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_whisperspeechtts device=cuda ! audioconvert ! wavenc ! filesink location=output_audio.wav ``` #### mariantranslate ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_mariantranslate device=cuda src=en target=fr ! fakesink +python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whispertranscribe device=cuda language=ko translate=yes ! pyml_mariantranslate device=cuda src=en target=fr ! fakesink ``` Supported src/target languages: @@ -1208,7 +1251,7 @@ https://huggingface.co/models?sort=trending&search=Helsinki #### whisperlive -`GST_DEBUG=4 gst-launch-1.0 filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whisperlive device=cuda language=ko translate=yes llm-model-name="microsoft/phi-2" ! audioconvert ! wavenc ! filesink location=output_audio.wav` +`python pyml-launch.py filesrc location=data/air_traffic_korean_with_english.wav ! decodebin ! audioconvert ! pyml_whisperlive device=cuda language=ko translate=yes llm-model-name="microsoft/phi-2" ! audioconvert ! wavenc ! filesink location=output_audio.wav` ### LLM @@ -1219,46 +1262,41 @@ https://huggingface.co/models?sort=trending&search=Helsinki 3. LLM pipeline (in this case, we use phi-2) -`GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_llm.txt ! pyml_llm device=cuda model-name="microsoft/phi-2" ! fakesink` +`python pyml-launch.py filesrc location=data/prompt_for_llm.txt ! pyml_llm device=cuda model-name="microsoft/phi-2" ! fakesink` -#### Remote LLM (Ollama) +#### Remote LLM -`pyml_llm_remote` sends text to a remote LLM endpoint via HTTP. Works with Ollama, -OpenAI-compatible APIs, or any server that speaks the same protocol. +`pyml_llm_remote` sends text to a remote LLM endpoint via HTTP. The examples use +the OpenAI-compatible `/v1/chat/completions` path, which Ollama, llama.cpp and +vLLM all serve, so the same line works against any of them. The element picks the +request format from the URL: drop `url=` to fall back to its default, Ollama's +native `/api/generate`. -##### Ollama (default endpoint) +##### Basic call ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_llm.txt \ +python pyml-launch.py filesrc location=data/prompt_for_llm.txt \ ! "text/x-raw,format=utf8" \ - ! pyml_llm_remote model-name=llama3 \ + ! pyml_llm_remote url=http://localhost:11434/v1/chat/completions \ + model-name=llama3 \ ! fakesink ``` -##### Ollama with system prompt and custom model +##### With a system prompt and a custom model ``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_llm.txt \ +python pyml-launch.py filesrc location=data/prompt_for_llm.txt \ ! "text/x-raw,format=utf8" \ - ! pyml_llm_remote model-name=qwen3:8b \ + ! pyml_llm_remote url=http://localhost:11434/v1/chat/completions \ + model-name=qwen3:8b \ system-prompt="You are a helpful assistant. Answer concisely." \ temperature=0.5 \ ! fakesink ``` -##### OpenAI-compatible endpoint (e.g. Ollama with /v1 API) - -``` -GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_llm.txt \ - ! "text/x-raw,format=utf8" \ - ! pyml_llm_remote url=http://localhost:11434/v1/chat/completions \ - model-name=llama3 \ - ! fakesink -``` - ### stablediffusion -`GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_stable_diffusion.txt ! pyml_stablediffusion device=cuda ! pngenc ! filesink location=output_image.png` +`python pyml-launch.py filesrc location=data/prompt_for_stable_diffusion.txt ! pyml_stablediffusion device=cuda ! pngenc ! filesink location=output_image.png` #### Caption @@ -1267,7 +1305,7 @@ GST_DEBUG=4 gst-launch-1.0 filesrc location=data/prompt_for_llm.txt \ (should also work with "microsoft/Phi-3.5-vision-instruct" model) ``` -GST_DEBUG=3 gst-launch-1.0 filesrc location=data/soccer_single_camera.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! tee name=t t. ! queue ! textoverlay name=overlay wait-text=false ! videoconvert ! autovideosink t. ! queue leaky=2 max-size-buffers=1 ! videoconvertscale ! video/x-raw,width=240,height=180 ! pyml_caption_qwen device=cuda:0 prompt="In one sentence, describe what you see?" model-name="Qwen/Qwen2.5-VL-3B-Instruct-AWQ" name=cap cap.src ! fakesink async=0 sync=0 cap.text_src ! queue ! coalescehistory history-length=10 ! pyml_llm model-name="Qwen/Qwen3-0.6B" device=cuda system-prompt="You receive the history of what happened in recent times, summarize it nicely with excitement but NEVER mention the specific times. Focus on the most recent events." ! queue ! overlay.text_sink +python pyml-launch.py filesrc location=data/soccer_single_camera.mp4 ! decodebin ! videoconvertscale ! video/x-raw,width=640,height=480 ! tee name=t t. ! queue ! textoverlay name=overlay wait-text=false ! videoconvert ! autovideosink t. ! queue leaky=2 max-size-buffers=1 ! videoconvertscale ! video/x-raw,width=240,height=180 ! pyml_caption_qwen device=cuda:0 prompt="In one sentence, describe what you see?" model-name="Qwen/Qwen2.5-VL-3B-Instruct-AWQ" name=cap cap.src ! fakesink async=0 sync=0 cap.text_src ! queue ! coalescehistory history-length=10 ! pyml_llm model-name="Qwen/Qwen3-0.6B" device=cuda system-prompt="You receive the history of what happened in recent times, summarize it nicely with excitement but NEVER mention the specific times. Focus on the most recent events." ! queue ! overlay.text_sink ``` ### kafkasink @@ -1330,13 +1368,13 @@ docker exec kafka kafka-topics --create --topic test-kafkasink-topic --bootstrap ### non ML -`GST_DEBUG=4 gst-launch-1.0 videotestsrc ! video/x-raw,width=1280,height=720 ! pyml_overlay meta-path=data/sample_metadata.json tracking=true ! videoconvert ! autovideosink` +`python pyml-launch.py videotestsrc ! video/x-raw,width=1280,height=720 ! pyml_overlay meta-path=data/sample_metadata.json tracking=true ! videoconvert ! autovideosink` ### streammux/streamdemux pipeline ``` - GST_DEBUG=4 gst-launch-1.0 videotestsrc pattern=ball ! video/x-raw, width=320, height=240 ! queue ! pyml_streammux name=mux videotestsrc pattern=smpte ! video/x-raw, width=320, height=240 ! queue ! mux.sink_1 videotestsrc pattern=smpte ! video/x-raw, width=320, height=240 ! queue ! mux.sink_2 mux.src ! queue ! pyml_streamdemux name=demux demux.src_0 ! queue ! glimagesink demux.src_1 ! queue ! glimagesink demux.src_2 ! queue ! glimagesink + python pyml-launch.py videotestsrc pattern=ball ! video/x-raw, width=320, height=240 ! queue ! pyml_streammux name=mux videotestsrc pattern=smpte ! video/x-raw, width=320, height=240 ! queue ! mux.sink_1 videotestsrc pattern=smpte ! video/x-raw, width=320, height=240 ! queue ! mux.sink_2 mux.src ! queue ! pyml_streamdemux name=demux demux.src_0 ! queue ! glimagesink demux.src_1 ! queue ! glimagesink demux.src_2 ! queue ! glimagesink ``` ### Segment Anything (SAM) @@ -1346,41 +1384,32 @@ docker exec kafka kafka-topics --create --topic test-kafkasink-topic --bootstrap #### Auto-mask segmentation (segment everything) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_sam model-name=facebook/sam2-hiera-small device=cuda prompt-mode=auto \ + ! pyml_sam model-name=facebook/sam2-hiera-small device=cuda mode=auto \ ! videoconvert ! autovideosink sync=false ``` #### Point-prompt segmentation (segment object at center) ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_sam model-name=facebook/sam2-hiera-small device=cuda \ - prompt-mode=point points="320,240" \ + mode=points max-masks=10 \ ! videoconvert ! autovideosink sync=false ``` ### OCR -`pyml_ocr` performs text detection and recognition using EasyOCR or TrOCR. - -#### EasyOCR text detection (default) - -``` -gst-launch-1.0 filesrc location=data/document.mp4 ! decodebin name=d \ - d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_ocr backend=easyocr languages="en" device=cuda \ - ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false -``` +`pyml_ocr` recognizes text with TrOCR and appends it as a `GST-OCR:` chunk. #### TrOCR recognition ``` -gst-launch-1.0 filesrc location=data/document.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/document.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_ocr backend=trocr model-name=microsoft/trocr-base-printed device=cuda \ + ! pyml_ocr model-name=microsoft/trocr-base-printed device=cuda \ ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false ``` @@ -1391,7 +1420,7 @@ gst-launch-1.0 filesrc location=data/document.mp4 ! decodebin name=d \ #### Face detection only ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_face device=cuda \ ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false @@ -1399,10 +1428,13 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### Face detection + recognition with gallery +`gallery-path` is a directory of your own images, one face per file, named after +the person. Without it the element detects faces but names none. + ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_face device=cuda gallery-path=data/face_gallery/ recognition-threshold=0.6 \ + ! pyml_face device=cuda gallery-path=data/face_gallery/ threshold=0.6 \ ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false ``` @@ -1413,7 +1445,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### RAFT optical flow with color visualization ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_optical_flow model-name=raft-small device=cuda visualize=true \ ! videoconvert ! autovideosink sync=false @@ -1426,18 +1458,18 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### 2x upscale ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=320,height=240" \ - ! pyml_superres device=cuda scale=2 \ + ! pyml_superres device=cuda scale-factor=2 \ ! videoconvert ! autovideosink sync=false ``` #### 4x upscale with tile processing ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=320,height=240" \ - ! pyml_superres device=cuda scale=4 tile-size=256 tile-overlap=32 \ + ! pyml_superres device=cuda scale-factor=4 \ ! videoconvert ! autovideosink sync=false ``` @@ -1448,9 +1480,9 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### SlowFast action recognition ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_action model-name=slowfast_r50 device=cuda clip-length=32 \ + ! pyml_action model-name=slowfast_r50 device=cuda num-frames=32 \ ! videoconvert ! pyml_overlay ! videoconvert ! autovideosink sync=false ``` @@ -1461,9 +1493,9 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### PatchCore anomaly detection ``` -gst-launch-1.0 filesrc location=data/factory.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/factory.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ - ! pyml_anomaly device=cuda coreset-path=data/coreset.pt threshold=0.5 \ + ! pyml_anomaly device=cuda reference-path=data/factory_reference.npy threshold=0.5 \ ! videoconvert ! autovideosink sync=false ``` @@ -1474,7 +1506,7 @@ gst-launch-1.0 filesrc location=data/factory.mp4 ! decodebin name=d \ #### CLAP audio event detection ``` -gst-launch-1.0 filesrc location=data/audio_sample.wav ! decodebin \ +python pyml-launch.py filesrc location=data/audio_sample.wav ! decodebin \ ! audioconvert ! audioresample ! audio/x-raw,format=F32LE,rate=48000,channels=1 \ ! pyml_clap device=cuda labels="gunshot,siren,baby crying,music,speech" threshold=0.3 \ ! fakesink @@ -1487,7 +1519,7 @@ gst-launch-1.0 filesrc location=data/audio_sample.wav ! decodebin \ #### LLaVA visual question answering ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_vlm model-name=llava-hf/llava-1.5-7b-hf device=cuda \ prompt="What is happening in this scene?" \ @@ -1501,20 +1533,20 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### CLIP embedding extraction ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_embedding model-name=openai/clip-vit-base-patch32 device=cuda \ - output-mode=metadata \ + normalize=true \ ! fakesink ``` #### DINOv2 embeddings saved to file ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_embedding model-name=facebook/dinov2-base device=cuda \ - output-mode=file output-path=embeddings.npy \ + frame-stride=5 \ ! fakesink ``` @@ -1525,7 +1557,7 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### YOLO + standalone SORT tracker ``` -gst-launch-1.0 filesrc location=data/soccer_tracking.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/soccer_tracking.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda \ ! pyml_tracker tracker-type=sort max-age=30 min-hits=3 iou-threshold=0.3 \ @@ -1539,7 +1571,7 @@ gst-launch-1.0 filesrc location=data/soccer_tracking.mp4 ! decodebin name=d \ #### Webhook alert on person detection ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_objectdetector model-name=fasterrcnn_resnet50_fpn device=cuda \ ! pyml_alert rules='{"class":"person","min_score":0.8}' \ @@ -1550,11 +1582,10 @@ gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ #### MQTT alert with zone filtering ``` -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale ! "video/x-raw,width=640,height=480" \ ! pyml_yolo model-name=yolo11m device=cuda \ ! pyml_alert rules='{"class":"person","min_score":0.7,"zone":[0,0,320,240]}' \ mqtt-broker=localhost:1883 mqtt-topic=alerts/zone1 cooldown=5 \ ! pyml_overlay ! videoconvert ! autovideosink sync=false ``` -``` \ No newline at end of file diff --git a/data/COLLABORA_02_RGB.png b/data/COLLABORA_02_RGB.png new file mode 100644 index 0000000..46cd1f1 Binary files /dev/null and b/data/COLLABORA_02_RGB.png differ diff --git a/data/Chinedu-Obasi_2684938.jpg b/data/Chinedu-Obasi_2684938.jpg new file mode 100644 index 0000000..f60a259 Binary files /dev/null and b/data/Chinedu-Obasi_2684938.jpg differ diff --git a/data/audio_sample.wav b/data/audio_sample.wav new file mode 100644 index 0000000..1b6407f Binary files /dev/null and b/data/audio_sample.wav differ diff --git a/data/document.mp4 b/data/document.mp4 new file mode 100644 index 0000000..96bfc4b Binary files /dev/null and b/data/document.mp4 differ diff --git a/data/factory.mp4 b/data/factory.mp4 new file mode 100644 index 0000000..0ccbfc2 Binary files /dev/null and b/data/factory.mp4 differ diff --git a/data/factory_reference.npy b/data/factory_reference.npy new file mode 100644 index 0000000..32a1aae Binary files /dev/null and b/data/factory_reference.npy differ diff --git a/demo/football/README.md b/demo/football/README.md new file mode 100644 index 0000000..319b7b3 --- /dev/null +++ b/demo/football/README.md @@ -0,0 +1,53 @@ +# Football demo + +Real-time football broadcast overlay: **detection → tracking → overlay** +(`pyml_yolo`/`pyml_objectdetector` -> `pyml_tracker` -> `pyml_football_overlay`). + +The overlay draws a foot ellipse per player coloured by team (red/blue, voted +from jersey hue), a gold ellipse for referees, motion trails (off by default), +and a focal-player HUD with headshot, ball contacts, and distance travelled. +Players whose team isn't decided yet (and unclassifiable kits, e.g. the +goalkeeper) are left unmarked rather than drawn in a placeholder colour. The +ball is tracked for contact counting but its marker is off by default. + +## Models + +The detector weights (`football.pt`, `football.onnx`, `football_fp16.onnx`, +`football_int8.onnx`) are hosted on the Hugging Face Hub at +`collabora/gst-python-ml-football`, not in git. `run.sh` downloads the one its +`BACKEND` needs into `models/football/` on first use. To fetch by hand: + +```bash +python demo/football/fetch_models.py # pt + fp16 +python demo/football/fetch_models.py all +``` + +## Run + +```bash +# file -> annotated MP4 +demo/football/run.sh +demo/football/run.sh 08fd33_4.mp4 demo/football/out.mp4 1280x720 + +# file -> live on-screen +demo/football/run.sh display +demo/football/run.sh display 08fd33_4.mp4 1280x720 + +# live camera -> on-screen +demo/football/run.sh camera /dev/video0 +``` + +## Environment knobs + +| Var | Default | Meaning | +|------------|---------|---------| +| `BACKEND` | `pt` | `pt` = PyTorch `pyml_yolo`; `fp16` = ONNX FP16 via `pyml_objectdetector` (CUDA). | +| `INTERVAL` | `3` | Run detection every Nth frame; the tracker/overlay still update every frame, so it stays smooth at ~N× less inference cost. The main real-time lever. | + +```bash +BACKEND=fp16 demo/football/run.sh display # faster inference path +INTERVAL=5 demo/football/run.sh display # detect every 5th frame +INTERVAL=1 demo/football/run.sh # detect every frame (max accuracy) +``` + + diff --git a/demo/football/fetch_models.py b/demo/football/fetch_models.py new file mode 100755 index 0000000..b6ad2aa --- /dev/null +++ b/demo/football/fetch_models.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# Football demo model download +# Copyright (C) 2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. +# +# Download the football detector weights from the Hugging Face Hub into +# models/football/ (gitignored). Usage: +# python demo/football/fetch_models.py # pt + fp16, what run.sh uses +# python demo/football/fetch_models.py int8 onnx # named variants +# python demo/football/fetch_models.py all + +import os +import sys + +from huggingface_hub import hf_hub_download + +REPO_ID = "collabora/gst-python-ml-football" +LOCAL_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "models", "football" +) +# run.sh's BACKEND value -> the file it loads +VARIANTS = { + "pt": "football.pt", + "fp16": "football_fp16.onnx", + "onnx": "football.onnx", + "int8": "football_int8.onnx", +} + + +def main(argv): + wanted = argv[1:] or ["pt", "fp16"] + if wanted == ["all"]: + wanted = list(VARIANTS) + for variant in wanted: + if variant not in VARIANTS: + sys.exit( + f"unknown model variant {variant!r}; " + f"choose from {', '.join(VARIANTS)} or all" + ) + local = os.path.join(LOCAL_DIR, VARIANTS[variant]) + if os.path.isfile(local): + print(local) + continue + path = hf_hub_download( + repo_id=REPO_ID, filename=VARIANTS[variant], local_dir=LOCAL_DIR + ) + print(path) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/demo/football/onnx_loop.py b/demo/football/onnx_loop.py new file mode 100644 index 0000000..703c02e --- /dev/null +++ b/demo/football/onnx_loop.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# Run a video through the ONNX (fp16) football pipeline. +# +# detector (onnx) -> pyml_tracker -> pyml_football_overlay +# +# Usage: +# python demo/football/onnx_loop.py INPUT.mp4 # live display, looping +# python demo/football/onnx_loop.py INPUT.mp4 OUTPUT.mp4 # write annotated mp4 +# (self-contained: finds the repo venv + plugins and re-execs into them) +import os +import subprocess +import sys +import glob + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +VENV = os.path.join(REPO, ".venv") +MODEL = os.path.join(REPO, "models/football/football_fp16.onnx") +os.environ["GST_PLUGIN_PATH"] = ( + os.path.join(REPO, "plugins") + os.pathsep + os.environ.get("GST_PLUGIN_PATH", "") +) +if not os.environ.get("_ONNX_LOOP_REEXEC") and os.path.isdir(VENV): + os.environ["VIRTUAL_ENV"] = VENV + os.environ["PATH"] = ( + os.path.join(VENV, "bin") + os.pathsep + os.environ.get("PATH", "") + ) + libs = sorted( + set( + glob.glob( + os.path.join( + VENV, "lib", "python*", "site-packages", "nvidia", "*", "lib" + ) + ) + ) + ) + if libs: + os.environ["LD_LIBRARY_PATH"] = os.pathsep.join( + [*libs, os.environ.get("LD_LIBRARY_PATH", "")] + ) + os.environ["_ONNX_LOOP_REEXEC"] = "1" + pybin = os.path.join(VENV, "bin", "python") + exe = pybin if os.path.exists(pybin) else sys.executable + os.execv(exe, [exe, *sys.argv]) + +if not os.path.isfile(MODEL): + subprocess.check_call( + [ + sys.executable, + os.path.join(REPO, "demo", "football", "fetch_models.py"), + "fp16", + ] + ) + +import gi # noqa: E402 + +gi.require_version("Gst", "1.0") +from gi.repository import Gst, GLib # noqa: E402 + +Gst.init(None) + + +def on_message(bus, message, loop, pipeline, do_loop): + t = message.type + + if t == Gst.MessageType.EOS: + if do_loop: + # Display mode: seek back to the start to loop the clip. + print("Looping...") + if not pipeline.seek_simple( + Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0 + ): + print("Failed to seek back to start", file=sys.stderr) + loop.quit() + else: + # mp4 mode: end of file, the muxer has finalized the file. + loop.quit() + + elif t == Gst.MessageType.ERROR: + err, debug = message.parse_error() + print(f"ERROR: {err}", file=sys.stderr) + if debug: + print(f"DEBUG: {debug}", file=sys.stderr) + loop.quit() + + +def main(): + if len(sys.argv) < 2: + print(f"usage: {sys.argv[0]} INPUT.mp4 [OUTPUT.mp4]", file=sys.stderr) + print( + " no OUTPUT -> live display (looping); OUTPUT -> write annotated mp4", + file=sys.stderr, + ) + sys.exit(1) + video = os.path.abspath(sys.argv[1]) + out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else None + + # Shared detection + overlay chain. Feed the ORIGINAL resolution: + # pyml_objectdetector letterboxes to the model's 640 internally for + # inference and maps boxes back, so the overlay stays full-res. + chain = ( + f"filesrc location={video} ! " + "decodebin ! videoconvert ! video/x-raw,format=RGB ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_objectdetector engine-name=onnx " + f" model-name={MODEL} device=cuda:0 " + " input-format=nchw post-process=anchor_free interval=1 " + " confidence=0.1 nms-iou=0.7 ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_tracker tracker-type=bytetrack new-track-confidence=0.25 ! " + "videoconvert ! video/x-raw,format=RGBA ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_football_overlay class-names=ball,goalkeeper,player,referee " + " team-colors=true trails=false show-ids=false show-labels=false " + " draw-from-detections=true min-confidence=0 merge-iou=0.5 " + " position-smoothing=0.7 highlight-focal=false ! " + ) + if out: + pipeline_description = ( + chain + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "videoconvert ! openh264enc ! h264parse ! mp4mux ! " + f"filesink location={out}" + ) + do_loop = False + else: + # Pre-roll buffer absorbs inference jitter for smooth real-time display. + pipeline_description = ( + chain + "queue max-size-buffers=600 max-size-time=0 max-size-bytes=0 " + " min-threshold-buffers=30 ! " + "videoconvert ! autovideosink sync=true" + ) + do_loop = True + + print(pipeline_description) + print(f"writing -> {out}" if out else "live display (looping)") + + try: + pipeline = Gst.parse_launch(pipeline_description) + except GLib.Error as e: + print(f"Failed to create pipeline: {e}", file=sys.stderr) + sys.exit(1) + + loop = GLib.MainLoop() + + bus = pipeline.get_bus() + bus.add_signal_watch() + bus.connect("message", on_message, loop, pipeline, do_loop) + + pipeline.set_state(Gst.State.PLAYING) + + try: + loop.run() + except KeyboardInterrupt: + if out: + # Finalize the mp4 on Ctrl-C: send EOS and wait for the muxer to + # flush its trailer, otherwise the file is left unplayable. + pipeline.send_event(Gst.Event.new_eos()) + bus.timed_pop_filtered( + 5 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR + ) + finally: + pipeline.set_state(Gst.State.NULL) + if out: + print(f"Done: {out}") + + +if __name__ == "__main__": + main() diff --git a/demo/football/run.sh b/demo/football/run.sh new file mode 100755 index 0000000..272e209 --- /dev/null +++ b/demo/football/run.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Football broadcast-overlay demo. +# +# Ppipeline: +# detector -> pyml_tracker (ByteTrack) -> pyml_football_overlay +# +# Usage: +# demo/football/run.sh [INPUT.mp4] [OUTPUT.mp4] [WxH] # file -> annotated mp4 +# demo/football/run.sh display [INPUT.mp4] [WxH] # file -> live on-screen +# demo/football/run.sh camera [/dev/videoN] [WxH] # live camera -> on-screen +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO" +source .venv/bin/activate +export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" + +BACKEND="${BACKEND:-pt}" +# The weights live on the Hugging Face Hub; this is a no-op once cached. +python demo/football/fetch_models.py "$BACKEND" +INTERVAL="${INTERVAL:-3}" # run detection every Nth frame; tracker/overlay stay per-frame +CONF="${CONF:-0.1}" # detector confidence threshold (low = more detections) +IOU="${IOU:-0.7}" # NMS IoU (ultralytics/football_analyzer default) +NEWTRACK="${NEWTRACK:-0.25}" # min confidence to START a new track (ByteTrack gate; kills ghosts) +DRAWCONF="${DRAWCONF:-0}" # min confidence to DRAW a detection (0 = draw all; raise to trim weak boxes) +MERGE="${MERGE:-0.5}" # collapse overlapping boxes (lower=merge more; 0 disables) so one player=one circle +SMOOTH="${SMOOTH:-0.6}" # temporal EMA on circle positions (0=off, higher=smoother but more lag) +CLASSES="ball,goalkeeper,player,referee" +TRACK="pyml_tracker tracker-type=bytetrack new-track-confidence=$NEWTRACK" +# Detection-based overlay: circles sit on the raw per-frame detections (no +# tracking drift/phantoms/doubles); merge collapses overlaps and +# position-smoothing low-passes the positions. DRAWCONF defaults 0 so no +# detection is hidden; the tracker still runs so the HUD keeps its stats. +OVERLAY="pyml_football_overlay class-names=$CLASSES team-colors=true trails=false show-ids=false show-labels=false draw-from-detections=true min-confidence=$DRAWCONF merge-iou=$MERGE position-smoothing=$SMOOTH highlight-focal=false" + +if [[ "$BACKEND" == "fp16" ]]; then + export LD_LIBRARY_PATH="$(python -c "import os,nvidia,glob;b=os.path.dirname(nvidia.__file__);print(':'.join(sorted(set(glob.glob(b+'/*/lib')))))"):${LD_LIBRARY_PATH:-}" + DETECT="pyml_objectdetector engine-name=onnx model-name=models/football/football_fp16.onnx device=cuda:0 input-format=nchw post-process=anchor_free interval=$INTERVAL" + IN_FMT="RGB"; FORCE_SQUARE=1 +else + DETECT="pyml_yolo model-name=models/football/football device=cuda:0 interval=$INTERVAL confidence=$CONF nms-iou=$IOU" + IN_FMT="RGBA"; FORCE_SQUARE=0 +fi + +POST_DETECT="$TRACK" +[[ "$IN_FMT" == "RGB" ]] && POST_DETECT="$TRACK ! videoconvert ! video/x-raw,format=RGBA" + +# A queue at each stage boundary turns the serial chain into a threaded +# pipeline: while inference runs on frame N, the sink renders N-1 and the +# decoder reads N+1. Nothing is dropped (leaky=no, the default). +Q="queue max-size-buffers=8 max-size-time=0 max-size-bytes=0" +# Pre-roll buffer before the display sink: build a head start of processed +# frames so real-time playback (sync=true) rides out per-frame inference +# jitter without stuttering. Smooths jitter, not a sustained throughput +# deficit -- if inference can't keep up on average, playback just lags +# (still no drops). Lower INTERVAL/raise the head start if it falls behind. +PREROLL="queue max-size-buffers=600 max-size-time=0 max-size-bytes=0 min-threshold-buffers=30" + +# detector -> tracker -> overlay, with a thread boundary at each hop. +CHAIN="$Q ! $DETECT ! $Q ! $POST_DETECT ! $Q ! $OVERLAY" + +MODE="${1:-file}" +if [[ "$MODE" == "camera" ]]; then + DEV="${2:-/dev/video0}"; SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + echo "[$BACKEND] live camera $DEV @ ${W}x${H} -> autovideosink (needs a display)" + exec gst-launch-1.0 -e \ + v4l2src device="$DEV" ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $CHAIN \ + ! $Q ! videoconvert ! autovideosink sync=false +elif [[ "$MODE" == "display" ]]; then + IN="${2:-data/soccer_tracking.mp4}" + SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + [[ -f "$IN" ]] || { echo "input not found: $IN" >&2; exit 1; } + echo "[$BACKEND] '$IN' @ ${W}x${H} -> live display (real-time, sync=true)" + exec gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $CHAIN \ + ! $PREROLL ! videoconvert ! autovideosink sync=true +else + IN="${1:-data/soccer_tracking.mp4}" + OUT="${2:-demo/football/out.mp4}" + SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + [[ -f "$IN" ]] || { echo "input not found: $IN" >&2; exit 1; } + echo "[$BACKEND] '$IN' @ ${W}x${H} -> '$OUT'" + gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $CHAIN \ + ! $Q ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" + echo "Done: $OUT" +fi diff --git a/docs/index.html b/docs/index.html index 73243ff..1866767 100644 --- a/docs/index.html +++ b/docs/index.html @@ -918,12 +918,12 @@
Drop-in GStreamer elements for every ML task. Use them in any gst-launch-1.0 pipeline or Python app.
Drop-in GStreamer elements for every ML task. Use them in any pyml-launch pipeline or Python app.
Real pipelines you can run right now. Every example works with gst-launch-1.0.
Real pipelines you can run right now. Every example works with pyml-launch.
YOLO11 detection with multi-object tracking and overlay.
-gst-launch-1.0 filesrc location=video.mp4 \ +python pyml-launch.py filesrc location=video.mp4 \ ! decodebin ! videoconvertscale \ ! video/x-raw,width=640,height=480 \ ! pyml_yolo model-name=yolo11m \ @@ -1467,7 +1467,7 @@YOLO + Tracking
VisionTorch Compile
Object detection with torch.compile for optimized steady-state throughput.
-gst-launch-1.0 filesrc location=video.mp4 \ +python pyml-launch.py filesrc location=video.mp4 \ ! decodebin ! videoconvert ! videoscale \ ! video/x-raw,width=640,height=480 \ ! pyml_objectdetector \ @@ -1480,7 +1480,7 @@Torch Compile
VisionPose Estimation
YOLO pose with skeleton visualization on frame.
-gst-launch-1.0 filesrc location=video.mp4 \ +python pyml-launch.py filesrc location=video.mp4 \ ! decodebin ! videoconvert \ ! videoscale ! video/x-raw,width=640,height=480 \ ! pyml_yolo_pose model-name=yolo11n-pose \ @@ -1491,7 +1491,7 @@Pose Estimation
VisionDepth Estimation
DepthAnything V2 monocular depth with colormap.
-gst-launch-1.0 filesrc location=video.mp4 \ +python pyml-launch.py filesrc location=video.mp4 \ ! decodebin ! videoconvert \ ! videoscale ! video/x-raw,width=640,height=480 \ ! pyml_depth device=cuda \ @@ -1503,7 +1503,7 @@Depth Estimation
VisionZero-Shot CLIP
Classify video frames with custom text labels.
-gst-launch-1.0 filesrc location=video.mp4 \ +python pyml-launch.py filesrc location=video.mp4 \ ! decodebin ! videoconvert \ ! videoscale ! video/x-raw,width=640,height=480 \ ! pyml_clip device=cuda \ @@ -1516,7 +1516,7 @@Zero-Shot CLIP
AudioTranscribe + Translate
Korean audio to English text transcription.
-gst-launch-1.0 filesrc location=audio.wav \ +python pyml-launch.py filesrc location=audio.wav \ ! decodebin ! audioconvert \ ! pyml_whispertranscribe \ device=cuda language=ko \ @@ -1527,7 +1527,7 @@Transcribe + Translate
AudioVAD + Transcription
Voice activity gating before Whisper for lower latency.
-gst-launch-1.0 filesrc location=audio.wav \ +python pyml-launch.py filesrc location=audio.wav \ ! decodebin ! audioconvert \ ! audioresample \ ! audio/x-raw,format=S16LE,\ @@ -1540,7 +1540,7 @@VAD + Transcription
LanguageLLM Text Generation
Run a HuggingFace LLM as a GStreamer element.
-gst-launch-1.0 filesrc location=prompt.txt \ +python pyml-launch.py filesrc location=prompt.txt \ ! pyml_llm device=cuda \ model-name="microsoft/phi-2" \ ! fakesink@@ -1549,7 +1549,7 @@LLM Text Generation
LanguageStable Diffusion
Text prompt to PNG image generation.
-gst-launch-1.0 filesrc location=prompt.txt \ +python pyml-launch.py filesrc location=prompt.txt \ ! pyml_stablediffusion device=cuda \ ! pngenc \ ! filesink location=output.png@@ -1558,7 +1558,7 @@Stable Diffusion
Multi-StreamMux/Demux Pipeline
Batch two video streams through one model.
-gst-launch-1.0 \ +python pyml-launch.py \ filesrc location=cam1.mp4 ! decodebin \ ! videoconvertscale \ ! video/x-raw,width=640,height=480 \ @@ -1619,7 +1619,7 @@diff --git a/extern/rzv2h/CMakeLists.txt b/extern/rzv2h/CMakeLists.txt new file mode 100644 index 0000000..5f09e2f --- /dev/null +++ b/extern/rzv2h/CMakeLists.txt @@ -0,0 +1,54 @@ +# Build the `drpai_runtime` Python extension for RZ/V2H. +# +# This mirrors the SDK's apps/CMakeLists.txt (same TVM includes, same V2H +# runtime libraries) but produces a Python module instead of an executable. +# It MUST be configured with the SDK cross-toolchain and built inside the +# RZ/V2H DRP-AI TVM SDK Docker. See README.md. +# +# Required env: TVM_ROOT (root of rzv_drp-ai_tvm), SDK (Yocto cross SDK) +# Required -D : PYBIND11_INCLUDE_DIR, PYTHON_INCLUDE_DIR (target aarch64 python) +cmake_minimum_required(VERSION 3.16) +project(drpai_runtime CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT DEFINED ENV{TVM_ROOT}) + message(FATAL_ERROR "TVM_ROOT not set — source the DRP-AI TVM SDK env first") +endif() +set(TVM_ROOT "$ENV{TVM_ROOT}") + +set(DRPAI_APPS "${TVM_ROOT}/apps" CACHE PATH "rzv_drp-ai_tvm/apps directory") +set(PYBIND11_INCLUDE_DIR "" CACHE PATH "pybind11 include directory") +set(PYTHON_INCLUDE_DIR "" CACHE PATH "target python3 include dir") +set(LIBMERA_RT_PATH ${TVM_ROOT}/obj/build_runtime/v2h/lib) + +add_library(drpai_runtime MODULE + drpai_runtime_pybind.cpp + ${DRPAI_APPS}/MeraDrpRuntimeWrapper.cpp +) +set_target_properties(drpai_runtime PROPERTIES PREFIX "" SUFFIX ".so") + +target_include_directories(drpai_runtime PRIVATE + ${DRPAI_APPS} + ${TVM_ROOT}/tvm/include + ${TVM_ROOT}/setup/include + ${TVM_ROOT}/tvm/3rdparty/dlpack/include + ${TVM_ROOT}/tvm/3rdparty/dmlc-core/include + ${TVM_ROOT}/tvm/3rdparty/compiler-rt + ${PYBIND11_INCLUDE_DIR} + ${PYTHON_INCLUDE_DIR} +) + +add_definitions(-DMERA_DRP_RUNTIME) +target_compile_definitions(drpai_runtime PUBLIC KDLDRPAI) +target_link_directories(drpai_runtime PRIVATE ${LIBMERA_RT_PATH}) +target_link_libraries(drpai_runtime PRIVATE + mera2_runtime + mera2_plan_io + drp_tvm_rt + pthread +) +set_target_properties(drpai_runtime PROPERTIES + LINK_FLAGS "-Wl,-rpath,${LIBMERA_RT_PATH} -Wl,-rpath-link,${LIBMERA_RT_PATH}") + +target_compile_options(drpai_runtime PRIVATE -O3 -mtune=cortex-a55 -Wall -fvisibility=hidden) diff --git a/extern/rzv2h/README.md b/extern/rzv2h/README.md new file mode 100644 index 0000000..de8d0bc --- /dev/null +++ b/extern/rzv2h/README.md @@ -0,0 +1,80 @@ +# Object detection on Renesas RZ/V2H (DRP-AI NPU) + +This runs `pyml_objectdetector` on the **RZ/V2H** DRP-AI NPU, using a YOLO11 +model compiled with the **DRP-AI TVM** compiler (powered by EdgeCortix MERA). + +It is the decomposed, metadata-passing pipeline used elsewhere in this repo — +detector -> (tracker) -> overlay, but the detector's inference runs on the NPU: + +``` +... ! pyml_objectdetector engine-name=drpai model-name=Up and Running in Minutes
# Set plugin path and run export GST_PLUGIN_PATH=$PWD/plugins:$GST_PLUGIN_PATH -gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin \ +python pyml-launch.py filesrc location=data/people.mp4 ! decodebin \ ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 \ ! pyml_yolo model-name=yolo11m device=cuda ! pyml_overlay \ ! videoconvert ! autovideosinkdevice=drpai + input-format=nchw post-process=anchor_free + ! pyml_tracker ! pyml_overlay ! ... +``` +## Prerequisites + +- RZ/V2H EVK with the **RZ/V2H AI SDK v6.00** Yocto image (provides the DRP-AI + driver, `/dev/drpai0`, GStreamer, and Python 3). +- The **DRP-AI TVM** package (`rzv_drp-ai_tvm`) and its SDK Docker, with the + environment sourced so `TVM_ROOT`, `SDK` (cross SDK), and the DRP-AI + translator are set. (`PRODUCT=V2H`.) +- `pybind11` headers available to the cross build. + +## 1 — Convert the model (in the SDK Docker) + +```bash +./convert_yolo11_v2h.sh yolo11m 640 +``` + +This exports YOLO11->ONNX (input node `images`, `1x3x640x640`) and runs the V2H +DRP-AI TVM compiler. See the script for the exact commands. + +## 2 — Build the Python binding (in the SDK Docker) + +Source the SDK env first (so `TVM_ROOT`/`SDK` are set and CXX is the aarch64 +cross compiler), then: + +```bash +cd rzv2h +cmake -B build \ + -DCMAKE_TOOLCHAIN_FILE="$TVM_ROOT/apps/toolchain/runtime.cmake" \ + -DPYBIND11_INCLUDE_DIR="$(python3 -m pybind11 --includes | sed 's/-I//;q')" \ + -DPYTHON_INCLUDE_DIR="$SDK/sysroots/aarch64-poky-linux/usr/include/python3.12" +cmake --build build -j +``` + +Adjust `python3.12` to the AI SDK image's Python version, and point +`PYBIND11_INCLUDE_DIR` at a real pybind11 headers dir if the one-liner doesn't +resolve in the container. + +## 3 — Deploy to the board + +Copy onto the RZ/V2H (e.g. under `/home/weston`): + +- this repo's `plugins/` (the gst-python-ml elements), +- `build/drpai_runtime.so`, +- the compiled `yolo11m_drpai_v2h/` deploy dir, +- a COCO label file if you overlay class names. + +```bash +export GST_PLUGIN_PATH=/home/weston/gst-python-ml/plugins:$GST_PLUGIN_PATH +export PYTHONPATH=/home/weston/rzv2h/build:$PYTHONPATH +gst-inspect-1.0 pyml_objectdetector +``` + +## 4 — Run on the board + +File -> annotated file (run as a user that can open `/dev/drpai0`, often root): + +```bash +gst-launch-1.0 filesrc location=clip.mp4 ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name=yolo11m_drpai_v2h device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker tracker-type=bytetrack \ + ! videoconvert ! "video/x-raw,format=RGBA" ! pyml_overlay \ + ! videoconvert ! autovideosink +``` + +Live camera (MIPI/USB): swap `filesrc ! decodebin` for the camera source +(`v4l2src` / the EVK's ISP source), keeping the `640x640` caps into the detector. diff --git a/extern/rzv2h/convert_yolo11_v2h.sh b/extern/rzv2h/convert_yolo11_v2h.sh new file mode 100755 index 0000000..06d8412 --- /dev/null +++ b/extern/rzv2h/convert_yolo11_v2h.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Compile YOLO11 (ONNX) -> RZ/V2H DRP-AI (INT8) deploy dir, using the REAL +# mera2 + DRP-AI Translator i8 + DRP-AI Quantizer flow. +# +# RUN INSIDE the drpai-tvm-v2h container (built via rzv2h/sdk_eval/build_image.sh), +# with this repo mounted at /work. RZ/V2H uses the DRP-AI INT8 accelerator, so +# quantization is MANDATORY and calibration images are required — this is why +# the plain FP compile_onnx_model.py does NOT work for V2H. +# +# Usage (inside container): +# ./rzv2h/convert_yolo11_v2h.sh [MODEL.onnx] [OUT_DIR] [CALIB_DIR] [IMGSZ] +# Defaults assume the repo is at /work and the ONNX is exported already +# (e.g. `yolo export model=models/yolo11m/yolo11m.pt format=onnx imgsz=640` on a +# host with ultralytics — the container has no ultralytics). +set -euo pipefail + +ONNX="${1:-/work/models/yolo11m/yolo11m.onnx}" +OUT="${2:-/work/rzv2h/yolo11m_drpai_v2h}" +CALIB="${3:-/work/rzv2h/calib}" +IMGSZ="${4:-640}" + +: "${TVM_ROOT:?run inside the drpai-tvm-v2h container (TVM_ROOT unset)}" +export PRODUCT=V2H +export SDK="$(find /opt/ -name sysroots -type d | head -1)/../" +export TRANSLATOR="$(find /opt/ -name python_api -type d | head -1)/../../" +: "${QUANTIZER:?QUANTIZER env not set (expected from the image)}" +export PATH="$TVM_ROOT/tutorials:$PATH" # so run_drp_compiler.sh resolves +chmod +x "$TVM_ROOT"/tutorials/*.sh 2>/dev/null || true # SDK ships them non-+x + +[[ -f "$ONNX" ]] || { echo "ONNX not found: $ONNX (export it first)"; exit 1; } +[[ -d "$CALIB" ]] || { echo "calibration image dir not found: $CALIB"; exit 1; } + +# The stock quant script preprocesses calibration images as ImageNet (224 + +# mean/std) — wrong for YOLO (needs IMGSZ, /255, RGB, CHW). Patch that one line. +python3 - "$TVM_ROOT/tutorials/compile_onnx_model_quant.py" "$IMGSZ" <<'PYEOF' +import sys +p, sz = sys.argv[1], int(sys.argv[2]) +s = open(p).read() +old = "input_data = pre_process_imagenet_pytorch(image, mean, stdev, need_transpose=True)" +new = ("input_data = (cv2.resize(image,(%d,%d))[:,:,::-1]" + ".astype('float32')/255.0).transpose(2,0,1)" % (sz, sz)) +if old in s: + open(p, "w").write(s.replace(old, new)); print("[patch] calibration preprocessing ->", sz) +else: + print("[patch] calibration line already patched / not found") +PYEOF + +rm -rf "$OUT" +cd "$TVM_ROOT/tutorials" +python3 compile_onnx_model_quant.py "$ONNX" \ + -o "$OUT" -i images -s "1,3,${IMGSZ},${IMGSZ}" \ + -t "$SDK" -d "$TRANSLATOR" -c "$QUANTIZER" --images "$CALIB" + +echo +echo "Done. RZ/V2H DRP-AI (INT8) deploy dir: $OUT" +echo " sub_0000__CPU_DRP_TVM/{deploy.so,deploy.json,deploy.params} (aarch64 + DRP-AI)" +echo " preprocess/ (DRP-AI pre-processing runtime objects)" +echo "Copy $OUT to the board; load sub_0000__CPU_DRP_TVM with the MERA runtime." diff --git a/extern/rzv2h/drpai_runtime_pybind.cpp b/extern/rzv2h/drpai_runtime_pybind.cpp new file mode 100644 index 0000000..5d2d0b9 --- /dev/null +++ b/extern/rzv2h/drpai_runtime_pybind.cpp @@ -0,0 +1,149 @@ +// drpai_runtime_pybind.cpp +// Copyright (C) 2024-2026 Collabora Ltd. — LGPL (see COPYING). +// +// pybind11 binding around the Renesas DRP-AI TVM runtime +// (MeraDrpRuntimeWrapper, powered by EdgeCortix MERA(TM)) for RZ/V2H. +// +// Exposes a minimal `drpai_runtime.Runtime` class to Python so the pure-Python +// `drpai_engine.py` can drive the DRP-AI NPU: +// +// import drpai_runtime +// rt = drpai_runtime.Runtime() +// rt.load("/path/to/deploy_dir") # deploy.so/json/params +// rt.set_input(0, nchw_float32_numpy) +// rt.run() +// out0 = rt.get_output(0) # numpy (float32, fp16 upcast) +// +// Build with CMake against the board's DRP-AI TVM runtime — see CMakeLists.txt +// and README.md. This compiles only inside the RZ/V2H DRP-AI TVM SDK and runs +// only on the board (it talks to /dev/drpai0). + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "MeraDrpRuntimeWrapper.h" + +namespace py = pybind11; + +static float fp16_to_fp32(uint16_t h) { + uint32_t sign = static_cast (h & 0x8000) << 16; + uint32_t exp = (h >> 10) & 0x1F; + uint32_t mant = h & 0x3FF; + uint32_t f; + if (exp == 0) { + if (mant == 0) { + f = sign; + } else { + exp = 127 - 15 + 1; + while ((mant & 0x400) == 0) { + mant <<= 1; + exp--; + } + mant &= 0x3FF; + f = sign | (exp << 23) | (mant << 13); + } + } else if (exp == 0x1F) { + f = sign | 0x7F800000 | (mant << 13); // Inf / NaN + } else { + f = sign | ((exp - 15 + 127) << 23) | (mant << 13); + } + float out; + std::memcpy(&out, &f, sizeof(out)); + return out; +} + +static uint64_t get_drpai_start_addr() { + int fd = open("/dev/drpai0", O_RDWR); + if (fd < 0) { + throw std::runtime_error("Failed to open /dev/drpai0 (run on the board, as root?)"); + } + drpai_data_t drpai_data; + int ret = ioctl(fd, DRPAI_GET_DRPAI_AREA, &drpai_data); + close(fd); + if (ret == -1) { + throw std::runtime_error("ioctl(DRPAI_GET_DRPAI_AREA) failed"); + } + return drpai_data.address; +} + +class Runtime { + public: + Runtime() : rt_() {} + + bool load(const std::string& model_dir) { + model_dir_ = model_dir; + return rt_.LoadModel(model_dir, get_drpai_start_addr()); + } + + void set_input(int index, + py::array_t data) { + rt_.SetInput(index, static_cast (data.data())); + } + + void run() { rt_.Run(); } + + int num_input() { return rt_.GetNumInput(model_dir_); } + int num_output() { return rt_.GetNumOutput(); } + + py::array get_output(int index) { + auto out = rt_.GetOutput(index); + InOutDataType dtype = std::get<0>(out); + const void* ptr = std::get<1>(out); + int64_t size = std::get<2>(out); + + switch (dtype) { + case InOutDataType::FLOAT16: { + const uint16_t* src = reinterpret_cast (ptr); + py::array_t result(size); + float* dst = static_cast (result.request().ptr); + for (int64_t i = 0; i < size; ++i) dst[i] = fp16_to_fp32(src[i]); + return result; + } + case InOutDataType::FLOAT32: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(float)); + return result; + } + case InOutDataType::INT32: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(int32_t)); + return result; + } + case InOutDataType::INT64: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(int64_t)); + return result; + } + default: + throw std::runtime_error("Unsupported DRP-AI output data type"); + } + } + + private: + MeraDrpRuntimeWrapper rt_; + std::string model_dir_; +}; + +PYBIND11_MODULE(drpai_runtime, m) { + m.doc() = "pybind11 binding for the Renesas DRP-AI TVM runtime (RZ/V2H)"; + py::class_ (m, "Runtime") + .def(py::init<>()) + .def("load", &Runtime::load, py::arg("model_dir"), + "Load a DRP-AI TVM deploy directory (deploy.so/json/params).") + .def("set_input", &Runtime::set_input, py::arg("index"), py::arg("data")) + .def("run", &Runtime::run) + .def("num_input", &Runtime::num_input) + .def("num_output", &Runtime::num_output) + .def("get_output", &Runtime::get_output, py::arg("index")); +} diff --git a/extern/rzv2h/emulation/drpai_runtime.py b/extern/rzv2h/emulation/drpai_runtime.py new file mode 100644 index 0000000..6fdbe25 --- /dev/null +++ b/extern/rzv2h/emulation/drpai_runtime.py @@ -0,0 +1,123 @@ +# drpai_runtime.py — off-board stand-in for the native pybind `drpai_runtime`. +# Copyright (C) 2024-2026 Collabora Ltd. — LGPL (see COPYING). +# +# Same interface as the C++ binding (Runtime.load / set_input / run / +# num_output / get_output), with two backends auto-selected by what's in the +# model directory and what's importable: +# +# 1. MERA / TVM graph_executor — if the dir has deploy.so/json/params AND a +# `tvm` runtime is importable (i.e. inside the Renesas DRP-AI TVM SDK +# container, or on the board). This runs the REAL MERA/TVM runtime — the +# faithful "test through the TVM runtime". On the board the deploy.so runs +# on the DRP-AI NPU / Arm CPU; in the SDK container it runs on whatever the +# module was compiled for (aarch64 needs QEMU; an x86-target build runs +# natively for functional check). +# +# 2. ONNX Runtime (CPU) — fallback look-alike for plain x86 dev boxes +# with no SDK: runs the same yolo11m.onnx that feeds the DRP-AI compiler so +# the engine's preprocess/reshape/decode path is exercised. Validates our +# code, NOT the DRP-AI/MERA runtime. +# +# get_output() always returns a FLAT array, matching the C++ GetOutput buffer, +# so the engine's reshape-to-(1, 4+nc, anchors) path is genuinely tested. + +import glob +import os + +import numpy as np + + +class Runtime: + def __init__(self): + self._backend = None + # tvm backend + self._mod = None + self._dev = None + self._input_name = os.getenv("DRPAI_INPUT_NAME", "images") + # onnx backend + self._sess = None + self._ort_input = None + self._feed = None + self._outputs = None + + def load(self, model_dir): + deploy_so = os.path.join(model_dir, "deploy.so") + if os.path.isfile(deploy_so) and self._try_load_tvm(model_dir, deploy_so): + return True + return self._try_load_onnx(model_dir) + + # ---- backend 1: real MERA / TVM graph_executor ---- + def _try_load_tvm(self, model_dir, deploy_so): + try: + import tvm + from tvm.contrib import graph_executor + except ImportError: + return False + try: + lib = tvm.runtime.load_module(deploy_so) + with open(os.path.join(model_dir, "deploy.json")) as f: + graph = f.read() + self._dev = tvm.cpu(0) + self._mod = graph_executor.create(graph, lib, self._dev) + with open(os.path.join(model_dir, "deploy.params"), "rb") as f: + self._mod.load_params(bytearray(f.read())) + self._backend = "tvm" + print( + f"[drpai_runtime] MERA/TVM graph_executor backend " + f"(deploy.so, input='{self._input_name}') — real runtime" + ) + return True + except Exception as e: + print(f"[drpai_runtime] TVM backend load failed ({e}); trying ONNX") + return False + + # ---- backend 2: ONNX Runtime look-alike ---- + def _try_load_onnx(self, model_dir): + try: + import onnxruntime as ort + except ImportError: + print("[drpai_runtime] no TVM and no onnxruntime — cannot load") + return False + onnx_files = sorted(glob.glob(os.path.join(model_dir, "*.onnx"))) + if not onnx_files: + print(f"[drpai_runtime] no deploy.so and no .onnx in {model_dir!r}") + return False + self._sess = ort.InferenceSession( + onnx_files[0], providers=["CPUExecutionProvider"] + ) + self._ort_input = self._sess.get_inputs()[0].name + self._backend = "onnx" + print( + f"[drpai_runtime] ONNX Runtime EMULATION backend ({onnx_files[0]}, " + f"input='{self._ort_input}') — NOT the NPU/MERA runtime" + ) + return True + + def set_input(self, index, data): + arr = np.ascontiguousarray(data, dtype=np.float32) + if self._backend == "tvm": + import tvm + + self._mod.set_input(self._input_name, tvm.nd.array(arr, self._dev)) + else: + self._feed = arr + + def run(self): + if self._backend == "tvm": + self._mod.run() + else: + self._outputs = self._sess.run(None, {self._ort_input: self._feed}) + + def num_input(self): + return 1 + + def num_output(self): + if self._backend == "tvm": + return self._mod.get_num_outputs() + return len(self._outputs) if self._outputs is not None else 0 + + def get_output(self, index): + # Flat buffer, like the C++ GetOutput; the engine reshapes it. + if self._backend == "tvm": + return self._mod.get_output(index).numpy().reshape(-1).astype(np.float32) + return np.asarray(self._outputs[index], dtype=np.float32).reshape(-1) diff --git a/extern/rzv2h/emulation/run_emulated.sh b/extern/rzv2h/emulation/run_emulated.sh new file mode 100755 index 0000000..da44de2 --- /dev/null +++ b/extern/rzv2h/emulation/run_emulated.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the DRP-AI object-detection pipeline on the DEV BOX using the emulated +# drpai_runtime (CPU/ONNX Runtime stand-in) — same engine code as the board, +# but no NPU. For validating the integration before deploying to RZ/V2H. +# +# Usage: ./run_emulated.sh [INPUT.mp4] [OUTPUT.mp4] +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../.." && pwd)" +cd "$REPO" + +source .venv/bin/activate +export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" +export PYTHONPATH="$HERE:${PYTHONPATH:-}" # resolves `import drpai_runtime` to the fake + +IN="${1:-08fd33_4.mp4}" +OUT="${2:-${IN%.*}_drpai_emu.mp4}" +DEPLOY="$HERE/yolo11m_drpai_v2h_emu" # dir containing yolo11m.onnx + +if [[ ! -f "$DEPLOY/yolo11m.onnx" ]]; then + echo "Missing $DEPLOY/yolo11m.onnx — export it first:" >&2 + echo " yolo export model=yolo11m.pt format=onnx imgsz=640 opset=12 simplify=True" >&2 + echo " mkdir -p $DEPLOY && cp yolo11m.onnx $DEPLOY/" >&2 + exit 1 +fi + +echo "EMULATED DRP-AI run: '$IN' -> '$OUT' (CPU/ONNX, not the NPU)" +gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name="$DEPLOY" device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker tracker-type=bytetrack \ + ! videoconvert ! "video/x-raw,format=RGBA" \ + ! pyml_football_overlay show-ids=false show-labels=false \ + ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" +echo "Done: $OUT" diff --git a/extern/rzv2h/sdk_eval/README.md b/extern/rzv2h/sdk_eval/README.md new file mode 100644 index 0000000..13fa30f --- /dev/null +++ b/extern/rzv2h/sdk_eval/README.md @@ -0,0 +1,113 @@ +# Faithful DRP-AI TVM eval (real mera2 / MERA runtime) + +This is the most faithful test short of running on hardware: the **real** +`mera2` compile and the **real** MERA/TVM runtime, instead of the ONNX-RT +look-alike in [../emulation](../emulation). It composes with the same +`engine-name=drpai` + `drpai_runtime` shim we use everywhere else. + +## Read this first — what's gated, and the aarch64 catch + +Two things make this unable to run on a plain x86 box out of the box: + +1. **License-gated downloads (Renesas account required).** The stack build needs + the **DRP-AI Translator i8** and the **RZ/V2H AI SDK** (`RTK0EF0180F06000SJ.zip`). + There is **no public prebuilt image**; you download these and build Renesas' + `Dockerfile`. I cannot fetch them for you. +2. **The compile targets aarch64, not x86.** Even `compile_cpu_only_onnx_model.py` + uses `target = "llvm ... -mtriple=aarch64-linux-gnu"` and the SDK's aarch64 + cross-g++. So `deploy.so` runs on the board's Arm CPU / NPU — to execute it + off-board you either run on the **board**, under **QEMU-aarch64**, or compile + with an **x86 `llvm` target** for a pure functional check (see below). + +If you don't have the downloads, the ONNX-RT emulation in `../emulation` +already validates all of *our* code (engine preprocess/reshape/decode + +pipeline). What's left to validate here is mera2-compile success and runtime +numerics — both inherently need Renesas assets or hardware. + +## Steps + +### 1. Build the SDK image (host, needs the two downloads) + +```bash +mkdir -p rzv2h/sdk_eval/assets +# put both Renesas downloads in rzv2h/sdk_eval/assets/ : +# DRP-AI_Translator_i8-*-Linux-x86_64-Install and RTK0EF0180F06000SJ.zip +cd rzv2h/sdk_eval && ./build_image.sh +``` + +`build_image.sh` fetches the repo `Dockerfile`, assembles a clean build context +(Dockerfile + the toolchain `.sh` it unzips from the AI SDK zip + the Translator +installer), and runs `docker build --build-arg PRODUCT=V2H -t drpai-tvm-v2h`. +The Dockerfile (`FROM ubuntu:22.04`) defaults `PRODUCT=V2H` and builds the TVM +fork itself, so the build takes a while. + +To fetch just the Dockerfile by hand: +`wget https://raw.githubusercontent.com/renesas-rz/rzv_drp-ai_tvm/main/Dockerfile` + +### 2. Compile YOLO11 with the real mera2 (inside the container) + +```bash +docker run -it --rm -v "$PWD":/workspace/gst-python-ml drpai-tvm-v2h bash +# inside: +cd /workspace/gst-python-ml +./rzv2h/convert_yolo11_v2h.sh yolo11m 640 # real mera2.from_onnx + mera2.drp.build +# -> yolo11m_drpai_v2h/{deploy.so,deploy.json,deploy.params} (aarch64) +``` + +For a **host x86 functional check** instead of the board artifact, compile with a +native target (edit a copy of `tutorials/compile_onnx_model.py` to +`target = "llvm"` and drop the aarch64 cross-compiler), producing an x86 +`deploy.so` the MERA/TVM `graph_executor` can run natively. + +### 3. Run through the real MERA/TVM runtime + +The [../emulation/drpai_runtime.py](../emulation/drpai_runtime.py) shim +auto-selects the **MERA/TVM `graph_executor`** backend as soon as the model dir +has `deploy.so/json/params` and `tvm` is importable (true inside this +container). The engine code is unchanged. + +```bash +export GST_PLUGIN_PATH=/workspace/gst-python-ml/plugins:$GST_PLUGIN_PATH +export PYTHONPATH=/workspace/gst-python-ml/rzv2h/emulation:$PYTHONPATH +# (x86 deploy.so) run natively; (aarch64 deploy.so) run under qemu-aarch64 +gst-launch-1.0 filesrc location=08fd33_4.mp4 ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name=yolo11m_drpai_v2h device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker ! videoconvert ! "video/x-raw,format=RGBA" \ + ! pyml_football_overlay ! videoconvert ! autovideosink +``` + +The shim prints which backend it picked: +`[drpai_runtime] MERA/TVM graph_executor backend ... — real runtime`. + +## On the actual board + +Two ways to run the same pipeline on the RZ/V2H: + +- **Python graph_executor** — copy the `deploy.so/json/params` + the emulation + shim; if the board image has the MERA/TVM python runtime, it Just Works (the + shim's TVM backend), NPU included. +- **C++ pybind binding** — build [../drpai_runtime_pybind.cpp](../drpai_runtime_pybind.cpp) + per [../README.md](../README.md); the native `drpai_runtime.so` takes + precedence over this shim on `PYTHONPATH`. + +## Verified results (RZ/V2H AI SDK v6.00 + DRP-AI Translator i8 v1.11) + +Both paths were run end-to-end driving the `drpai-tvm-v2h` image on an x86 host: + +- **x86 MERA/TVM runtime test** — `compile_x86_cpu.py` compiled YOLO11 via the + MERA-fork TVM (native `llvm`), and `x86_runtime_check.py` ran it through the + real `graph_executor`: output matched ONNX to **max|Δ| = 6.2e-3**, **22 = 22 + detections** (label `person`). Confirms compile + MERA/TVM runtime + the + `drpai_runtime` shim + our decoder, no NPU needed. +- **Real INT8 NPU compile** — `../convert_yolo11_v2h.sh` (quantized flow) + produced the RZ/V2H deploy dir: `[Finish DRP-AI Translator for V2H]`, + `sub_0000__CPU_DRP_TVM/{deploy.so (65 MB),deploy.json,deploy.params}` + + `preprocess/` (DRP-AI pre-processing objects). aarch64 — runs on the board. + +SDK gotchas the scripts now handle automatically: `run_drp_compiler.sh` ships +non-executable and off-PATH (`chmod +x` + add tutorials to PATH); the quant +script preprocesses calibration as ImageNet-224 instead of 640 (patched). And +V2H **requires** the INT8 quantized flow — the plain FP `compile_onnx_model.py` +drives a legacy translator path the i8 v1.11 layout lacks. diff --git a/extern/rzv2h/sdk_eval/_probe_sysroot.sh b/extern/rzv2h/sdk_eval/_probe_sysroot.sh new file mode 100644 index 0000000..0344994 --- /dev/null +++ b/extern/rzv2h/sdk_eval/_probe_sysroot.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Probe the RZ/V2H board rootfs (via the cross-SDK aarch64 sysroot) for the +# GStreamer + Python stack our pipeline needs. Run inside drpai-tvm-v2h. +# Target rootfs sysroot (NOT the x86_64-pokysdk-linux cross-compiler dir). +SR=$(ls -d /opt/*/*/sysroots/*-poky-linux 2>/dev/null | grep -v pokysdk | head -1) +[ -d "$SR" ] || SR=$(ls -d /opt/*/sysroots/*-poky-linux 2>/dev/null | grep -v pokysdk | head -1) +echo "sysroot = $SR" +echo "--- python3 ---"; ls -d "$SR"/usr/lib/python3* 2>/dev/null | head -1 +echo "--- gstreamer core ---"; ls "$SR"/usr/lib/libgstreamer-1.0.so.* 2>/dev/null +grep -h "Version" "$SR"/usr/lib/pkgconfig/gstreamer-1.0.pc 2>/dev/null +echo "--- gst-python loader (libgstpython) ---"; find "$SR" -name 'libgstpython*' 2>/dev/null | head +echo "--- GstAnalytics (lib + typelib) ---" +find "$SR" -iname '*gstanalytics*' 2>/dev/null | head +ls "$SR"/usr/lib/girepository-1.0/ 2>/dev/null | grep -iE 'Analytics|GstApp|GstBase|^Gst-' | head +echo "--- python modules on target: gi / numpy / cairo / cv2 ---" +for m in gi numpy cairo cv2; do + hit=$(find "$SR" -maxdepth 7 -path '*python3*' -iname "${m}" 2>/dev/null | head -1) + echo "$m: ${hit:-MISSING}" +done +echo "--- tvm / mera python runtime on target? ---" +find "$SR" -iname '*tvm*' -o -iname '*mera*' 2>/dev/null | grep -i python | head +echo "--- gstreamer plugins present (count) ---" +ls "$SR"/usr/lib/gstreamer-1.0/*.so 2>/dev/null | wc -l diff --git a/extern/rzv2h/sdk_eval/build_image.sh b/extern/rzv2h/sdk_eval/build_image.sh new file mode 100755 index 0000000..fc7f073 --- /dev/null +++ b/extern/rzv2h/sdk_eval/build_image.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build the Renesas DRP-AI TVM (Mera2) Docker image for RZ/V2H. +# +# This is the *faithful* compile/runtime stack (real mera2 + MERA runtime). +# It needs two downloads that require a Renesas account login — put them in +# ./assets first (this script cannot download them for you). Both arrive as +# ZIPs and can be dropped in as-is: +# +# DRP-AI Translator i8 (ZIP, contains DRP-AI_Translator_i8-*-Linux-x86_64-Install) +# https://www.renesas.com/software-tool/drp-ai-translator-i8 (Downloads tab) +# RZ/V2H AI SDK (RTK0EF0180F*SJ.zip) +# https://www.renesas.com/us/en/software-tool/rzv2h-ai-software-development-kit +# +# The repo Dockerfile COPYs every ./*.sh in the context and runs it, plus +# ./DRP-AI_Translator*-Install. So we assemble a CLEAN context holding only: +# Dockerfile + the SDK toolchain installer (.sh, from the AI SDK zip) + the +# Translator installer (from the Translator zip). +set -euo pipefail +cd "$(dirname "$0")" +ASSETS="${ASSETS:-./assets}" +CTX="${CTX:-./context}" +PRODUCT="${PRODUCT:-V2H}" +TAG="${TAG:-drpai-tvm-v2h}" + +mkdir -p "$ASSETS" +TMPS=() +cleanup() { for d in "${TMPS[@]:-}"; do [[ -n "$d" ]] && rm -rf "$d"; done; } +trap cleanup EXIT + +# --- DRP-AI Translator i8: accept an extracted *-Install or the downloaded zip --- +TR=$(ls "$ASSETS"/DRP-AI_Translator*-Linux*-x86_64-Install 2>/dev/null | head -n1 || true) +if [[ -z "$TR" ]]; then + TRZIP=$(ls "$ASSETS"/*[Tt]ranslator*i8*.zip "$ASSETS"/*DRP-AI_Translator*.zip 2>/dev/null | head -n1 || true) + if [[ -n "$TRZIP" ]]; then + t=$(mktemp -d); TMPS+=("$t") + unzip -o -q "$TRZIP" -d "$t" + TR=$(find "$t" -iname "DRP-AI_Translator*-Linux*-x86_64-Install" | head -n1 || true) + fi +fi + +# --- RZ/V2H AI SDK zip (any v6.x build number) --- +ZIP=$(ls "$ASSETS"/RTK0EF0180F*SJ.zip 2>/dev/null | head -n1 || true) + +if [[ -z "$TR" || -z "$ZIP" ]]; then + echo "Missing gated downloads in $ASSETS (Renesas login required):" >&2 + [[ -z "$TR" ]] && echo " - DRP-AI Translator i8 (zip or extracted *-Install)" >&2 + [[ -z "$ZIP" ]] && echo " - RZ/V2H AI SDK (RTK0EF0180F*SJ.zip)" >&2 + exit 1 +fi + +# Clean build context. +rm -rf "$CTX" && mkdir -p "$CTX" +wget -nc https://raw.githubusercontent.com/renesas-rz/rzv_drp-ai_tvm/main/Dockerfile \ + -O "$CTX/Dockerfile" +cp "$TR" "$CTX/" + +# Unzip the AI SDK and extract its Yocto toolchain installer (.sh) into context. +s=$(mktemp -d); TMPS+=("$s") +unzip -o -q "$ZIP" -d "$s" +# The Yocto toolchain installer is the big *toolchain*.sh (e.g. +# ai_sdk_setup/rz-vlp-...-rzv2h-evk-toolchain-5.0.11.sh). Pick the largest +# match so we don't grab a small board/flash helper script by mistake. +SDK_SH=$(find "$s" -iname "*toolchain*.sh" -printf '%s\t%p\n' | sort -rn | head -n1 | cut -f2-) +[[ -n "$SDK_SH" ]] || { echo "No toolchain .sh found inside $ZIP" >&2; exit 1; } +cp "$SDK_SH" "$CTX/" + +echo "Build context ready in $CTX:" +ls -1 "$CTX" +echo +echo "Building image '$TAG' (PRODUCT=$PRODUCT) — builds the TVM fork, takes a while..." +docker build --build-arg PRODUCT="$PRODUCT" -t "$TAG" "$CTX" + +cat < /deploy.{so,json,params} — loadable by tvm.contrib.graph_executor, i.e. +# by the drpai_runtime shim's TVM backend. +# +# Run inside the drpai-tvm-v2h container: +# python3 compile_x86_cpu.py [input_name] [C,H,W] +import os +import sys + +import onnx +import tvm +from tvm import relay +from tvm.relay import transform +from tvm.relay.build_module import build as _build, bind_params_by_name +from tvm.relay.param_dict import save_param_dict +from tvm.ir.transform import Sequential, PassContext + +model_file = sys.argv[1] +out_dir = sys.argv[2] +input_name = sys.argv[3] if len(sys.argv) > 3 else "images" +chw = [int(x) for x in (sys.argv[4].split(",") if len(sys.argv) > 4 else [3, 640, 640])] +input_shape = [1] + chw + +os.makedirs(out_dir, exist_ok=True) +print(f"[x86 compile] {model_file} input {input_name}={input_shape} -> {out_dir}") + +onnx_model = onnx.load_model(model_file) +mod, params = relay.frontend.from_onnx(onnx_model, {input_name: input_shape}) +if params: + mod["main"] = bind_params_by_name(mod["main"], params) + +with PassContext(opt_level=3): + mod = Sequential([ + transform.SimplifyInference(), + transform.FoldConstant(), + transform.FoldExplicitPadding(), + transform.BackwardFoldScaleAxis(), + transform.ForwardFoldScaleAxis(), + transform.FoldConstant(), + transform.DynamicToStatic(), + transform.RemoveUnusedFunctions(), + ])(mod) + +target = "llvm" # native host (x86), no aarch64 cross target +with PassContext(opt_level=3): + graph, lib, all_params = _build(mod, target=target, target_host=target, params=params) + +lib.export_library(os.path.join(out_dir, "deploy.so")) # default host compiler -> x86 .so +with open(os.path.join(out_dir, "deploy.json"), "w") as f: + f.write(graph) +with open(os.path.join(out_dir, "deploy.params"), "wb") as f: + f.write(save_param_dict(all_params)) +print(f"[x86 compile finished] -> {out_dir}/deploy.so,deploy.json,deploy.params") diff --git a/extern/rzv2h/sdk_eval/x86_runtime_check.py b/extern/rzv2h/sdk_eval/x86_runtime_check.py new file mode 100644 index 0000000..d1ee523 --- /dev/null +++ b/extern/rzv2h/sdk_eval/x86_runtime_check.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# x86_runtime_check.py — run a real input through the MERA/TVM graph_executor +# (via the drpai_runtime shim's TVM backend) and check parity against the +# known-good ONNX output. Run INSIDE the drpai-tvm-v2h container; needs the +# x86 deploy dir + the pre-saved input/onnx-reference .npy files. +import sys +import numpy as np + +sys.path.insert(0, "/work/rzv2h/emulation") # drpai_runtime shim (TVM backend) +sys.path.insert(0, "/work/plugins/python") # utils.detection_decoder (pure numpy) + +import drpai_runtime +from utils.detection_decoder import decode + +DEPLOY = "/work/rzv2h/yolo11m_x86_cpu" +x = np.load("/work/rzv2h/_x86test_input.npy").astype(np.float32) +ref = np.load("/work/rzv2h/_x86test_onnxout.npy").astype(np.float32).reshape(-1) + +rt = drpai_runtime.Runtime() +assert rt.load(DEPLOY), "drpai_runtime.load failed" +rt.set_input(0, x) +rt.run() +out = np.asarray(rt.get_output(0), dtype=np.float32).reshape(-1) + +n = min(out.size, ref.size) +maxdiff = float(np.max(np.abs(out[:n] - ref[:n]))) if n else float("nan") +print(f"TVM out size={out.size} ref size={ref.size} max|TVM-ONNX|={maxdiff:.3e}") + +tvm_det = decode(out.reshape(1, 84, 8400), "anchor_free")[0] +onnx_det = decode(ref.reshape(1, 84, 8400), "anchor_free")[0] +print(f"detections TVM={len(tvm_det['boxes'])} ONNX={len(onnx_det['boxes'])}") +if len(tvm_det["boxes"]): + print("TVM labels:", sorted(set(int(c) for c in tvm_det["labels"]))) +print("PASS" if maxdiff < 1e-2 and len(tvm_det["boxes"]) == len(onnx_det["boxes"]) else "CHECK") diff --git a/extern/rzv2h/yocto/README.md b/extern/rzv2h/yocto/README.md new file mode 100644 index 0000000..ccfbdb4 --- /dev/null +++ b/extern/rzv2h/yocto/README.md @@ -0,0 +1 @@ +# Custom RZ/V2H image for the gst-python-ml pipeline diff --git a/extern/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc b/extern/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc new file mode 100644 index 0000000..cfa0a3d --- /dev/null +++ b/extern/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc @@ -0,0 +1,25 @@ +# Pin GStreamer to 1.24 across the stack so GstAnalytics is available. +# +# scarthgap's oe-core ships GStreamer 1.22.x. The 1.24 recipes must be present +# in the build (see README: copy the gstreamer1.0* recipes from oe-core +# styhead/master into recipes-multimedia/gstreamer/ of this layer, or layer in +# a newer meta-oe). These PREFERRED_VERSION lines then select them. +# +# require this from local.conf: +# require ${TOPDIR}/../layers/meta-gst-python-ml/conf/include/gstreamer-1.24.inc + +GST_124 ?= "1.24.%" + +PREFERRED_VERSION_gstreamer1.0 = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-base = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-good = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-bad = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-ugly = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-libav = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-python = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-rtsp-server = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-vaapi = "${GST_124}" + +# GstAnalytics + the object-detection / tracking metas live in -plugins-bad. +# Make sure analytics isn't disabled by a PACKAGECONFIG override. +PACKAGECONFIG:append:pn-gstreamer1.0-plugins-bad = " analytics" diff --git a/extern/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf b/extern/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf new file mode 100644 index 0000000..262f68a --- /dev/null +++ b/extern/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf @@ -0,0 +1,15 @@ +# meta-gst-python-ml — adds the runtime stack gst-python-ml needs on RZ/V2H. +# +# The RZ/V2H AI SDK v6.00 image is Yocto scarthgap (5.0.11) with GStreamer +# 1.22.x. gst-python-ml requires GStreamer >= 1.24 (for GstAnalytics, the +# metadata type every pyml_* element uses), the gst-python plugin loader, and +# numpy/pycairo/pygobject/opencv. This layer carries those additions. +BBPATH .= ":${LAYERDIR}" +BBFILES += "${LAYERDIR}/recipes-*/*/*.bb ${LAYERDIR}/recipes-*/*/*.bbappend" + +BBFILE_COLLECTIONS += "gst-python-ml" +BBFILE_PATTERN_gst-python-ml = "^${LAYERDIR}/" +BBFILE_PRIORITY_gst-python-ml = "20" + +LAYERDEPENDS_gst-python-ml = "core openembedded-layer" +LAYERSERIES_COMPAT_gst-python-ml = "scarthgap styhead" diff --git a/extern/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb b/extern/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb new file mode 100644 index 0000000..4a9cef9 --- /dev/null +++ b/extern/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb @@ -0,0 +1,26 @@ +SUMMARY = "Runtime stack for gst-python-ml on RZ/V2H (GStreamer 1.24 + Python)" +LICENSE = "MIT" + +inherit packagegroup + +RDEPENDS:${PN} = " \ + gstreamer1.0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-libav \ + gstreamer1.0-python \ + python3-core \ + python3-pygobject \ + python3-numpy \ + python3-pycairo \ + python3-opencv \ +" +# Notes: +# - gstreamer1.0-python provides the libgstpython.so plugin loader that runs +# the pyml_* .py elements. It is NOT in the stock AI SDK image. +# - GstAnalytics (used by base_objectdetector / tracker / overlay) ships in +# gstreamer1.0-plugins-bad once GStreamer is >= 1.24 with the analytics +# PACKAGECONFIG enabled (see conf/include/gstreamer-1.24.inc). +# - The DRP-AI MERA/TVM *Python* runtime is not a stock Yocto package; install +# it onto the image separately (see ../README.md "DRP-AI runtime on board"). diff --git a/extern/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb b/extern/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb new file mode 100644 index 0000000..3b3245b --- /dev/null +++ b/extern/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb @@ -0,0 +1,35 @@ +SUMMARY = "gst-python-ml elements (pyml_*) + DRP-AI engine for RZ/V2H" +DESCRIPTION = "Installs the pure-Python GStreamer elements and sets GST_PLUGIN_PATH/PYTHONPATH." +LICENSE = "LGPL-2.1-or-later" +LIC_FILES_CHKSUM = "file://COPYING;md5= " + +# Point this at your gst-python-ml source. Examples: +# SRC_URI = "git://github.com/collabora/gst-python-ml.git;branch=main;protocol=https" +# SRCREV = " " +# or a local checkout via: SRC_URI = "file:///path/to/gst-python-ml" +SRC_URI = "git://github.com/collabora/gst-python-ml.git;branch=master;protocol=https" +SRCREV = "${AUTOREV}" +S = "${WORKDIR}/git" + +# Pure-Python elements: nothing to compile. +do_compile[noexec] = "1" + +PYML_DIR = "${datadir}/gst-python-ml" + +do_install() { + install -d ${D}${PYML_DIR} + cp -r ${S}/plugins ${D}${PYML_DIR}/plugins + + # Environment so GStreamer finds the .py elements and Python finds the pkg. + install -d ${D}${sysconfdir}/profile.d + cat > ${D}${sysconfdir}/profile.d/gst-python-ml.sh < ", ) - src_template = Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - VIDEO_SRC_CAPS.copy(), - ) - - sink_template = Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.ALWAYS, - VIDEO_SINK_CAPS.copy(), - ) - __gsttemplates__ = (src_template, sink_template) + if backend.BACKEND == "gst": + src_template = Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + VIDEO_SRC_CAPS.copy(), + ) + + sink_template = Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + VIDEO_SINK_CAPS.copy(), + ) + __gsttemplates__ = (src_template, sink_template) rules = GObject.Property( type=str, @@ -219,32 +222,11 @@ def do_set_caps(self, incaps, outcaps): return True def _read_detections(self, buf): - """Extract detections from upstream GstAnalytics od_mtd.""" - detections = [] - meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + """Extract detections from upstream object-detection metadata.""" + meta = analytics.get_relation_meta(buf) if not meta: - return detections - - count = GstAnalytics.relation_get_length(meta) - for index in range(count): - ret, od_mtd = meta.get_od_mtd(index) - if not ret or od_mtd is None: - continue - label_quark = od_mtd.get_obj_type() - label = GLib.quark_to_string(label_quark) - presence, x, y, w, h, score = od_mtd.get_location() - if presence: - detections.append( - { - "label": label, - "score": score, - "x": x, - "y": y, - "w": w, - "h": h, - } - ) - return detections + return [] + return analytics.read_objects(meta) def _check_rule(self, rule, detection): """Check if a detection matches an alert rule.""" @@ -393,10 +375,9 @@ def do_stop(self): return True -if CAN_REGISTER_ELEMENT: - GObject.type_register(AlertTransform) - __gstelementfactory__ = ("pyml_alert", Gst.Rank.NONE, AlertTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_alert", AlertTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_alert' element will not be registered because required modules are missing." ) diff --git a/plugins/python/anomaly.py b/plugins/python/anomaly.py index 50586e7..c80f67e 100644 --- a/plugins/python/anomaly.py +++ b/plugins/python/anomaly.py @@ -17,24 +17,16 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - import json - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.anomaly_engine import AnomalyEngine from engine.engine_factory import EngineFactory + from backend import frameio, GObject + from tasks.anomaly import AnomalyTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -44,130 +36,7 @@ ANOMALY_META_HEADER = b"GST-ANOMALY:" -class AnomalyEngine(PyTorchEngine): - """ - PyTorch engine for anomaly detection using a PatchCore-like approach. - - Uses a pretrained feature extractor (WideResNet50 or ResNet) to extract - patch-level features and compare them against a reference distribution. - - Supports torchvision backbone models: - wide_resnet50_2 - resnet50 - resnet18 - """ - - def do_load_model(self, model_name, **kwargs): - try: - import torch - import torchvision.models as models - - model_fn = getattr(models, model_name, None) - if model_fn is None: - raise ValueError(f"Unknown backbone model: {model_name}") - - self.backbone = model_fn(weights="DEFAULT") - # Remove the final FC layer to get feature maps - self.feature_layers = torch.nn.Sequential( - *list(self.backbone.children())[:-2] - ) - self.execute_with_stream(lambda: self.feature_layers.to(self.device)) - self.feature_layers.eval() - - self.reference_features = None - self._transform = None - self.logger.info(f"Anomaly backbone '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load anomaly backbone '{model_name}': {e}") - - def load_reference(self, reference_path): - """Load precomputed reference features from a .npy file.""" - import numpy as np - - try: - self.reference_features = np.load(reference_path) - self.logger.info( - f"Loaded reference features from '{reference_path}': " - f"shape={self.reference_features.shape}" - ) - except Exception as e: - self.logger.warning(f"Failed to load reference features: {e}") - - def _get_transform(self): - if self._transform is None: - from torchvision import transforms - - self._transform = transforms.Compose( - [ - transforms.ToPILImage(), - transforms.Resize((224, 224)), - transforms.ToTensor(), - transforms.Normalize( - mean=[0.485, 0.456, 0.406], - std=[0.229, 0.224, 0.225], - ), - ] - ) - return self._transform - - def do_forward(self, frames, threshold=0.5): - import numpy as np - import torch - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - transform = self._get_transform() - results = [] - for frame in frames: - try: - tensor = transform(frame.astype(np.uint8)).unsqueeze(0).to(self.device) - - with torch.no_grad(): - features = self.feature_layers(tensor) - - # Global average pool to get a feature vector - feat_vec = features.mean(dim=[2, 3]).squeeze(0).cpu().numpy() - - # Compute anomaly score against reference distribution - anomaly_score = 0.0 - if self.reference_features is not None: - distances = np.linalg.norm( - self.reference_features - feat_vec, axis=-1 - ) - anomaly_score = float(distances.min()) - - # Generate a spatial anomaly heatmap from feature map distances - feat_map = features.squeeze(0).cpu().numpy() - heatmap = np.linalg.norm(feat_map, axis=0) - heatmap = (heatmap - heatmap.min()) / ( - heatmap.max() - heatmap.min() + 1e-8 - ) - - is_anomaly = anomaly_score >= threshold - - results.append( - { - "score": anomaly_score, - "is_anomaly": is_anomaly, - "heatmap": heatmap, - } - ) - except Exception as e: - self.logger.error(f"Anomaly inference error on frame: {e}") - results.append( - { - "score": 0.0, - "is_anomaly": False, - "heatmap": None, - } - ) - - return results[0] if not is_batch else results - - -class AnomalyTransform(VideoTransform): +class AnomalyTransform(VideoTransform, AnomalyTask): """ GStreamer element for anomaly detection in video frames. @@ -181,6 +50,8 @@ class AnomalyTransform(VideoTransform): Anomaly scores are always attached as a GST-ANOMALY: memory chunk (JSON). """ + META_HEADER = ANOMALY_META_HEADER + __gstmetadata__ = ( "Anomaly Detection", "Transform", @@ -230,120 +101,26 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_anomaly") - def do_transform_ip(self, buf): - try: - # Load reference features on first transform if path is set - if not self._reference_loaded and self.reference_path and self.engine: - self.engine.load_reference(self.reference_path) - self._reference_loaded = True - - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - frame = frames[0] if frames.ndim == 4 else frames - result = self._do_forward(frame) - if result is None: - return Gst.FlowReturn.OK - - self._apply_anomaly(buf, result, fmt, frame) - return Gst.FlowReturn.OK + def process_frames(self, frames, num_sources, fmt, target): + """Score the primary frame against the reference features.""" + if not self._reference_loaded and self.reference_path and self.engine: + self.engine.load_reference(self.reference_path) + self._reference_loaded = True - except Exception as e: - self.logger.error(f"Anomaly detection transform error: {e}") - return Gst.FlowReturn.ERROR + frame = frames[0] if frames.ndim == 4 else frames + result = self.forward(frame) + if result is None: + return - def _do_forward(self, frame): - if self.engine: - return self.engine.do_forward(frame, threshold=self.threshold) - return None + output, blob = self.decode(frame, result, fmt) + frameio.write_result(target, output, blob, self.META_HEADER) - def _apply_anomaly(self, buf, result, fmt, frame): - """Overlay heatmap on frame and append anomaly metadata.""" - import cv2 - import numpy as np - is_anomaly = result.get("is_anomaly", False) - heatmap = result.get("heatmap") - - # Draw heatmap overlay before appending read-only metadata memory - if self.draw_heatmap and is_anomaly and heatmap is not None: - H, W = frame.shape[:2] - heatmap_resized = cv2.resize(heatmap, (W, H)) - heatmap_uint8 = (heatmap_resized * 255).astype(np.uint8) - heatmap_color = cv2.applyColorMap(heatmap_uint8, cv2.COLORMAP_JET) - heatmap_rgb = cv2.cvtColor(heatmap_color, cv2.COLOR_BGR2RGB) - - overlay = cv2.addWeighted(frame, 0.6, heatmap_rgb, 0.4, 0) - - # Draw anomaly score text - score = result.get("score", 0.0) - text = f"ANOMALY: {score:.3f}" - cv2.putText( - overlay, - text, - (12, 36), - cv2.FONT_HERSHEY_SIMPLEX, - 1.0, - (255, 0, 0), - 2, - cv2.LINE_AA, - ) - - output = self._convert_rgb_to_format(overlay, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - - # Append anomaly metadata (without the numpy heatmap) - meta = { - "score": result.get("score", 0.0), - "is_anomaly": is_anomaly, - } - meta_bytes = ANOMALY_META_HEADER + json.dumps(meta).encode("utf-8") - tmp = Gst.Buffer.new_allocate(None, len(meta_bytes), None) - tmp.fill(0, meta_bytes) - buf.append_memory(tmp.get_memory(0)) - - @staticmethod - def _convert_rgb_to_format(rgb, fmt): - """Convert an RGB numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return rgb - elif fmt == "BGR": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) - elif fmt == "RGBA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - return np.roll(rgba, 1, axis=-1) - elif fmt == "ABGR": - bgra = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - return np.roll(bgra, 1, axis=-1) - else: - return rgb - - -if CAN_REGISTER_ELEMENT: - GObject.type_register(AnomalyTransform) - __gstelementfactory__ = ("pyml_anomaly", Gst.Rank.NONE, AnomalyTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_anomaly", AnomalyTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_anomaly' element will not be registered because required modules are missing." ) diff --git a/plugins/python/backend/__init__.py b/plugins/python/backend/__init__.py new file mode 100644 index 0000000..2ea5f1d --- /dev/null +++ b/plugins/python/backend/__init__.py @@ -0,0 +1,82 @@ +# Pluggable element backend +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""Pluggable multimedia-framework backend for the ML elements. + +The ML logic (engines, tasks, base classes) is framework-agnostic. The pieces +that DO depend on the framework are isolated behind this package: + + * the element base classes `BaseTransform` / `BaseAggregator` / `VideoTransform` + * the `analytics` metadata interface (add/get/remove detections) + +Select the active backend with the `PYML_BACKEND` environment variable +(default ``"gst"``). To add another backend, create a sibling package exposing +the same names (reusing `backend.core.MLEngineMixin` and implementing +`backend.analytics.AnalyticsBackend`) and add a branch below. +""" + +import os + +from backend.analytics import AnalyticsBackend # noqa: F401 (re-exported) +from backend.frameio import FrameIO # noqa: F401 (re-exported) +from backend.core import MLEngineMixin # noqa: F401 (re-exported) + +BACKEND = os.environ.get("PYML_BACKEND", "gst").lower() + +if BACKEND == "gst": + from backend.gst import ( + BaseTransform, + BaseAggregator, + VideoTransform, + analytics, + frameio, + FlowReturn, + GObject, + ) +elif BACKEND == "g2g": + from backend.g2g import ( + BaseTransform, + BaseAggregator, + VideoTransform, + analytics, + frameio, + FlowReturn, + GObject, + ) +else: + raise ImportError( + f"Unknown PYML_BACKEND={BACKEND!r}; supported backends: 'gst', 'g2g'" + ) + + +def register_gst_element(name, cls, type_name=None): + """Register `cls` with the GObject type system and return its factory tuple. + + Leaf elements call this behind a `BACKEND == "gst"` guard: under g2g the + element base classes are plain Python objects, so type_register would fail. + """ + from backend.gst import Gst, GObject + + GObject.type_register(cls, type_name) + return (name, Gst.Rank.NONE, cls) + + +__all__ = [ + "BACKEND", + "register_gst_element", + "BaseTransform", + "BaseAggregator", + "VideoTransform", + "analytics", + "frameio", + "FlowReturn", + "GObject", + "AnalyticsBackend", + "FrameIO", + "MLEngineMixin", +] diff --git a/plugins/python/backend/analytics.py b/plugins/python/backend/analytics.py new file mode 100644 index 0000000..9d624b7 --- /dev/null +++ b/plugins/python/backend/analytics.py @@ -0,0 +1,85 @@ +# AnalyticsBackend +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Framework-agnostic interface for analytics metadata. + +Detection / classification / tracking results are attached to a buffer as +analytics metadata. GStreamer expresses this via `GstAnalytics` relation +metadata. Elements should go through this interface instead of calling the +framework API directly, so the same element code can run on a different backend +later. + +The `meta` handle and the detection handles returned by `add_*` are opaque to +callers: pass them back into the same backend (e.g. into `relate`). The read +side (`read_objects`) returns plain dicts so consumers need no framework types. +""" + +from abc import ABC, abstractmethod + + +class AnalyticsBackend(ABC): + """Attach to and read analytics metadata on a buffer/frame.""" + + @abstractmethod + def add_relation_meta(self, buf): + """Attach (or fetch the existing) analytics relation meta. Returns an + opaque meta handle, or None on failure.""" + + @abstractmethod + def get_relation_meta(self, buf): + """Return the attached relation meta handle, or None if absent.""" + + @abstractmethod + def remove_relation_meta(self, buf): + """Remove any attached relation meta. Returns True if something was + removed.""" + + @abstractmethod + def relation_length(self, meta): + """Number of relations (detections) currently held by `meta`.""" + + @abstractmethod + def quark(self, label): + """Intern a string label into the backend's id space (a GQuark for + GStreamer). Accepts a str; ints pass through unchanged.""" + + @abstractmethod + def add_object(self, meta, label, x, y, w, h, score): + """Add an object-detection box (x, y, w, h) with a class `label` and + confidence `score`. Returns an opaque detection handle, or None.""" + + @abstractmethod + def add_classification(self, meta, index, label): + """Add a single-class classification result tagged with a stream + `index` and a `label`. Returns an opaque handle, or None.""" + + @abstractmethod + def add_tracking(self, meta, track_id, timestamp=None): + """Add a tracking record for `track_id`. `timestamp` defaults to the + backend's current running time. Returns an opaque handle, or None.""" + + @abstractmethod + def relate(self, meta, src, dst): + """Relate two handles (e.g. a detection to its tracking record). + Returns True on success.""" + + @abstractmethod + def read_objects(self, meta): + """Read back the object detections held by `meta`. Returns a list of + dicts with keys: ``label`` (str), ``x``, ``y``, ``w``, ``h``, ``score``. + Only present (valid-location) detections are returned.""" diff --git a/plugins/python/backend/core.py b/plugins/python/backend/core.py new file mode 100644 index 0000000..7e92c4b --- /dev/null +++ b/plugins/python/backend/core.py @@ -0,0 +1,303 @@ +# MLEngineMixin +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Portable, framework-agnostic ML element logic. + +`MLEngineMixin` holds everything an ML element needs that does NOT depend on +the multimedia framework: the engine/model lifecycle and the backing storage +for the common tunable properties. + +A framework backend builds a concrete element by combining this mixin with its +own element base class (`GstBase.BaseTransform`, `GstBase.Aggregator`, ...) and +declaring the properties in whatever form the framework requires +(`@GObject.Property` for GStreamer, etc). The property getters/setters read and +write the `self._ ` fields initialised here, so the shared logic stays +identical across backends. + +This module imports no `gi`; keep it that way so the same mixin can load under a +non-GStreamer backend. +""" + +from engine.engine_manager import EngineManager +from log.logger_factory import LoggerFactory + +#: A tunable the element stores and mirrors onto the engine under the same name: +#: ``(name, type, default, blurb)``. +ENGINE_TUNABLES = ( + ("batch_size", int, 1, "Number of items to process in a batch"), + ("frame_stride", int, 1, "How often to process a frame"), + ("device_queue_id", int, 1, "ID of the DeviceQueue from the pool to use"), +) + + +def _engine_tunable(gobject, name, value_type, default, blurb): + field = f"_{name}" + + def get(self): + return getattr(self, field) + + def set(self, value): + setattr(self, field, value) + if self.engine: + setattr(self.engine, name, value) + + # pygobject takes the property's name off the getter + get.__name__ = name + return gobject.Property(get, type=value_type, default=default, blurb=blurb).setter( + set + ) + + +def ml_property_namespace(gobject): + """The tunables every ML element takes, declared with `gobject`'s `Property`. + + Returned as a namespace for an element base to unpack into its class body, + not as a mixin to inherit: pygobject installs a property only when it sits + in the class's own dict, so an inherited one leaves a gst element with none + of these. Both backends build their bases from this call, so a knob cannot + exist on one and not the other. + """ + + @gobject.Property(type=str) + def device(self): + "Device to run the inference on (cpu, cuda, cuda:0, cuda:1, etc.)" + return self.mgr.device + + @device.setter + def device(self, value): + self.mgr.set_device(value) + # TODO: why is this needed, for example for yolo? + if self.mgr.engine_name: + self.initialize_engine() + + @gobject.Property(type=str) + def model_name(self): + "Name of the pre-trained model or local model path" + return self._model_name + + @model_name.setter + def model_name(self, value): + self._model_name = value + + @gobject.Property(type=str) + def engine_name(self): + "Machine Learning Engine to use : pytorch, tflite, tensorflow, onnx, openvino, tvm, tinygrad, mlx, executorch, llamacpp, candle, jax, or custom engine name" + return self.mgr.engine_name + + @engine_name.setter + def engine_name(self, value): + self.mgr.engine_name = value + + @gobject.Property(type=str, default="auto") + def input_format(self): + "Input tensor layout: auto, nhwc, or nchw" + return self.engine.input_format if self.engine else "auto" + + @input_format.setter + def input_format(self, value): + if self.engine: + self.engine.input_format = value + + @gobject.Property(type=str, default="auto") + def post_process(self): + "Post-processing format for raw engine output (auto, none, or a key from detection_decoder)" + return self.engine.post_process if self.engine else "auto" + + @post_process.setter + def post_process(self, value): + if self.engine: + self.engine.post_process = value + + @gobject.Property(type=bool, default=False) + def compile(self): + "Enable torch.compile optimization for the model" + return self._compile + + @compile.setter + def compile(self, value): + self._compile = value + if value: + self.kwargs["compile"] = True + else: + self.kwargs.pop("compile", None) + + namespace = { + "device": device, + "model_name": model_name, + "engine_name": engine_name, + "input_format": input_format, + "post_process": post_process, + "compile": compile, + } + for name, value_type, default, blurb in ENGINE_TUNABLES: + namespace[name] = _engine_tunable(gobject, name, value_type, default, blurb) + return namespace + + +class FrameProcessingMixin: + """The per-frame work a video element does, shared by every backend. + + The backend driver extracts the frame and handles the framework's error + signalling; what is left is the same on all of them, so it lives here once: + infer, turn the result into a frame and/or a metadata blob, write both back. + An element whose task class follows that contract supplies only its + `META_HEADER`; one that does not overrides `process_frames`. + """ + + #: Tag on the metadata blob this element appends, e.g. ``b"GST-DEPTH:"``. + #: `None` for an element that appends none. + META_HEADER = None + + def process_frames(self, frames, num_sources, fmt, target): + """Infer over the extracted frame(s) and write the result to `target`. + + Raises on a hard failure; the driver maps that to its own error return. + """ + from backend import frameio + + result = self.forward(frames) + if result is None: + raise RuntimeError(f"{type(self).__name__}: inference returned None") + # Inference sees the whole batch, but one buffer carries one frame back: + # the overlay and the blob describe the primary source. + frame = frames[0] if num_sources > 1 else frames + if num_sources > 1 and isinstance(result, list): + if not result: + return + result = result[0] + output, blob = self.decode(frame, result, fmt) + frameio.write_result(target, output, blob, self.META_HEADER) + + +class PayloadProcessingMixin: + """The per-buffer work a non-video element does, shared by every backend. + + These families read one media type and write another (text in / text out, + audio in / text out), so a buffer is opaque bytes rather than a frame. The + backend driver maps the input buffer and turns whatever comes back into + output buffers; the middle, bytes to bytes, is the same everywhere and is + what the element supplies. + """ + + def process_payload(self, payload: bytes) -> list[bytes]: + """Turn one input buffer's bytes into the payloads to send onward. + + Zero or more, because these families are not all 1:1: an element that + accumulates across buffers returns an empty list until it has something + to say, and one that chunks its output returns several at once. + """ + raise NotImplementedError( + f"{type(self).__name__} must implement process_payload" + ) + + def payload_duration_ns(self, payload_size): + """How long a payload of this many bytes plays for. + + `None`, the usual answer, means the output covers the same stretch of + the stream as the input did, so it keeps the input's timing. An element + that generates media of its own length (speech from a text buffer) works + it out from the byte count instead. + """ + return None + + +class MLEngineMixin: + """Engine/model lifecycle and the start hook, shared by every ML element.""" + + def on_start(self): + """One-time setup before the first buffer, on any backend. + + For an element with its own background work to start (an inference + thread, a warm-up). Framework lifecycle virtuals like `do_start` only + exist on GStreamer, so anything a hosted element also needs goes here. + """ + + def _ensure_started(self): + """Run `on_start` once, for a backend with no start virtual of its own.""" + if not getattr(self, "_started", False): + self._started = True + self.on_start() + + def _ml_init(self): + """Initialise shared ML state. Call this from the element's __init__.""" + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self.mgr = EngineManager(self.logger) + self.kwargs = {} + self._batch_size = 1 + self._frame_stride = 1 + self._model_name = None + self._device_queue_id = 0 + self._system_prompt = None + self._prompt = None + self._compile = False + + @property + def engine(self): + return self.mgr.engine + + # Engine / model lifecycle. None of this touches the framework, so every + # backend reuses it verbatim. + def initialize_engine(self): + if not self.engine and self.mgr.engine_name: + self.mgr.initialize_engine() + self.engine.batch_size = self._batch_size + self.engine.frame_stride = self._frame_stride + if self._device_queue_id: + self.engine.device_queue_id = self._device_queue_id + if not self.engine: + self.logger.error(f"Unsupported ML engine: {self.mgr.engine_name}") + + def do_load_model(self): + self.initialize_engine() + if self.engine is None: + self.logger.error( + f"Cannot load model {self._model_name}: engine not initialized" + ) + return + if self._model_name is None: + self.logger.warning("Cannot load model as model name is not set") + return + self.mgr.do_load_model(self._model_name, **self.kwargs) + + def get_model(self): + """Gets the model from the engine.""" + self.initialize_engine() + if self.engine is None: + self.logger.error( + f"Cannot get model {self._model_name}: engine not initialized" + ) + return None + if self.engine: + return self.engine.get_model() + return None + + def set_model(self, model): + """Sets the model in the engine.""" + self.initialize_engine() + if self.engine is None: + self.logger.error("Cannot load model: engine not initialized") + return False + self.engine.model = model + self.logger.info("Model set successfully in the engine.") + + def get_tokenizer(self): + self.initialize_engine() + if self.engine is None: + self.logger.error("Cannot get tokenizer: engine not initialized") + return None + return self.mgr.get_tokenizer() diff --git a/plugins/python/backend/frameio.py b/plugins/python/backend/frameio.py new file mode 100644 index 0000000..25c96b6 --- /dev/null +++ b/plugins/python/backend/frameio.py @@ -0,0 +1,72 @@ +# FrameIO +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Framework-agnostic interface for frame buffer I/O. + +Video transform elements read pixel data out of a buffer as a numpy frame, run +inference, and either write a frame back or append an opaque metadata blob. +GStreamer expresses these via buffer mapping (`Gst.Buffer.map`), the muxed +buffer processor, and `append_memory`. Elements go through this interface +instead of touching those directly, so the per-buffer task logic that produces +numpy frames / metadata stays framework-free. + +The `target` and `source` arguments are opaque handles (a Gst buffer and the +sink pad on the gst backend); callers only pass them through. +""" + +from abc import ABC, abstractmethod + + +class FrameIO(ABC): + """Read frames from and write frames/metadata back to a buffer/frame.""" + + @abstractmethod + def read_frames(self, target, source, width, height, framerate=(30, 1)): + """Extract frame(s) from `target`. Returns a tuple + ``(frames, num_sources, fmt)`` where ``frames`` is a numpy array shaped + (H, W, C) for a single source or (N, H, W, C) for a batch (or None on + failure), ``num_sources`` is the source count, and ``fmt`` is the pixel + format string (e.g. "RGB").""" + + @abstractmethod + def read_frame(self, target, source, width, height): + """Read a single (H, W, C) RGB frame from `target`, or None on failure. + Use this for plain (non-muxed) video buffers; use `read_frames` when the + buffer may carry batched/muxed sources.""" + + @abstractmethod + def write_frame(self, target, frame): + """Write an (H, W, C) uint8 numpy ``frame`` back into ``target`` in + place. Returns True on success.""" + + @abstractmethod + def append_blob(self, target, header, payload): + """Append an opaque metadata blob (``header`` bytes followed by + ``payload`` bytes) to ``target``. Returns True on success.""" + + def write_result(self, target, output=None, blob=None, header=None): + """Write back what an element produced: a frame, a blob, or both. + + The tail nearly every video element shares. Either half may be absent: + an element that only annotates returns no frame, one that only redraws + returns no blob. + """ + if output is not None: + self.write_frame(target, output) + if blob is not None: + self.append_blob(target, header, blob) diff --git a/plugins/python/backend/g2g/__init__.py b/plugins/python/backend/g2g/__init__.py new file mode 100644 index 0000000..46cde56 --- /dev/null +++ b/plugins/python/backend/g2g/__init__.py @@ -0,0 +1,36 @@ +# g2g backend +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""g2g backend: element bases plus the frame I/O and analytics implementations. + +The counterpart of `backend.gst`, targeting the glass2glass (`g2g`) host instead +of GStreamer. Elements are plain Python objects the host drives via +`g2g_process` / `g2g_process_batch`; there is no GObject type system or GstBase +element, so `GObject` / `FlowReturn` are lightweight shims (see `shims.py`) that +let leaf element code load unchanged. Selected by `PYML_BACKEND=g2g`. + +Unlike the gst backend, no `gi` is imported anywhere here, so a leaf element can +be hosted with no GStreamer present at all. +""" + +from backend.g2g.shims import GObject, FlowReturn # noqa: F401 +from backend.g2g.analytics import analytics # noqa: F401 +from backend.g2g.frameio import frameio # noqa: F401 +from backend.g2g.transform import BaseTransform # noqa: F401 +from backend.g2g.video_transform import VideoTransform # noqa: F401 +from backend.g2g.aggregator import BaseAggregator # noqa: F401 + +__all__ = [ + "BaseTransform", + "BaseAggregator", + "VideoTransform", + "analytics", + "frameio", + "FlowReturn", + "GObject", +] diff --git a/plugins/python/backend/g2g/aggregator.py b/plugins/python/backend/g2g/aggregator.py new file mode 100644 index 0000000..b63fde5 --- /dev/null +++ b/plugins/python/backend/g2g/aggregator.py @@ -0,0 +1,52 @@ +# BaseAggregator (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""g2g backend for the `aggregator` element family (N inputs -> 1 output). + +The host calls `g2g_process_batch([buf, ...], w, h, fmt, sink)` with one +`FrameBuffer` per input. This base reads each into a frame and hands them to +`process_frames`, the same per-frame hook a video transform fills in: on gst the +N-source case arrives muxed into one buffer instead, but what the element is +given is the same, so it spells the same. +""" + +import numpy as np + +from backend.core import FrameProcessingMixin +from backend.g2g.analytics import analytics +from backend.g2g.frameio import as_rgb, frameio +from backend.g2g.transform import BaseTransform + + +class BaseAggregator(BaseTransform, FrameProcessingMixin): + """Base for g2g ML aggregator elements (input format may differ from output).""" + + def __init__(self): + super().__init__() + self.width = 0 + self.height = 0 + + def g2g_process_batch(self, buffers, width, height, fmt, sink): + self.width = width + self.height = height + frameio.bind(sink, fmt) + analytics.bind(sink) + self._ensure_model() + self._ensure_started() + frames = [] + for buf in buffers: + frame = frameio.read_frame(buf, None, width, height) + if frame is not None: + frames.append(frame) + if not frames: + return None + # (H, W, C) for one source and (N, H, W, C) for several, which is what + # `read_frames` hands a video transform. + batch = frames[0] if len(frames) == 1 else np.stack(frames, axis=0) + self.process_frames(as_rgb(batch, fmt), len(frames), fmt, buffers[0]) + return None diff --git a/plugins/python/backend/g2g/analytics.py b/plugins/python/backend/g2g/analytics.py new file mode 100644 index 0000000..2e3605e --- /dev/null +++ b/plugins/python/backend/g2g/analytics.py @@ -0,0 +1,162 @@ +# G2gAnalyticsBackend (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""Analytics metadata over the g2g host's `MetaSink`. + +GStreamer attaches a `GstAnalyticsRelationMeta` to the buffer and relates +detections, classifications and tracking records. The g2g host instead hands the +element a flat, write-only `MetaSink` per frame with `add_object(label, x, y, w, +h, score)` / `add_classification(label, score)` / `add_blob(...)`; the host then +materializes those into the frame's `AnalyticsMeta`. This backend maps the rich +`AnalyticsBackend` interface the leaf task code uses onto that flat sink: + + * the "relation meta" handle is a thin wrapper over the bound sink that counts + how many records were staged (so `relation_length` works); + * string labels are interned to the `u32` ids the sink expects (`quark`); + * `add_*` return the sink's own staging handle, which `relate` passes back to + pair a detection with its tracking id; + * the sink stages straight into the host frame with no read path, so + `read_objects` cannot see its own records back. +""" + +import threading + +from backend.analytics import AnalyticsBackend + + +class _RelationMeta: + """The g2g stand-in for a buffer's relation meta: the bound sink plus a count + of staged records (GstAnalytics tracks relations; the flat sink only counts).""" + + def __init__(self, sink): + self.sink = sink + self.count = 0 + + +class _Bound: + """What one element has staged: the frame's sink, and its label id space.""" + + def __init__(self): + self.sink = None + self.meta = None + self.labels = {} # str -> u32 id + self.next_id = 0 + self.published_names = -1 + + +class G2gAnalyticsBackend(AnalyticsBackend): + """`AnalyticsBackend` mapping detections/classifications onto a `MetaSink`.""" + + def __init__(self): + # Per-thread because the host runs one thread per element: one shared + # binding would stage an element's detections on another's frame. + self._threads = threading.local() + + @property + def _bound(self): + bound = getattr(self._threads, "bound", None) + if bound is None: + bound = _Bound() + self._threads.bound = bound + return bound + + def bind(self, sink): + """Bind this frame's sink (called per frame by the g2g element bases). + A fresh relation-meta is created lazily on first `add_relation_meta`.""" + bound = self._bound + bound.sink = sink + bound.meta = None + # Each frame gets its own sink, so the names have to be sent again. + bound.published_names = -1 + + def quark(self, label): + """Intern a string label into the `u32` id space the sink expects; ints + pass through unchanged (matches the GStreamer GQuark contract).""" + if isinstance(label, int): + return label + bound = self._bound + qid = bound.labels.get(label) + if qid is None: + qid = bound.next_id + bound.labels[label] = qid + bound.next_id += 1 + return qid + + def _publish_class_names(self): + """Send the interned label names to the sink, so a consumer can show a + name instead of an id. Re-sent when a new label is interned mid-frame.""" + bound = self._bound + if bound.sink is None or bound.next_id == bound.published_names: + return + names = [""] * bound.next_id + for name, qid in bound.labels.items(): + names[qid] = name + bound.sink.set_class_names(names) + bound.published_names = bound.next_id + + def add_relation_meta(self, buf): + bound = self._bound + if bound.sink is None: + return None + if bound.meta is None: + bound.meta = _RelationMeta(bound.sink) + return bound.meta + + def get_relation_meta(self, buf): + return self._bound.meta + + def remove_relation_meta(self, buf): + bound = self._bound + had = bound.meta is not None + bound.meta = None + return had + + def relation_length(self, meta): + return meta.count if meta else 0 + + def add_object(self, meta, label, x, y, w, h, score): + if meta is None: + return None + meta.count += 1 + qid = self.quark(label) + self._publish_class_names() + return meta.sink.add_object( + qid, float(x), float(y), float(w), float(h), float(score) + ) + + def add_classification(self, meta, index, label): + if meta is None: + return None + # The flat sink's add_classification is (label, score); the gst `index` + # (stream id) has no place in it and is dropped. + meta.count += 1 + qid = self.quark(label) + self._publish_class_names() + return meta.sink.add_classification(qid, 1.0) + + def add_tracking(self, meta, track_id, timestamp=None): + # The host stamps its own arrival time, so the gst `timestamp` is dropped. + if meta is None: + return None + meta.count += 1 + return meta.sink.add_tracking(int(track_id)) + + def relate(self, meta, src, dst): + if meta is None or src is None or dst is None: + return False + meta.sink.relate(int(src), int(dst)) + return True + + def read_objects(self, meta): + # The sink is write-only (staged straight into the host frame); the + # element cannot read its own staged detections back. + return [] + + +#: Defined here, like `frameio`, to avoid a circular import through `backend`. +analytics = G2gAnalyticsBackend() diff --git a/plugins/python/backend/g2g/frameio.py b/plugins/python/backend/g2g/frameio.py new file mode 100644 index 0000000..495b708 --- /dev/null +++ b/plugins/python/backend/g2g/frameio.py @@ -0,0 +1,113 @@ +# G2gFrameIO (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""Frame buffer I/O backed by the g2g host's `FrameBuffer`. + +The g2g host hands each frame to `g2g_process(buf, w, h, fmt, sink)` where `buf` +is a `FrameBuffer`: a writable buffer-protocol view straight onto the frame's +system memory (no copy in or out). This backend reads it as a numpy frame, writes +a processed frame back in place, and routes opaque blobs to the host's `MetaSink`. + +Unlike the GStreamer backend there is no muxed/batched buffer: the g2g host +delivers one source per `FrameBuffer` (batching is the aggregator's job, via +`g2g_process_batch`), so `read_frames` always reports a single source. +""" + +import threading + +import numpy as np + +from backend.frameio import FrameIO + +#: For each pixel format the host carries: how many channels a pixel takes, and +#: which of them are R, G and B. `None` for a format with no colour to pick out. +_FORMATS = { + "RGB": (3, (0, 1, 2)), + "BGR": (3, (2, 1, 0)), + "RGBA": (4, (0, 1, 2)), + "ARGB": (4, (1, 2, 3)), + "BGRA": (4, (2, 1, 0)), + "ABGR": (4, (3, 2, 1)), + "GRAY8": (1, None), +} + + +def as_rgb(frames, fmt): + """The RGB view of `frames` the ML elements infer over. + + Nothing converts pixels ahead of a hosted element the way `videoconvert` + does in a gst pipeline, so a host format that is not already RGB is reduced + here. Read-only: the write-back target is still the original buffer. + """ + channels, rgb = _FORMATS.get((fmt or "RGB").upper(), _FORMATS["RGB"]) + already_rgb = channels == 3 and rgb == (0, 1, 2) + if rgb is None or already_rgb: + return frames + return frames[..., list(rgb)] + + +class G2gFrameIO(FrameIO): + """`FrameIO` over the g2g `FrameBuffer` (pixels) and `MetaSink` (blobs).""" + + def __init__(self): + # Per-thread because the host runs one thread per element: one shared + # binding would send an element's blobs to whichever element bound last. + self._bound = threading.local() + + def bind(self, sink, fmt="RGB"): + """Bind the current frame's sink and pixel format (called per frame by + the g2g element bases before any read/write).""" + self._bound.sink = sink + self._bound.fmt = (fmt or "RGB").upper() + + @property + def _sink(self): + return getattr(self._bound, "sink", None) + + @property + def _fmt(self): + return getattr(self._bound, "fmt", "RGB") + + def _channels(self, fmt=None): + channels, _ = _FORMATS.get((fmt or self._fmt).upper(), _FORMATS["RGB"]) + return channels + + def read_frame(self, target, source, width, height): + c = self._channels() + arr = np.frombuffer(target, dtype=np.uint8) + if arr.size < width * height * c: + return None + return arr[: width * height * c].reshape((height, width, c)) + + def read_frames(self, target, source, width, height, framerate=(30, 1)): + # One source per FrameBuffer; report (frame, num_sources=1, fmt). + frame = self.read_frame(target, source, width, height) + if frame is None: + return None, 0, self._fmt + return frame, 1, self._fmt + + def write_frame(self, target, frame): + # The FrameBuffer is writable, so frombuffer yields a writable view we + # overwrite in place (no copy back to the host). + view = np.frombuffer(target, dtype=np.uint8) + flat = np.ascontiguousarray(frame, dtype=np.uint8).reshape(-1) + n = min(view.size, flat.size) + view[:n] = flat[:n] + return True + + def append_blob(self, target, header, payload): + if self._sink is None: + return False + hdr = header if isinstance(header, str) else bytes(header).decode("latin-1") + self._sink.add_blob(hdr, bytes(payload)) + return True + + +#: Defined here rather than in the package __init__ so the element bases can +#: import it without a circular import through `backend`. +frameio = G2gFrameIO() diff --git a/plugins/python/backend/g2g/shims.py b/plugins/python/backend/g2g/shims.py new file mode 100644 index 0000000..f42b819 --- /dev/null +++ b/plugins/python/backend/g2g/shims.py @@ -0,0 +1,141 @@ +# Framework-primitive shims (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""Stand-ins for the GStreamer framework primitives leaf elements reference. + +The g2g host drives plain-Python objects, not `GstBase` elements: there is no +GObject type system and no `Gst.FlowReturn`. But leaf element code declares +`@GObject.Property(...)` for its tunables and returns `FlowReturn.OK/ERROR` from +its per-frame method. These shims let that same leaf code load and run unchanged +under the g2g backend (`from backend import GObject, FlowReturn`). + +`GObject.Property` becomes a plain descriptor that stores values on the instance +(or delegates to a getter/setter pair); gst-only metadata (nick, blurb, min/max) +is accepted and ignored. `FlowReturn` is a plain enum whose `OK`/`ERROR` the host +treats as success/failure of a frame. +""" + +from enum import IntEnum, IntFlag + +#: How a pipeline line spells a bool, matching what the g2g host accepts. +BOOL_SPELLINGS = { + "true": True, + "1": True, + "yes": True, + "false": False, + "0": False, + "no": False, +} + + +class ParamFlags(IntFlag): + """The `GObject.ParamFlags` leaves pass to `Property`, accepted and ignored.""" + + READABLE = 1 + WRITABLE = 2 + READWRITE = READABLE | WRITABLE + + +class FlowReturn(IntEnum): + """The subset of `Gst.FlowReturn` leaf per-frame methods return.""" + + OK = 0 + ERROR = -5 + NOT_LINKED = -1 + EOS = -3 + + +class Property: + """Descriptor mimicking `GObject.Property`, in both forms leaves use. + + Decorator form (getter + optional setter):: + + @GObject.Property(type=str) + def model_name(self): ... + @model_name.setter + def model_name(self, value): ... + + Attribute form (plain storage):: + + broker = GObject.Property(type=str, default=None, nick="...", blurb="...") + + Values back a per-instance ``_g2gprop_ `` attribute. The ``nick`` / + ``blurb`` / ``minimum`` / ``maximum`` keywords are accepted for source + compatibility and otherwise ignored (no GObject type system here), but + ``type`` is kept: the host cannot know it, so a value off a pipeline line + arrives as text and is converted here. + """ + + def __init__(self, fget=None, *, type=None, default=None, **_gst_meta): + self._fget = fget + self._fset = None + self._type = type + self._default = default + self._name = None + + # `GObject.Property(type=str)` returns an instance that is then applied as a + # decorator to the getter; this captures it. + def __call__(self, fget): + self._fget = fget + return self + + def setter(self, fset): + self._fset = fset + return self + + def __set_name__(self, owner, name): + self._name = name + + def _slot(self): + return f"_g2gprop_{self._name}" + + def __get__(self, obj, owner=None): + if obj is None: + return self + if self._fget is not None: + return self._fget(obj) + return getattr(obj, self._slot(), self._default) + + def __set__(self, obj, value): + value = self._converted(value) + if self._fset is not None: + self._fset(obj, value) + else: + setattr(obj, self._slot(), value) + + def _converted(self, value): + """The value as the declared type, for one that arrived as text. + + The host forwards a pipeline line's `key=value` verbatim, since only this + class knows what type the property is. Anything already of the right type + (a value set from Python) is left alone. + """ + if self._type in (None, str) or not isinstance(value, str): + return value + if self._type is bool: + spelled = BOOL_SPELLINGS.get(value.strip().lower()) + if spelled is None: + raise ValueError(f"{self._name}: {value!r} is not true or false") + return spelled + return self._type(value) + + +class _GObjectShim: + """The ``GObject`` namespace leaves import from the backend.""" + + Property = Property + ParamFlags = ParamFlags + + # Some leaves type-hint setters as `prop: GObject.GParamSpec`. + class GParamSpec: # noqa: N801 + pass + + ParamSpec = GParamSpec + + +GObject = _GObjectShim() diff --git a/plugins/python/backend/g2g/transform.py b/plugins/python/backend/g2g/transform.py new file mode 100644 index 0000000..333a91d --- /dev/null +++ b/plugins/python/backend/g2g/transform.py @@ -0,0 +1,72 @@ +# BaseTransform (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""g2g backend for the `transform` element family. + +The gst counterpart subclasses `GstBase.BaseTransform`; the g2g element is a +plain Python object driven by the host's `g2g_process(...)` call. All engine / +model logic still comes from the portable `MLEngineMixin`, and the shared +tunables come from `ml_property_namespace` declared with the `GObject` shim, so +the same set exists here as on gst. + +The model is loaded lazily on the first frame (`_ensure_model`) rather than from +a `do_start` framework virtual, since the g2g host has no start hook. + +The payload driver (`g2g_process_payload`, for a stream that is not raw video) +also sits here rather than in the aggregator, so that the aggregator, which +subclasses this, gets it too: a single-chain text or audio element is hosted +1-in-1-out even though its gst counterpart is a `GstBase.Aggregator`. +""" + +from backend.core import MLEngineMixin, PayloadProcessingMixin, ml_property_namespace +from backend.g2g.shims import GObject + + +class BaseTransform(MLEngineMixin, PayloadProcessingMixin): + """Base for g2g ML transform elements (same in/out format, e.g. detection).""" + + locals().update(ml_property_namespace(GObject)) + + def __init__(self): + self._ml_init() + + def g2g_properties(self): + """Every property this element declares, for the host to check a pipeline + line against before it runs. + + Without this the host has no way to tell a knob this element has from a + typo, and would set the typo as an attribute nothing ever reads. + """ + return sorted( + { + name + for klass in type(self).__mro__ + for name, value in vars(klass).items() + if isinstance(value, GObject.Property) + } + ) + + def _ensure_model(self): + """Load the model on first use (the g2g host has no start hook). + + Guard on the model, not the engine: setting the `device` property + eagerly creates the engine (with no model loaded), so an engine-only + check would skip the load and leave inference with a null model. + """ + if self.mgr.engine_name and (self.engine is None or self.engine.model is None): + self.do_load_model() + + def g2g_process_payload(self, buffers, caps, meta): + """Host driver for a stream that is not raw video: run the element's + `process_payload` over the input bytes and emit what it returns.""" + self._ensure_model() + self._ensure_started() + # copied, not viewed: the host takes its buffer back when this returns, + # and an element that accumulates keeps what it was given + for payload in self.process_payload(bytes(memoryview(buffers[0]))): + meta.emit(payload, duration_ns=self.payload_duration_ns(len(payload))) diff --git a/plugins/python/backend/g2g/video_transform.py b/plugins/python/backend/g2g/video_transform.py new file mode 100644 index 0000000..1a9f360 --- /dev/null +++ b/plugins/python/backend/g2g/video_transform.py @@ -0,0 +1,47 @@ +# VideoTransform (g2g backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""g2g backend for video transform elements. + +The host calls `g2g_process(buf, w, h, fmt, sink)` once per frame. This base +binds the frame's sink onto the shared `frameio` / `analytics` (so leaf task code +that calls them through `from backend import frameio, analytics` reaches this +frame's buffer and sink), extracts the frame, then hands off to `process_frames`, +the framework-agnostic per-frame hook the leaf element supplies (the same hook the +gst backend drives from `do_transform_ip`). +""" + +from backend.g2g.analytics import analytics +from backend.g2g.frameio import as_rgb, frameio +from backend.core import FrameProcessingMixin +from backend.g2g.transform import BaseTransform + + +class VideoTransform(BaseTransform, FrameProcessingMixin): + """Base for g2g video transform elements.""" + + def __init__(self): + super().__init__() + self.width = 0 + self.height = 0 + + def g2g_process(self, buf, width, height, fmt, sink): + self.width = width + self.height = height + # Route this frame's pixels (buf) and metadata sink to the shared I/O the + # leaf task code uses. + frameio.bind(sink, fmt) + analytics.bind(sink) + self._ensure_model() + self._ensure_started() + frames, num_sources, fmt = frameio.read_frames(buf, None, width, height) + if frames is None: + return None + self.process_frames(as_rgb(frames, fmt), num_sources, fmt, buf) + # Blobs/detections are staged on the sink; no return payload needed. + return None diff --git a/plugins/python/backend/gst/__init__.py b/plugins/python/backend/gst/__init__.py new file mode 100644 index 0000000..e565b51 --- /dev/null +++ b/plugins/python/backend/gst/__init__.py @@ -0,0 +1,46 @@ +# GStreamer backend +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""GStreamer backend: element bases plus the analytics metadata implementation. + +Element registration metadata (`__gstmetadata__`, `__gsttemplates__`, and the +`__gstelementfactory__` / `GObject.type_register` calls in each leaf plugin) is +GStreamer-specific and stays in this backend. +""" + +import gi + +gi.require_version("Gst", "1.0") +from gi.repository import Gst, GObject # noqa: E402 + +from backend.gst.transform import BaseTransform # noqa: E402 +from backend.gst.aggregator import BaseAggregator # noqa: E402 +from backend.gst.video_transform import VideoTransform # noqa: E402 +from backend.gst.analytics import GstAnalyticsBackend # noqa: E402 +from backend.gst.frameio import GstFrameIO # noqa: E402 + +#: The analytics metadata implementation for this backend. +analytics = GstAnalyticsBackend() + +#: The frame buffer I/O implementation for this backend. +frameio = GstFrameIO() + +# Framework primitives exposed to leaf elements so their task code imports them +# from the backend rather than touching `gi` directly. `FlowReturn` is the +# process()/transform return type; `GObject` carries the property declarations. +FlowReturn = Gst.FlowReturn + +__all__ = [ + "BaseTransform", + "BaseAggregator", + "VideoTransform", + "analytics", + "frameio", + "FlowReturn", + "GObject", +] diff --git a/plugins/python/backend/gst/aggregator.py b/plugins/python/backend/gst/aggregator.py new file mode 100644 index 0000000..02d2a8f --- /dev/null +++ b/plugins/python/backend/gst/aggregator.py @@ -0,0 +1,163 @@ +# BaseAggregator (GStreamer backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""GStreamer backend for the `aggregator` element family (input format differs +from output format, e.g. audio in / text out). + +GStreamer half of the backend split: the element base (`GstBase.Aggregator`) +and the framework virtuals (`do_change_state`, `do_aggregate`, segment +handling). Engine/model logic lives in the portable `MLEngineMixin`, and the +shared tunables come from `ml_property_namespace`. +""" + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstBase", "1.0") +gi.require_version("GLib", "2.0") +from gi.repository import Gst, GObject, GstBase # noqa: E402 + +from backend.core import ( # noqa: E402 + MLEngineMixin, + PayloadProcessingMixin, + ml_property_namespace, +) + + +class PayloadDriver: + """The GStreamer half of the payload seam. + + Kept apart from the element base so it can be driven without standing up a + `GstBase.Aggregator`, the same way `FrameProcessingMixin` keeps the video + work apart from the element that hosts it. + """ + + #: Send output straight out of the element's own src pad instead of through + #: the aggregator. Two families have always done that. + PUSH_FROM_SRC_PAD = False + + def do_process(self, buf): + """Read the input payload, run the element's `process_payload`, and send + each payload it returns as its own buffer. Elements supply + `process_payload`, not this.""" + try: + success, map_info = buf.map(Gst.MapFlags.READ) + if not success: + self.logger.error("Failed to map input buffer") + return Gst.FlowReturn.ERROR + + payload = bytes(map_info.data) + buf.unmap(map_info) + + for output in self.process_payload(payload): + outbuf = Gst.Buffer.new_allocate(None, len(output), None) + outbuf.fill(0, output) + self.stamp_payload(outbuf, buf) + self.push_payload(outbuf) + + return Gst.FlowReturn.OK + + except Exception as e: + self.logger.error(f"Error processing buffer: {e}") + return Gst.FlowReturn.ERROR + + def stamp_payload(self, outbuf, inbuf): + """Time one output buffer, the gst spelling of what `meta.emit` takes. + + An element that generates media of its own length says so through + `payload_duration_ns`; that audio runs for as long as it runs and plays + wherever the pipeline reaches it, so the input's times say nothing about + it. Everything else covers the same stretch of stream as its input. + """ + duration_ns = self.payload_duration_ns(outbuf.get_size()) + if duration_ns is None: + outbuf.pts = inbuf.pts + outbuf.dts = inbuf.dts + outbuf.duration = inbuf.duration + return + outbuf.pts = Gst.CLOCK_TIME_NONE + outbuf.dts = Gst.CLOCK_TIME_NONE + outbuf.duration = duration_ns + + def push_payload(self, outbuf): + """Send one output buffer downstream.""" + if not self.PUSH_FROM_SRC_PAD: + self.finish_buffer(outbuf) + return + ret = self.srcpad.push(outbuf) + if ret != Gst.FlowReturn.OK: + raise RuntimeError(f"Error pushing payload to pipeline: {ret}") + + +class BaseAggregator( + GstBase.Aggregator, MLEngineMixin, PayloadProcessingMixin, PayloadDriver +): + """ + Base class for GStreamer aggregator elements that perform inference + with a machine learning model. This class manages shared properties + and handles model loading and device management via MLEngine. + """ + + __gstmetadata__ = ( + "BaseAggregator", + "Aggregator", + "Generic machine learning model aggregator element", + "Aaron Boxer ", + ) + + # unpacked here rather than inherited: pygobject installs a property only + # when it sits in the class's own dict + locals().update(ml_property_namespace(GObject)) + + def __init__(self): + super().__init__() + self._ml_init() + self.segment_pushed = False + + # GStreamer framework virtual: load the model on NULL -> READY. + def do_change_state(self, transition): + if transition == Gst.StateChange.NULL_TO_READY: + self.do_load_model() + return Gst.Element.do_change_state(self, transition) + + def push_segment_if_needed(self): + if not self.segment_pushed: + segment = Gst.Segment() + segment.init(Gst.Format.TIME) + segment.start = 0 + segment.stop = Gst.CLOCK_TIME_NONE + segment.position = 0 + + self.srcpad.push_event(Gst.Event.new_segment(segment)) + self.segment_pushed = True + + # GStreamer framework virtual: pull buffers from sink pads and process. + def do_aggregate(self, timeout): + if all(pad.is_eos() for pad in self.sinkpads): + return Gst.FlowReturn.EOS + self.push_segment_if_needed() + self.process_all_sink_pads() + self.selected_samples(Gst.CLOCK_TIME_NONE, 0, 0, None) + return Gst.FlowReturn.OK + + def process_all_sink_pads(self): + if len(self.sinkpads) == 0: + return + buf = self.sinkpads[0].pop_buffer() + if buf: + self.do_process(buf) diff --git a/plugins/python/backend/gst/analytics.py b/plugins/python/backend/gst/analytics.py new file mode 100644 index 0000000..9b2549d --- /dev/null +++ b/plugins/python/backend/gst/analytics.py @@ -0,0 +1,102 @@ +# GstAnalyticsBackend +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""GStreamer implementation of the analytics metadata interface. + +Wraps `GstAnalytics` relation metadata. The opaque `meta` handle is a +`GstAnalytics.RelationMeta`; the opaque detection handle is the `*_mtd` object +returned by `add_*_mtd` (it carries the `.id` used by `relate`). +""" + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstAnalytics", "1.0") +gi.require_version("GLib", "2.0") +from gi.repository import Gst, GstAnalytics, GLib # noqa: E402 + +from backend.analytics import AnalyticsBackend # noqa: E402 + + +class GstAnalyticsBackend(AnalyticsBackend): + def add_relation_meta(self, buf): + return GstAnalytics.buffer_add_analytics_relation_meta(buf) + + def get_relation_meta(self, buf): + return GstAnalytics.buffer_get_analytics_relation_meta(buf) + + def remove_relation_meta(self, buf): + meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + if meta is None: + return False + # GstAnalytics relation meta is an add-once accumulator: detections are + # added into it as *_mtd entries. The Python bindings expose no path to + # detach it (the RelationMeta wrapper is not a Gst.Meta, so it cannot be + # passed to Gst.Buffer.remove_meta). Attempt it for bindings that do + # accept it; otherwise report that nothing was removed. + try: + return bool(buf.remove_meta(meta)) + except (TypeError, AttributeError): + return False + + def relation_length(self, meta): + return GstAnalytics.relation_get_length(meta) + + def quark(self, label): + if isinstance(label, int): + return label + return GLib.quark_from_string(str(label)) + + def add_object(self, meta, label, x, y, w, h, score): + qk = self.quark(label) + ret, mtd = meta.add_od_mtd(qk, x, y, w, h, score) + return mtd if ret else None + + def add_classification(self, meta, index, label): + qk = self.quark(label) + ret, mtd = meta.add_one_cls_mtd(index, qk) + return mtd if ret else None + + def add_tracking(self, meta, track_id, timestamp=None): + if timestamp is None: + timestamp = Gst.util_get_timestamp() + ret, mtd = meta.add_tracking_mtd(track_id, timestamp) + return mtd if ret else None + + def relate(self, meta, src, dst): + return bool( + GstAnalytics.RelationMeta.set_relation( + meta, GstAnalytics.RelTypes.RELATE_TO, src.id, dst.id + ) + ) + + def read_objects(self, meta): + objects = [] + count = GstAnalytics.relation_get_length(meta) + for index in range(count): + ret, od_mtd = meta.get_od_mtd(index) + if not ret or od_mtd is None: + continue + label = GLib.quark_to_string(od_mtd.get_obj_type()) + presence, x, y, w, h, score = od_mtd.get_location() + if not presence: + continue + objects.append( + {"label": label, "x": x, "y": y, "w": w, "h": h, "score": score} + ) + return objects diff --git a/plugins/python/backend/gst/frameio.py b/plugins/python/backend/gst/frameio.py new file mode 100644 index 0000000..4b9a56b --- /dev/null +++ b/plugins/python/backend/gst/frameio.py @@ -0,0 +1,80 @@ +# GstFrameIO +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""GStreamer implementation of the frame buffer I/O interface. + +Reads use `MuxedBufferProcessor` (handles single and batched/muxed buffers); +writes map the buffer for writing and memmove the frame bytes in place; blob +append wraps the bytes in a new memory chunk on the buffer. +""" + +import gi + +gi.require_version("Gst", "1.0") +from gi.repository import Gst # noqa: E402 + +from log.logger_factory import LoggerFactory # noqa: E402 +from utils.muxed_buffer_processor import MuxedBufferProcessor # noqa: E402 +from utils.format_converter import FormatConverter # noqa: E402 +from backend.frameio import FrameIO # noqa: E402 + + +class GstFrameIO(FrameIO): + def __init__(self): + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self.format_converter = FormatConverter() + + def read_frames(self, target, source, width, height, framerate=(30, 1)): + processor = MuxedBufferProcessor( + self.logger, width, height, framerate[0], framerate[1] + ) + frames, _id_str, num_sources, fmt = processor.extract_frames(target, source) + return frames, num_sources, fmt + + def read_frame(self, target, source, width, height): + success, map_info = target.map(Gst.MapFlags.READ) + if not success: + return None + try: + return self.format_converter.to_rgb( + map_info.data, width, height, target, source + ) + finally: + target.unmap(map_info) + + def write_frame(self, target, frame): + import ctypes + import numpy as np + + success, map_info = target.map(Gst.MapFlags.WRITE) + if not success: + return False + try: + frame_bytes = np.ascontiguousarray(frame).tobytes() + dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) + ctypes.memmove(dst, frame_bytes, min(len(frame_bytes), map_info.size)) + return True + finally: + target.unmap(map_info) + + def append_blob(self, target, header, payload): + blob = bytes(header) + bytes(payload) + tmp = Gst.Buffer.new_allocate(None, len(blob), None) + tmp.fill(0, blob) + target.append_memory(tmp.get_memory(0)) + return True diff --git a/plugins/python/backend/gst/transform.py b/plugins/python/backend/gst/transform.py new file mode 100644 index 0000000..62840ec --- /dev/null +++ b/plugins/python/backend/gst/transform.py @@ -0,0 +1,67 @@ +# BaseTransform (GStreamer backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""GStreamer backend for the `transform` element family (same input/output +format, e.g. object detection). + +This file is the GStreamer half of the backend split: the element base +(`GstBase.BaseTransform`) and the framework virtuals (`do_start`). All +engine/model logic lives in the portable `MLEngineMixin`, and the shared +tunables come from `ml_property_namespace`, so both backends declare the same +set from one place. +""" + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstBase", "1.0") +from gi.repository import GObject, GstBase # noqa: E402 + +from backend.core import MLEngineMixin, ml_property_namespace # noqa: E402 + + +class BaseTransform(GstBase.BaseTransform, MLEngineMixin): + """ + Base class for GStreamer transform elements that perform + inference with a machine learning model. This class manages shared properties + and handles model loading and device management via MLEngine. + """ + + __gstmetadata__ = ( + "BaseTransform", + "Transform", + "Generic machine learning model transform element", + "Aaron Boxer ", + ) + + # unpacked here rather than inherited: pygobject installs a property only + # when it sits in the class's own dict + locals().update(ml_property_namespace(GObject)) + + def __init__(self): + super().__init__() + self._ml_init() + + # GStreamer framework virtual: load the model when the element starts, then + # run whatever the element itself needs starting (the backend-neutral hook). + def do_start(self): + self.do_load_model() + on_start = getattr(self, "on_start", None) + if on_start: + on_start() + return True diff --git a/plugins/python/backend/gst/video_transform.py b/plugins/python/backend/gst/video_transform.py new file mode 100644 index 0000000..7185a54 --- /dev/null +++ b/plugins/python/backend/gst/video_transform.py @@ -0,0 +1,84 @@ +# VideoTransform (GStreamer backend) +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import traceback + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstBase", "1.0") +gi.require_version("GstVideo", "1.0") +from gi.repository import Gst # noqa: E402 + +from backend.core import FrameProcessingMixin # noqa: E402 +from backend.gst.transform import BaseTransform # noqa: E402 + + +class VideoTransform(BaseTransform, FrameProcessingMixin): + """ + GStreamer element for video transformation using a PyTorch model. + """ + + # Define VIDEO_CAPS to support multiple formats + VIDEO_CAPS = Gst.Caps.from_string( + "video/x-raw,format=(string){ RGB, RGBA, ARGB, BGRA, ABGR }," + "width=(int)[1,2147483647],height=(int)[1,2147483647]" + ) + __gsttemplates__ = ( + Gst.PadTemplate.new( + "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, VIDEO_CAPS + ), + Gst.PadTemplate.new( + "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, VIDEO_CAPS + ), + ) + + def do_set_caps(self, incaps, outcaps): + struct = incaps.get_structure(0) + self.width = struct.get_int("width").value + self.height = struct.get_int("height").value + + return True + + def do_transform_ip(self, buf): + """GStreamer per-frame driver: extract the frame(s) through the backend + frame I/O, run the element's `process_frames`, and map the outcome to a + `Gst.FlowReturn`. Elements supply `process_frames`, not this.""" + # Imported lazily: the frameio singleton lives in backend.gst, which is + # still being constructed when this module is imported. + from backend import frameio + + try: + frames, num_sources, fmt = frameio.read_frames( + buf, + self.sinkpad, + self.width, + self.height, + ( + getattr(self, "framerate_num", 30), + getattr(self, "framerate_denom", 1), + ), + ) + if frames is None: + self.logger.error("Failed to extract frames") + return Gst.FlowReturn.ERROR + self.process_frames(frames, num_sources, fmt, buf) + return Gst.FlowReturn.OK + except Exception as e: + self.logger.error(f"Transform error: {e}\n{traceback.format_exc()}") + return Gst.FlowReturn.ERROR diff --git a/plugins/python/base_aggregator.py b/plugins/python/base_aggregator.py index f8bf863..d8f6bd0 100644 --- a/plugins/python/base_aggregator.py +++ b/plugins/python/base_aggregator.py @@ -16,208 +16,11 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from abc import abstractmethod -import gi +"""Compatibility shim. `BaseAggregator` now lives behind the pluggable backend +in `backend/` (see `backend/__init__.py`). Leaf plugins keep importing it from +here; the active backend is chosen by the PYML_BACKEND environment variable. +""" -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GLib", "2.0") -from gi.repository import Gst, GObject, GstBase # noqa: E402 +from backend import BaseAggregator -from log.logger_factory import LoggerFactory -from engine.engine_manager import EngineManager - - -class BaseAggregator(GstBase.Aggregator): - """ - Base class for GStreamer aggregator elements that perform inference - with a machine learning model. This class manages shared properties - and handles model loading and device management via MLEngine. - """ - - __gstmetadata__ = ( - "BaseAggregator", - "Aggregator", - "Generic machine learning model aggregator element", - "Aaron Boxer ", - ) - - def __init__(self): - super().__init__() - self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) - self.mgr = EngineManager(self.logger) - self.kwargs = {} - self.__batch_size = 1 - self.__frame_stride = 1 - self.__model_name = None - self.__device_queue_id = 0 - self.__system_prompt = None - self._prompt = None - self.__compile = False - self.segment_pushed = False - - @property - def engine(self): - return self.mgr.engine - - @GObject.Property(type=str) - def device(self): - "Device to run the inference on (cpu, cuda, cuda:0, cuda:1, etc.)" - return self.mgr.device - - @device.setter - def device(self, value): - self.mgr.set_device(value) - # todo why is this needed ? - if self.engine_name: - self.initialize_engine() - - @GObject.Property(type=int, default=1) - def batch_size(self): - "Number of items to process in a batch" - return self.__batch_size - - @batch_size.setter - def batch_size(self, value): - self.__batch_size = value - if self.engine: - self.engine.batch_size = value - - @GObject.Property(type=int, default=1) - def frame_stride(self): - "How often to process a frame" - return self.__frame_stride - - @frame_stride.setter - def frame_stride(self, value): - self.__frame_stride = value - if self.engine: - self.engine.frame_stride = value - - @GObject.Property(type=str) - def model_name(self): - "Name of the pre-trained model or local model path" - return self.__model_name - - @model_name.setter - def model_name(self, value): - self.__model_name = value - - @GObject.Property(type=str) - def engine_name(self): - "Machine Learning Engine to use : pytorch, tflite, tensorflow, onnx or openvino, or custom engine name" - return self.mgr.engine_name - - @engine_name.setter - def engine_name(self, value): - self.mgr.engine_name = value - - @GObject.Property(type=int, default=1) - def device_queue_id(self): - "ID of the DeviceQueue from the pool to use" - return self.__device_queue_id - - @device_queue_id.setter - def device_queue_id(self, value): - self.__device_queue_id = value - if self.engine: - self.engine.device_queue_id = value - - @GObject.Property(type=bool, default=False, nick="compile") - def compile(self): - "Enable torch.compile optimization for the model" - return self.__compile - - @compile.setter - def compile(self, value): - self.__compile = value - if value: - self.kwargs["compile"] = True - else: - self.kwargs.pop("compile", None) - - def do_change_state(self, transition): - if transition == Gst.StateChange.NULL_TO_READY: - self.do_load_model() - return Gst.Element.do_change_state(self, transition) - - def initialize_engine(self): - if not self.engine and self.mgr.engine_name: - self.mgr.initialize_engine() - self.engine.batch_size = self.__batch_size - self.engine.frame_stride = self.__frame_stride - if self.__device_queue_id: - self.engine.device_queue_id = self.__device_queue_id - if not self.engine: - self.logger.error(f"Unsupported ML engine: {self.mgr.engine_name}") - - def do_load_model(self): - self.initialize_engine() - if self.engine is None: - self.logger.error( - f"Cannot load model {self.model_name}: engine not initialized" - ) - return - if self.model_name is None: - self.logger.warning("Cannot load model as model name is not set") - return - self.mgr.do_load_model(self.model_name, **self.kwargs) - - def get_model(self): - """Gets the model from the engine.""" - self.initialize_engine() - if self.engine is None: - self.logger.error( - f"Cannot get model {self.model_name}: engine not initialized" - ) - return None - """Gets the model from the engine.""" - if self.engine: - return self.engine.get_model() - return None - - def set_model(self, model): - """Sets the model in the engine.""" - self.initialize_engine() - if self.engine is None: - self.logger.error("Cannot load model: engine not initialized") - return False - self.engine.model = model - self.logger.info("Model set successfully in the engine.") - - def get_tokenizer(self): - self.initialize_engine() - if self.engine is None: - self.logger.error("Cannot get tokenizer: engine not initialized") - return None - return self.mgr.get_tokenizer() - - def push_segment_if_needed(self): - if not self.segment_pushed: - segment = Gst.Segment() - segment.init(Gst.Format.TIME) - segment.start = 0 - segment.stop = Gst.CLOCK_TIME_NONE - segment.position = 0 - - self.srcpad.push_event(Gst.Event.new_segment(segment)) - self.segment_pushed = True - - def do_aggregate(self, timeout): - if all(pad.is_eos() for pad in self.sinkpads): - return Gst.FlowReturn.EOS - self.push_segment_if_needed() - self.process_all_sink_pads() - self.selected_samples(Gst.CLOCK_TIME_NONE, 0, 0, None) - return Gst.FlowReturn.OK - - def process_all_sink_pads(self): - if len(self.sinkpads) == 0: - return - buf = self.sinkpads[0].pop_buffer() - if buf: - self.do_process(buf) - - @abstractmethod - def do_process(self, buf): - pass +__all__ = ["BaseAggregator"] diff --git a/plugins/python/base_caption.py b/plugins/python/base_caption.py index 2daefe3..fef65ef 100644 --- a/plugins/python/base_caption.py +++ b/plugins/python/base_caption.py @@ -17,25 +17,29 @@ # Boston, MA 02110-1301, USA. -from utils.muxed_buffer_processor import MuxedBufferProcessor # Added import +import backend +from backend import GObject, analytics +from video_transform import VideoTransform -import gi +# The text pad is a GStreamer request pad: a hosted element on g2g has one +# source pad and stages its caption as metadata instead. +if backend.BACKEND == "gst": + import gi -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GstVideo", "1.0") -gi.require_version("GLib", "2.0") -gi.require_version("GstAnalytics", "1.0") + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 -from gi.repository import Gst, GObject, GstAnalytics, GLib, GstBase # noqa: E402 -from video_transform import VideoTransform + TEXT_CAPS = Gst.Caps.from_string("text/x-raw, format=utf8") -TEXT_CAPS = Gst.Caps.from_string("text/x-raw, format=utf8") +#: How long a caption stays on screen. Long enough that the subtitle is still up +#: when the next frame's caption arrives. +CAPTION_DURATION_SECONDS = 60 class BaseCaption(VideoTransform): """ - Base GStreamer element for captioning video frames. + Base element for captioning video frames. """ __gstmetadata__ = ( @@ -45,34 +49,35 @@ class BaseCaption(VideoTransform): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new( - "text_src", Gst.PadDirection.SRC, Gst.PadPresence.REQUEST, TEXT_CAPS - ), - ) + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new( + "text_src", Gst.PadDirection.SRC, Gst.PadPresence.REQUEST, TEXT_CAPS + ), + ) def __init__(self): super().__init__() - self.__prompt = "What is shown in this image?" + self._prompt = "What is shown in this image?" self.text_src_pad = None @GObject.Property(type=str) def system_prompt(self): "Custom system prompt text" - return self.__system_prompt + return self._system_prompt @system_prompt.setter def system_prompt(self, value): - self.__system_prompt = value + self._system_prompt = value @GObject.Property(type=str) def prompt(self): "Custom prompt text" - return self.__prompt + return self._prompt @prompt.setter def prompt(self, value): - self.__prompt = value + self._prompt = value # make read only @GObject.Property(type=str) @@ -86,6 +91,75 @@ def engine_name(self, value): "The 'engine_name' property cannot be set in this derived class." ) + def forward(self, frames): + return self.engine.do_forward(frames) if self.engine else None + + def process_frames(self, frames, num_sources, fmt, target): + """Caption each source, staging one classification per caption. + + Captions carry no pixels and no blob, so this replaces the shared + infer-decode-write body rather than filling in `decode`. + """ + result = self.forward(frames) + if result is None: + raise RuntimeError(f"{type(self).__name__}: captioning returned None") + + captions = result if isinstance(result, list) else [result] * num_sources + if len(captions) != num_sources: + raise RuntimeError(f"expected {num_sources} captions, got {len(captions)}") + + meta = analytics.add_relation_meta(target) + if meta is None: + self.logger.error("Failed to add analytics metadata to buffer") + return + + for index, caption in enumerate(captions): + if not caption: + self.logger.warning(f"stream {index}: no caption generated") + continue + label = caption if num_sources == 1 else f"stream_{index}_{caption}" + if analytics.add_classification(meta, index, label) is None: + self.logger.error(f"stream {index}: failed to add the caption") + else: + self.logger.info(f"stream {index}: added caption {caption}") + + self.push_captions(captions, target) + + def push_captions(self, captions, buf): + """Send each caption out the text pad, one buffer per source. + + The caption is staged as metadata either way, so the pad is an extra: + nothing to do unless something asked for it, which is also what makes + this a no-op on a backend that has no request pads. + """ + if self.text_src_pad is None: + return + + if buf.pts == Gst.CLOCK_TIME_NONE: + buf.pts = Gst.util_uint64_scale( + Gst.util_get_timestamp(), + 1, # framerate_denom + 30 * Gst.SECOND, # framerate_num + ) + if buf.duration == Gst.CLOCK_TIME_NONE: + buf.duration = Gst.SECOND // 30 # framerate_num + + share = buf.duration // len(captions) + for index, caption in enumerate(captions): + if caption: + self.push_text_buffer(caption, buf.pts + index * share, buf.dts) + + def push_text_buffer(self, text, pts, dts): + """Push one caption to the `text_src` pad, timed with its video frame.""" + text_buffer = Gst.Buffer.new_wrapped(text.encode("utf-8")) + text_buffer.pts = pts + text_buffer.dts = dts + text_buffer.duration = CAPTION_DURATION_SECONDS * Gst.SECOND + + ret = self.text_src_pad.push(text_buffer) + if ret != Gst.FlowReturn.OK: + self.logger.warning(f"Failed to push text buffer: {ret}") + def do_request_new_pad(self, template, name, caps): if self.text_src_pad: self.logger.error("Element already has a text_src") @@ -105,155 +179,6 @@ def do_release_pad(self, pad): pad.set_active(False) self.text_src_pad = None - def push_text_buffer(self, text, buf_pts, buf_dts, buf_duration): - """ - Pushes a text buffer to the `text_src` pad with proper timestamps. - - Args: - text (str): The text to push as a buffer. - buf_pts (int): The PTS of the associated video buffer. - buf_duration (int): The duration of the associated video buffer. - """ - text_buffer = Gst.Buffer.new_wrapped(text.encode("utf-8")) - - # Set the text buffer timestamps - text_buffer.pts = buf_pts - text_buffer.dts = buf_dts - # Put a long duration so the subtitles are visible - text_buffer.duration = 60 * Gst.SECOND - - # Push the buffer - ret = self.text_src_pad.push(text_buffer) - if ret != Gst.FlowReturn.OK: - self.logger.warning(f"Failed to push text buffer: {ret}") - - def do_transform_ip(self, buf): - """ - In-place transformation for captioning inference using MuxedBufferProcessor. - """ - try: - # Initialize MuxedBufferProcessor with default framerate - muxed_processor = MuxedBufferProcessor( - self.logger, - self.width, - self.height, - framerate_num=30, - framerate_denom=1, - ) - frames, id_str, num_sources, format = muxed_processor.extract_frames( - buf, self.sinkpad - ) - if frames is None: - self.logger.error("Failed to extract frames") - return Gst.FlowReturn.ERROR - - # Set timestamps if none are set - if buf.pts == Gst.CLOCK_TIME_NONE: - buf.pts = Gst.util_uint64_scale( - Gst.util_get_timestamp(), - 1, # framerate_denom - 30 * Gst.SECOND, # framerate_num - ) - if buf.duration == Gst.CLOCK_TIME_NONE: - buf.duration = Gst.SECOND // 30 # framerate_num - - # Process frames (single or batch) - if num_sources == 1: - # Single-frame case - frame = frames - if self.engine: - result = self.engine.do_forward(frame) - if result: - self.caption = result - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if meta: - qk = GLib.quark_from_string(f"{result}") - ret, mtd = meta.add_one_cls_mtd(0, qk) - if ret: - self.logger.info(f"Successfully added caption {result}") - else: - self.logger.error( - "Failed to add classification metadata" - ) - else: - self.logger.error( - "Failed to add GstAnalytics metadata to buffer" - ) - - # Push text buffer if text_src pad is linked - if self.text_src_pad: - self.push_text_buffer( - self.caption, buf.pts, buf.dts, buf.duration - ) - else: - self.logger.warning( - "TextExtract: text_src pad is not linked, cannot push text buffer." - ) - else: - # Batch case - self.logger.info( - f"Processing batch with ID={id_str}, num_sources={num_sources}" - ) - if self.engine: - results = self.engine.do_forward(frames) - if results is None: - self.logger.error("Inference returned None") - return Gst.FlowReturn.ERROR - - # Ensure results is a list for batch processing - results_list = ( - results - if isinstance(results, list) - else [results] * num_sources - ) - if len(results_list) != num_sources: - self.logger.error( - f"Expected {num_sources} results, got {len(results_list)}" - ) - return Gst.FlowReturn.ERROR - - for idx, result in enumerate(results_list): - if result: - caption = result - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if meta: - qk = GLib.quark_from_string(f"stream_{idx}_{result}") - ret, mtd = meta.add_one_cls_mtd(idx, qk) - if ret: - self.logger.info( - f"Stream {idx}: Successfully added caption {result}" - ) - else: - self.logger.error( - f"Stream {idx}: Failed to add classification metadata" - ) - else: - self.logger.error( - f"Stream {idx}: Failed to add GstAnalytics metadata" - ) - - # Push text buffer for each frame - if self.text_src_pad: - # Adjust PTS for each frame in the batch - frame_pts = buf.pts + ( - idx * (buf.duration // num_sources) - ) - self.push_text_buffer( - caption, frame_pts, buf.duration // num_sources - ) - else: - self.logger.warning( - f"Stream {idx}: TextExtract: text_src pad is not linked, cannot push text buffer." - ) - else: - self.logger.warning(f"Stream {idx}: No caption generated") - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"Error during transformation: {e}") - return Gst.FlowReturn.ERROR - def do_sink_event(self, event): if self.text_src_pad: text_event = ( diff --git a/plugins/python/base_classifier.py b/plugins/python/base_classifier.py index 090976c..4a830b2 100644 --- a/plugins/python/base_classifier.py +++ b/plugins/python/base_classifier.py @@ -18,15 +18,9 @@ from utils.runtime_utils import runtime_check_gstreamer_version -import gi from video_transform import VideoTransform -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GstVideo", "1.0") -gi.require_version("GstAnalytics", "1.0") -gi.require_version("GLib", "2.0") -from gi.repository import Gst, GstAnalytics, GLib # noqa: E402 +from backend import analytics class BaseClassifier(VideoTransform): @@ -48,36 +42,12 @@ def do_forward(self, frame): self.logger.error("No model loaded in BaseClassifier.") return None - def do_transform_ip(self, buf): - """ - Processes an image and attaches classification metadata. - """ - import numpy as np - - try: - with buf.map(Gst.MapFlags.READ | Gst.MapFlags.WRITE) as info: - if info.data is None: - self.logger.error("Buffer mapping returned None data.") - return Gst.FlowReturn.ERROR - - frame = np.array(info.data, dtype=np.uint8).reshape( - self.height, self.width, 3 - ) - - # Perform classification - results = self.do_forward(frame) - if not results: - self.logger.warning("Classification returned no results.") - return Gst.FlowReturn.ERROR - - # Process results - self.do_decode(buf, results) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"do_transform_ip: Unexpected error: {e}") - return Gst.FlowReturn.ERROR + def process_frames(self, frames, num_sources, fmt, target): + """Classify the frame and attach the label as metadata.""" + results = self.do_forward(frames) + if not results: + raise RuntimeError("classification returned no results") + self.do_decode(target, results) def do_decode(self, buf, output): """ @@ -111,8 +81,9 @@ def do_decode(self, buf, output): self.logger.info(f"Classified as {label} with confidence score {score:.2f}") # Attach classification metadata - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if meta: - qk = GLib.quark_from_string(f"class_{label}") - meta.add_od_mtd(qk, 0, 0, self.width, self.height, score) + analytics.add_object( + meta, f"class_{label}", 0, 0, self.width, self.height, score + ) self.logger.info(f"Classified as {label} with score {score:.2f}") diff --git a/plugins/python/base_llm.py b/plugins/python/base_llm.py index a4da09a..28cc494 100644 --- a/plugins/python/base_llm.py +++ b/plugins/python/base_llm.py @@ -17,138 +17,96 @@ # Boston, MA 02110-1301, USA. -import gi +import backend +from backend import GObject +from base_aggregator import BaseAggregator -gi.require_version("Gst", "1.0") -gi.require_version("GLib", "2.0") -from gi.repository import Gst, GObject # noqa: E402 +if backend.BACKEND == "gst": + import gi -from base_aggregator import BaseAggregator + gi.require_version("Gst", "1.0") + from gi.repository import Gst # noqa: E402 class BaseLlm(BaseAggregator): """ - GStreamer base element that performs language model inference - with a PyTorch model. + Base element that performs language model inference with a PyTorch model. """ + PUSH_FROM_SRC_PAD = True + @GObject.Property(type=str) def system_prompt(self): "Custom system prompt text" - return self.__system_prompt + return self._system_prompt @system_prompt.setter def system_prompt(self, value): - self.__system_prompt = value + self._system_prompt = value @GObject.Property(type=str) def prompt(self): "Custom prompt text" - return self.__prompt + return self._prompt @prompt.setter def prompt(self, value): - self.__prompt = value - - __gsttemplates__ = ( - Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - ) - - def __init__(self): - super().__init__() - - def do_process(self, buf): - """ - Processes the input buffer with the language model - and pushes the result downstream. - """ - try: - # Map buffer to read input text - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map buffer") - return Gst.FlowReturn.ERROR - - # Convert memoryview to bytes and decode to string - input_text = bytes(map_info.data).decode("utf-8") - self.logger.info(f"Received text for LLM processing: {input_text}") - - # Ensure engine is initialized - if not self.engine: - self.logger.info("Engine not initialized, initializing now") - self.mgr.initialize_engine() - self.mgr.do_load_model(self.model_name) - - # Retry model loading if tokenizer or model is missing + self._prompt = value + + # the caps each pad negotiates, stated once for both backends + INPUT_CAPS = "text/x-raw,format=utf8" + OUTPUT_CAPS = "text/x-raw,format=utf8" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + ), + Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + ), + ) + + def process_payload(self, payload: bytes) -> list[bytes]: + """Generates a reply to the input text with the language model.""" + input_text = payload.decode("utf-8") + self.logger.info(f"Received text for LLM processing: {input_text}") + + # Ensure engine is initialized + if not self.engine: + self.logger.info("Engine not initialized, initializing now") + self.mgr.initialize_engine() + self.mgr.do_load_model(self.model_name) + + # Retry model loading if tokenizer or model is missing + tokenizer = self.get_tokenizer() + model = self.get_model() + self.logger.info(f"Tokenizer: {tokenizer}") + self.logger.info(f"Model: {model}") + if not tokenizer or not model: + self.logger.error( + f"Tokenizer initialized: {tokenizer is not None}, Model initialized: {model is not None}" + ) + self.logger.warning("Attempting to reload model") + if not self.mgr.do_load_model(self.model_name): + self.logger.error("Model reload failed") + return [] tokenizer = self.get_tokenizer() model = self.get_model() - self.logger.info(f"Tokenizer: {tokenizer}") - self.logger.info(f"Model: {model}") if not tokenizer or not model: - self.logger.error( - f"Tokenizer initialized: {tokenizer is not None}, Model initialized: {model is not None}" - ) - self.logger.warning("Attempting to reload model") - if not self.mgr.do_load_model(self.model_name): - self.logger.error("Model reload failed") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - tokenizer = self.get_tokenizer() - model = self.get_model() - if not tokenizer or not model: - self.logger.error("Model reload failed again") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - - # Generate text using the engine - generated_text = self.engine.do_generate( - input_text, system_prompt=self.system_prompt - ) - self.logger.info(f"Generated text: {generated_text}") - - buf.unmap(map_info) - - # Push the generated text downstream - return self.push_generated_text(buf, generated_text) - - except Exception as e: - self.logger.error(f"Error in LLM processing: {e}") - return Gst.FlowReturn.ERROR - - def push_generated_text(self, inbuf, generated_text): - """ - Push the generated text downstream. - """ - try: - generated_bytes = generated_text.encode("utf-8") - outbuf = Gst.Buffer.new_allocate(None, len(generated_bytes), None) - success, map_info_out = outbuf.map(Gst.MapFlags.WRITE) - if not success: - self.logger.error("Failed to map output buffer for writing") - return Gst.FlowReturn.ERROR - - map_info_out.data[: len(generated_bytes)] = generated_bytes - outbuf.unmap(map_info_out) - outbuf.pts = inbuf.pts - outbuf.dts = inbuf.dts - outbuf.duration = inbuf.duration - - # Push the buffer downstream - self.logger.info("Pushed generated text downstream") - ret = self.srcpad.push(outbuf) - - return ret - - except Exception as e: - self.logger.error(f"Error pushing generated text: {e}") + self.logger.error("Model reload failed again") + return [] + + generated_text = self.engine.do_generate( + input_text, system_prompt=self.system_prompt + ) + self.logger.info(f"Generated text: {generated_text}") + + return [generated_text.encode("utf-8")] diff --git a/plugins/python/base_objectdetector.py b/plugins/python/base_objectdetector.py index 84f2f2d..335fbc6 100644 --- a/plugins/python/base_objectdetector.py +++ b/plugins/python/base_objectdetector.py @@ -16,25 +16,21 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import traceback from utils.runtime_utils import runtime_check_gstreamer_version -import gi from video_transform import VideoTransform from utils.format_converter import FormatConverter -from utils.muxed_buffer_processor import MuxedBufferProcessor # Added import +from backend import analytics, GObject +from tasks.object_detector import ObjectDetectorTask +from utils.metadata import Metadata -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GstAnalytics", "1.0") -gi.require_version("GLib", "2.0") -from gi.repository import Gst, GstAnalytics, GObject, GLib # noqa: E402 -from utils.metadata import Metadata # noqa: E402 - -class BaseObjectDetector(VideoTransform): +class BaseObjectDetector(VideoTransform, ObjectDetectorTask): """ - GStreamer element for object detection with batch processing support. - Handles both single-frame buffers (no metadata) and batch buffers (metadata in last chunk). + GStreamer element shell for object detection with batch processing support. + Handles both single-frame buffers (no metadata) and batch buffers (metadata + in last chunk). The inference and metadata steps (do_forward / do_decode) + are inherited from the backend-agnostic ObjectDetectorTask; this class only + supplies the GStreamer per-buffer glue. """ def __init__(self): @@ -46,6 +42,10 @@ def __init__(self): self.metadata = Metadata("si") self.logger.info("Initialized BaseObjectDetector") self.__track = False + self.__interval = 1 + self._det_counter = 0 + self._cached_results = None + self._cached_num_sources = 1 @GObject.Property(type=bool, default=False) def track(self): @@ -60,130 +60,77 @@ def track(self, value): if self.engine: self.engine.track = value - def do_forward(self, frames): - self.logger.info( - f"Forward called with frames shape: {frames.shape if frames is not None else 'None'}" - ) - if self.engine: - self.engine.track = self.track - result = self.engine.do_forward(frames) - self.logger.debug(f"Forward result: {result} (type: {type(result)})") - return result - return None - - def do_transform_ip(self, buf): - """ - Transform the input buffer using MuxedBufferProcessor for frame extraction. - """ - self.logger.info(f"Transforming buffer: {hex(id(buf))}") - try: - # Use MuxedBufferProcessor to extract frames and metadata - muxed_processor = MuxedBufferProcessor( - self.logger, - self.width, - self.height, - self.framerate_num, - self.framerate_denom, - ) - frames, id_str, num_sources, format = muxed_processor.extract_frames( - buf, self.sinkpad - ) - if frames is None: - self.logger.error("Failed to extract frames") - return Gst.FlowReturn.ERROR - - # Process frames (single or batch) - results = self.do_forward(frames) - if results is None: - self.logger.error("Inference returned None") - return Gst.FlowReturn.ERROR + @GObject.Property(type=int, default=1, minimum=1, maximum=10000) + def interval(self): + "Run detection every Nth frame and re-attach the previous detections on" + "the frames in between (N=1 runs detection every frame). Lets downstream " + "tracking/overlay stay per-frame while detection runs at a lower rate." + return self.__interval - # Handle single-frame case - if num_sources == 1: - self.do_decode(buf, results, stream_idx=0) - # Handle batch case - else: - self.logger.info( - f"Processing batch with ID={id_str}, num_sources={num_sources}" - ) - results_list = results if isinstance(results, list) else [results] - if len(results_list) != num_sources: - self.logger.error( - f"Expected {num_sources} results, got {len(results_list)}" - ) - return Gst.FlowReturn.ERROR + @interval.setter + def interval(self, value): + self.__interval = max(1, int(value)) - for idx, result in enumerate(results_list): - if result is None: - self.logger.warning(f"Frame {idx} result is None") - continue - self.do_decode(buf, result, stream_idx=idx) + @GObject.Property(type=str, default="30/1") + def framerate(self): + "Source framerate as 'num/denom', used for muxed-stream frame timing" + return f"{self.framerate_num}/{self.framerate_denom}" - attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) - if attached_meta: - count = GstAnalytics.relation_get_length(attached_meta) - self.logger.info(f"Total metadata relations attached: {count}") - else: - self.logger.debug("No detections on this buffer") + @framerate.setter + def framerate(self, value): + try: + num, denom = str(value).split("/") + self.framerate_num = int(num) + self.framerate_denom = int(denom) + except (ValueError, AttributeError): + self.logger.warning(f"Invalid framerate '{value}', expected 'num/denom'") - return Gst.FlowReturn.OK + def process_frames(self, frames, num_sources, fmt, target): + """ + Run detection on the extracted frame(s) and attach results to `target`. - except Exception as e: - self.logger.error(f"Transform error: {e}\n{traceback.format_exc()}") - return Gst.FlowReturn.ERROR + Backend-agnostic per-frame hook: frame extraction and the FlowReturn + wrapping live in the backend driver (the gst `do_transform_ip` / + the g2g `g2g_process`), so the inference + metadata here is identical on + every backend. Raises on a hard failure; the driver maps that to its own + error return. + """ + run_detect = (self._det_counter % self.__interval) == 0 + self._det_counter += 1 - def do_decode(self, buf, output, stream_idx=0): - self.logger.info( - f"Decoding for stream {stream_idx}: {output} (type: {type(output)})" - ) - if isinstance(output, dict): - self.logger.info(f"Stream {stream_idx} - Processing dict") - boxes = output["boxes"] - labels = output["labels"] - scores = output["scores"] - elif hasattr(output, "boxes"): # Direct Results object (e.g., Ultralytics YOLO) - self.logger.info(f"Stream {stream_idx} - Processing Ultralytics Results") - boxes = output.boxes.xyxy.cpu().numpy() # [N, 4] - scores = output.boxes.conf.cpu().numpy() # [N] - labels = output.boxes.cls.cpu().numpy().astype(int) # [N] - elif ( - isinstance(output, list) and len(output) >= 6 - ): # [x1, y1, x2, y2, score, label] - self.logger.info(f"Stream {stream_idx} - Processing list of detections") - boxes = [[det[0], det[1], det[2], det[3]] for det in output] - scores = [det[4] for det in output] - labels = [int(det[5]) for det in output] + if run_detect: + results = self.do_forward(frames) + if results is None: + raise RuntimeError("inference returned None") + self._cached_results = results + self._cached_num_sources = num_sources + self._decode_results(target, results, num_sources) + elif self._cached_results is not None: + # Skip inference on this frame and re-attach the previous + # detections so downstream tracking/overlay stay per-frame. + self._decode_results(target, self._cached_results, self._cached_num_sources) + + attached_meta = analytics.get_relation_meta(target) + if attached_meta: + count = analytics.relation_length(attached_meta) + self.logger.info(f"Total metadata relations attached: {count}") else: - self.logger.error( - f"Stream {stream_idx} - Unrecognized format: {output} (type: {type(output)})" - ) - return - - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if not meta: - self.logger.error( - f"Stream {stream_idx} - Failed to add analytics relation metadata" - ) - return + self.logger.debug("No detections on this buffer") - self.logger.info(f"Stream {stream_idx} - Adding {len(boxes)} detections") - for i, (box, label, score) in enumerate(zip(boxes, labels, scores)): - x1, y1, x2, y2 = box - qk_string = f"stream_{stream_idx}_label_{label}" - qk = GLib.quark_from_string(qk_string) - ret, od_mtd = meta.add_od_mtd(qk, x1, y1, x2 - x1, y2 - y1, score) - if not ret: - self.logger.error( - f"Stream {stream_idx} - Failed to add od_mtd for detection {i}" + def _decode_results(self, target, results, num_sources): + # Single-frame case + if num_sources == 1: + self.do_decode(target, results, stream_idx=0) + # Batch case + else: + self.logger.info(f"Processing batch with num_sources={num_sources}") + results_list = results if isinstance(results, list) else [results] + if len(results_list) != num_sources: + raise RuntimeError( + f"expected {num_sources} results, got {len(results_list)}" ) - continue - self.logger.info( - f"Stream {stream_idx} - Added detection {i}: label={qk_string}, x1={x1}, y1={y1}, w={x2-x1}, h={y2-y1}, score={score}" - ) - - attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) - if attached_meta: - count = GstAnalytics.relation_get_length(attached_meta) - self.logger.info( - f"Stream {stream_idx} - Metadata relations after adding: {count}" - ) + for idx, result in enumerate(results_list): + if result is None: + self.logger.warning(f"Frame {idx} result is None") + continue + self.do_decode(target, result, stream_idx=idx) diff --git a/plugins/python/base_separate.py b/plugins/python/base_separate.py index f793b5e..8164620 100644 --- a/plugins/python/base_separate.py +++ b/plugins/python/base_separate.py @@ -21,14 +21,17 @@ from abc import abstractmethod import traceback -import gi -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GObject", "2.0") -from gi.repository import Gst, GObject, GstBase # noqa: E402 +import backend +from backend import GObject +from base_aggregator import BaseAggregator -from base_aggregator import BaseAggregator # noqa: E402 +if backend.BACKEND == "gst": + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") @@ -44,32 +47,29 @@ class BaseSeparate(BaseAggregator): SAMPLE_RATE = 44100 # native sample rate of Demucs - CAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=SAMPLE_RATE, - channels=1, + # the caps each pad negotiates, stated once for both backends. The rate has + # to match SAMPLE_RATE, which the chunking works from. + INPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=44100,channels=1" + OUTPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=44100,channels=1" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), ) - ) - - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - CAPS, - GstBase.AggregatorPad.__gtype__, - ), - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - CAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) def __init__(self): super().__init__() @@ -102,84 +102,36 @@ def do_separate(self, audio_data): def do_process_audio(self, audio_data): # audio_data is np.int16 array - encoded_size = audio_data.nbytes - outbuf = Gst.Buffer.new_allocate(None, encoded_size, None) - outbuf.fill(0, audio_data.tobytes()) - return outbuf - - def do_process(self, buf): - import numpy as np + return audio_data.tobytes() - self.push_segment_if_needed() - """Process audio data from the input buffers using source separation.""" - audio_collected = False + def process_payload(self, payload: bytes) -> list[bytes]: + """Separates the requested stem, one payload per full chunk of audio. - try: - # Map the buffer to access the audio data - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map input buffer") - return Gst.FlowReturn.ERROR - - # Convert buffer to numpy array (int16) - audio_data = np.frombuffer(map_info.data, dtype=np.int16) - audio_collected = True - - self.clip_buffer.extend(audio_data) - - chunk_duration = 1.0 if self.streaming else 10.0 - chunk_size = int(self.SAMPLE_RATE * chunk_duration) - - while len(self.clip_buffer) >= chunk_size: - chunk = np.fromiter( - (self.clip_buffer.popleft() for _ in range(chunk_size)), - dtype=np.int16, - ) - separated = self._separate_audio(chunk) - if separated is None: - self.logger.warning("Empty separated audio") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - - self._process_and_send(separated, buf) - - # Handle remaining buffer on EOS - if buf.flags & Gst.BufferFlags.LAST: - if len(self.clip_buffer) > 0: - chunk = np.fromiter(self.clip_buffer, dtype=np.int16) - separated = self._separate_audio(chunk) - if separated is None: - self.logger.warning("Empty separated audio") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - - self._process_and_send(separated, buf) - self.clip_buffer.clear() - - buf.unmap(map_info) - except Exception as e: - self.logger.error(f"Error during buffer processing: {e}") - if audio_collected: - buf.unmap(map_info) - return Gst.FlowReturn.ERROR + Audio is accumulated until there is a whole chunk to separate, so a + buffer produces anywhere from zero to several payloads. + """ + import numpy as np - if not audio_collected: - self.logger.warning("No audio data collected from sink pads.") - return Gst.FlowReturn.ERROR + audio_data = np.frombuffer(payload, dtype=np.int16) + self.clip_buffer.extend(audio_data) - return Gst.FlowReturn.OK + chunk_duration = 1.0 if self.streaming else 10.0 + chunk_size = int(self.SAMPLE_RATE * chunk_duration) - def _process_and_send(self, separated, inbuf): - outbuf = self.do_process_audio(separated) - if outbuf is None: - return + payloads = [] + while len(self.clip_buffer) >= chunk_size: + chunk = np.fromiter( + (self.clip_buffer.popleft() for _ in range(chunk_size)), + dtype=np.int16, + ) + separated = self._separate_audio(chunk) + if separated is None: + self.logger.warning("Empty separated audio") + return payloads - # Set PTS and duration from the input buffer - outbuf.pts = inbuf.pts - outbuf.duration = inbuf.duration + payloads.append(self.do_process_audio(separated)) - # Push the separated audio downstream - self.finish_buffer(outbuf) + return payloads def _separate_audio(self, chunk): """ diff --git a/plugins/python/base_transcribe.py b/plugins/python/base_transcribe.py index b690a4c..79e9da4 100644 --- a/plugins/python/base_transcribe.py +++ b/plugins/python/base_transcribe.py @@ -20,32 +20,22 @@ import sys from abc import abstractmethod -import gi +import backend +from backend import GObject +from base_aggregator import BaseAggregator -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GObject", "2.0") -from gi.repository import Gst, GObject, GstBase # noqa: E402 +if backend.BACKEND == "gst": + import gi -from base_aggregator import BaseAggregator # noqa: E402 + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") STT_SAMPLE_RATE = 16000 # Target sample rate for processing -ICAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=STT_SAMPLE_RATE, - channels=1, - ) -) - -OCAPS = Gst.Caps(Gst.Structure("text/x-raw", format="utf8")) - class BaseTranscribe(BaseAggregator): __gstmetadata__ = ( @@ -55,22 +45,29 @@ class BaseTranscribe(BaseAggregator): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - ICAPS, - GstBase.AggregatorPad.__gtype__, - ), - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OCAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) + # the caps each pad negotiates, stated once for both backends. The rate has + # to match STT_SAMPLE_RATE, which the VAD chunking works from. + INPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1" + OUTPUT_CAPS = "text/x-raw,format=utf8" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + ) def __init__(self): super().__init__() @@ -132,101 +129,62 @@ def do_transcribe(self, audio_data, task): pass def do_process_text(self, transcript): - # Encode the transcript as UTF-8 - text_bytes = transcript.encode("utf-8") - encoded_size = len(text_bytes) - - # Create a new buffer for output and write the transcription - outbuf = Gst.Buffer.new_allocate(None, encoded_size, None) - outbuf.fill(0, text_bytes) + """The payload one transcript becomes. `None` sends nothing.""" + return transcript.encode("utf-8") - return outbuf + def process_payload(self, payload: bytes) -> list[bytes]: + """Runs VAD over the audio, transcribing each clip that ends. - def do_process(self, buf): + Speech is accumulated across buffers, so most buffers produce nothing. + """ import numpy as np - self.push_segment_if_needed() - """Process audio data from the input buffers using VAD and Whisper.""" - audio_collected = False + audio_data = np.frombuffer(payload, dtype=np.int16) - try: - # Map the buffer to access the audio data - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map input buffer") - buf.unmap(map_info) - return Gst.FlowReturn.OK - - # Convert buffer to numpy array (int16) - audio_data = np.frombuffer(map_info.data, dtype=np.int16) - audio_collected = True - - if len(audio_data) < self._vad_chunk_size: - self.logger.warning("Insufficient audio data for processing") - buf.unmap(map_info) - return Gst.FlowReturn.OK - - # Process audio data with VAD (Voice Activity Detection) - while len(audio_data) >= self._vad_chunk_size: - vad_chunk = audio_data[: self._vad_chunk_size] - audio_data = audio_data[self._vad_chunk_size :] - - vad_confidence = self._vad.process_chunk(vad_chunk.tobytes()) - if vad_confidence >= 0.7: - if self.streaming: - transcript = self._transcribe_audio(vad_chunk) - if transcript is None: - self.logger.warning("Empty transcript") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - - self._process_and_send(transcript, buf) - else: - # VAD detects voice activity, add to buffer - self.active_clip = True - self.silence_counter = 0 - self.clip_buffer.extend(vad_chunk) - else: - # Increment silence counter when no voice is detected - self.silence_counter += 1 - - # If silence is detected for too long, end the current segment - if ( - self.active_clip - and self.silence_counter > self.clip_silence_trigger_counter - ): - self.active_clip = False - if not self.streaming: - # Perform transcription in batch mode - transcript = self._transcribe_audio(self.clip_buffer) - if transcript is None: - self.logger.warning("Empty transcript") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR - - self._process_and_send(transcript, buf) - self.clip_buffer.clear() # Clear the buffer for the next speech - buf.unmap(map_info) - except Exception as e: - self.logger.error(f"Error during buffer processing: {e}") + if len(audio_data) < self._vad_chunk_size: + self.logger.warning("Insufficient audio data for processing") + return [] - if not audio_collected: - self.logger.warning("No audio data collected from sink pads.") - return Gst.FlowReturn.ERROR + payloads = [] + while len(audio_data) >= self._vad_chunk_size: + vad_chunk = audio_data[: self._vad_chunk_size] + audio_data = audio_data[self._vad_chunk_size :] - return Gst.FlowReturn.OK - - def _process_and_send(self, transcript, inbuf): - outbuf = self.do_process_text(transcript) - if outbuf is None: + vad_confidence = self._vad.process_chunk(vad_chunk.tobytes()) + if vad_confidence >= 0.7: + if self.streaming: + self._collect_transcript(payloads, vad_chunk) + else: + # VAD detects voice activity, add to buffer + self.active_clip = True + self.silence_counter = 0 + self.clip_buffer.extend(vad_chunk) + else: + # Increment silence counter when no voice is detected + self.silence_counter += 1 + + # If silence is detected for too long, end the current segment + if ( + self.active_clip + and self.silence_counter > self.clip_silence_trigger_counter + ): + self.active_clip = False + if not self.streaming: + # Perform transcription in batch mode + self._collect_transcript(payloads, self.clip_buffer) + self.clip_buffer.clear() # Clear the buffer for the next speech + + return payloads + + def _collect_transcript(self, payloads, chunk): + transcript = self._transcribe_audio(chunk) + if transcript is None: + self.logger.warning("Empty transcript") return - # Set PTS and duration from the input buffer - outbuf.pts = inbuf.pts - outbuf.duration = inbuf.duration - - # Push the transcription downstream - self.finish_buffer(outbuf) + payload = self.do_process_text(transcript) + if payload is not None: + payloads.append(payload) def _transcribe_audio(self, chunk): """ diff --git a/plugins/python/base_transform.py b/plugins/python/base_transform.py index 1c461e5..e554cca 100644 --- a/plugins/python/base_transform.py +++ b/plugins/python/base_transform.py @@ -16,191 +16,11 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import gi -from engine.engine_manager import EngineManager +"""Compatibility shim. `BaseTransform` now lives behind the pluggable backend +in `backend/` (see `backend/__init__.py`). Leaf plugins keep importing it from +here; the active backend is chosen by the PYML_BACKEND environment variable. +""" -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -from gi.repository import GObject, GstBase # noqa: E402 +from backend import BaseTransform -from log.logger_factory import LoggerFactory # noqa: E402 - - -class BaseTransform(GstBase.BaseTransform): - """ - Base class for GStreamer transform elements that perform - inference with a machine learning model. This class manages shared properties - and handles model loading and device management via MLEngine. - """ - - __gstmetadata__ = ( - "BaseTransform", - "Transform", - "Generic machine learning model transform element", - "Aaron Boxer ", - ) - - def __init__(self): - super().__init__() - self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) - self.mgr = EngineManager(self.logger) - self.kwargs = {} - self.__batch_size = 1 - self.__frame_stride = 1 - self.__model_name = None - self.__device_queue_id = 0 - self.__system_prompt = None - self.__prompt = None - self.__compile = False - - @property - def engine(self): - return self.mgr.engine - - @GObject.Property(type=str) - def device(self): - "Device to run the inference on (cpu, cuda, cuda:0, cuda:1, etc.)" - return self.mgr.device - - @device.setter - def device(self, value): - self.mgr.set_device(value) - # todo why is this needed, for example for yolo ? - if self.engine_name: - self.initialize_engine() - - @GObject.Property(type=int, default=1) - def batch_size(self): - "Number of items to process in a batch" - return self.__batch_size - - @batch_size.setter - def batch_size(self, value): - self.__batch_size = value - if self.engine: - self.engine.batch_size = value - - @GObject.Property(type=int, default=1) - def frame_stride(self): - "How often to process a frame" - return self.__frame_stride - - @frame_stride.setter - def frame_stride(self, value): - self.__frame_stride = value - if self.engine: - self.engine.frame_stride = value - - @GObject.Property(type=str) - def model_name(self): - "Name of the pre-trained model or local model path" - return self.__model_name - - @model_name.setter - def model_name(self, value): - self.__model_name = value - - @GObject.Property(type=str) - def engine_name(self): - "Machine Learning Engine to use : pytorch, tflite, tensorflow, onnx, openvino, tvm, tinygrad, mlx, executorch, llamacpp, candle, jax, or custom engine name" - return self.mgr.engine_name - - @engine_name.setter - def engine_name(self, value): - self.mgr.engine_name = value - - @GObject.Property(type=str, default="auto") - def input_format(self): - "Input tensor layout: auto, nhwc, or nchw" - if self.engine: - return self.engine.input_format - return "auto" - - @input_format.setter - def input_format(self, value): - if self.engine: - self.engine.input_format = value - - @GObject.Property(type=str, default="auto") - def post_process(self): - "Post-processing format for raw engine output (auto, none, or a key from detection_decoder)" - if self.engine: - return self.engine.post_process - return "none" - - @post_process.setter - def post_process(self, value): - if self.engine: - self.engine.post_process = value - - @GObject.Property(type=int, default=1) - def device_queue_id(self): - "ID of the DeviceQueue from the pool to use" - return self.__device_queue_id - - @device_queue_id.setter - def device_queue_id(self, value): - self.__device_queue_id = value - if self.engine: - self.engine.device_queue_id = value - - @GObject.Property(type=bool, default=False, nick="compile") - def compile(self): - "Enable torch.compile optimization for the model" - return self.__compile - - @compile.setter - def compile(self, value): - self.__compile = value - if value: - self.kwargs["compile"] = True - else: - self.kwargs.pop("compile", None) - - def do_start(self): - self.do_load_model() - return True - - def initialize_engine(self): - if not self.engine and self.mgr.engine_name: - self.mgr.initialize_engine() - self.engine.batch_size = self.__batch_size - self.engine.frame_stride = self.__frame_stride - if self.__device_queue_id: - self.engine.device_queue_id = self.__device_queue_id - if not self.engine: - self.logger.error(f"Unsupported ML engine: {self.mgr.engine_name}") - - def do_load_model(self): - self.initialize_engine() - if self.engine is None: - self.logger.error( - f"Cannot load model {self.model_name}: engine not initialized" - ) - return - if self.model_name is None: - self.logger.warning("Cannot load model as model name is not set") - return - self.mgr.do_load_model(self.model_name, **self.kwargs) - - def get_model(self): - """Gets the model from the engine.""" - self.initialize_engine() - if self.engine is None: - self.logger.error( - f"Cannot get model {self.model_name}: engine not initialized" - ) - return None - """Gets the model from the engine.""" - if self.engine: - return self.engine.get_model() - return None - - def set_model(self, model): - """Sets the model in the engine.""" - self.initialize_engine() - if self.engine is None: - self.logger.error("Cannot load model: engine not initialized") - return False - self.engine.model = model - self.logger.info("Model set successfully in the engine.") +__all__ = ["BaseTransform"] diff --git a/plugins/python/base_translate.py b/plugins/python/base_translate.py index 6dec868..8ebbce9 100644 --- a/plugins/python/base_translate.py +++ b/plugins/python/base_translate.py @@ -17,17 +17,17 @@ # Boston, MA 02110-1301, USA. from abc import abstractmethod -import gi +import backend +from backend import GObject from base_aggregator import BaseAggregator -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -from gi.repository import Gst, GObject, GstBase # noqa: E402 +if backend.BACKEND == "gst": + import gi -# Define input and output caps for text/x-raw format -ICAPS = Gst.Caps(Gst.Structure("text/x-raw", format="utf8")) -OCAPS = Gst.Caps(Gst.Structure("text/x-raw", format="utf8")) + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 class BaseTranslate(BaseAggregator): @@ -38,22 +38,28 @@ class BaseTranslate(BaseAggregator): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - ICAPS, - GstBase.AggregatorPad.__gtype__, - ), - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OCAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) + # the caps each pad negotiates, stated once for both backends + INPUT_CAPS = "text/x-raw,format=utf8" + OUTPUT_CAPS = "text/x-raw,format=utf8" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + ) def __init__(self): super().__init__() @@ -82,64 +88,17 @@ def target(self, value): def do_translate_text(self, text): pass - def do_process(self, buf): - """ - Processes text data from the input buffers, - translates it, and pushes it downstream. - """ - try: - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map input buffer") - return Gst.FlowReturn.ERROR - - byte_data = bytes(map_info.data) - buf.unmap(map_info) - - if not byte_data: - return Gst.FlowReturn.OK - - try: - text_data = byte_data.decode("utf-8", errors="replace") - except Exception as e: - self.logger.error(f"Error decoding text data: {e}") - return Gst.FlowReturn.ERROR - - self.logger.info(f"Translating text: {text_data}") - - # Translate the text using the MarianMT model - translated_text = self.do_translate_text(text_data) - - if translated_text: - self.logger.info(f"Translated text: {translated_text}") - # Convert the translated text to a GstBuffer and push it downstream - outbuf = self.convert_text_to_buf(translated_text, buf) - self.finish_buffer(outbuf) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"Error processing text buffer: {e}") - return Gst.FlowReturn.ERROR - - def convert_text_to_buf(self, translated_text, inbuf): - """ - Converts translated text to a GstBuffer. - """ - try: - # Encode the translated text as UTF-8 - text_bytes = translated_text.encode("utf-8") - encoded_size = len(text_bytes) - - # Create a new buffer for output and write the translated text - outbuf = Gst.Buffer.new_allocate(None, encoded_size, None) - outbuf.fill(0, text_bytes) - - # Set PTS and duration from the input buffer - outbuf.pts = inbuf.pts - outbuf.duration = inbuf.duration - - return outbuf - except Exception as e: - self.logger.error(f"Error converting text to buffer: {e}") - return None + def process_payload(self, payload: bytes) -> list[bytes]: + """Decodes the input text, translates it, and returns the result.""" + if not payload: + return [] + + text_data = payload.decode("utf-8", errors="replace") + self.logger.info(f"Translating text: {text_data}") + + translated_text = self.do_translate_text(text_data) + if not translated_text: + return [] + + self.logger.info(f"Translated text: {translated_text}") + return [translated_text.encode("utf-8")] diff --git a/plugins/python/base_tts.py b/plugins/python/base_tts.py index f3ffd77..9160d1c 100644 --- a/plugins/python/base_tts.py +++ b/plugins/python/base_tts.py @@ -19,16 +19,21 @@ from abc import abstractmethod import io import asyncio -import gi +import backend +from backend import GObject from base_aggregator import BaseAggregator -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GstAudio", "1.0") -from gi.repository import Gst, GObject, GstBase, GstAudio # noqa: E402 +if backend.BACKEND == "gst": + import gi -ICAPS = Gst.Caps(Gst.Structure("text/x-raw", format="utf8")) + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + gi.require_version("GstAudio", "1.0") + from gi.repository import Gst, GstBase, GstAudio # noqa: E402 + +BYTES_PER_SAMPLE = 2 # S16LE, the format every subclass produces +NANOSECONDS_PER_SECOND = 1_000_000_000 class BaseTts(BaseAggregator): @@ -39,15 +44,23 @@ class BaseTts(BaseAggregator): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - ICAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) + PUSH_FROM_SRC_PAD = True + + # the sink pad caps, stated once for both backends; each subclass declares + # the audio it produces + INPUT_CAPS = "text/x-raw,format=utf8" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + ) language = GObject.Property( type=str, @@ -55,12 +68,6 @@ class BaseTts(BaseAggregator): nick="Language", blurb="Two-character code for the language to be used by TTS model.", ) - streaming = GObject.Property( - type=bool, - default=False, - nick="Streaming", - blurb="Enable streaming mode for real-time audio generation.", - ) speaker = GObject.Property( type=str, default="Andrew Chipper", @@ -72,7 +79,17 @@ def __init__(self): super().__init__() self.segment_pushed = False self.device = "cpu" - self.streaming_enabled = False + self.__streaming = False + + @GObject.Property(type=bool, default=False) + def streaming(self): + "Enable streaming mode for real-time audio generation." + return self.__streaming + + @streaming.setter + def streaming(self, value): + self.__streaming = value + self.logger.info(f"Streaming mode {'enabled' if value else 'disabled'}") @abstractmethod def do_load_model(self): @@ -86,27 +103,6 @@ def do_generate_speech(self, transcript): def do_get_sample_rate(self): pass - def set_property(self, property_name, value): - if property_name == "speaker": - self.speaker = value - elif property_name == "language": - self.language = value - elif property_name == "streaming": - self.streaming_enabled = value - self.logger.info(f"Streaming mode {'enabled' if value else 'disabled'}") - else: - super().set_property(property_name, value) - - def get_property(self, property_name): - if property_name == "speaker": - return self.speaker - elif property_name == "language": - return self.language - elif property_name == "streaming": - return self.streaming_enabled - else: - return super().get_property(property_name) - def do_set_caps(self, in_caps, out_caps): self.audio_info = GstAudio.AudioInfo() self.audio_info.set_format( @@ -114,37 +110,26 @@ def do_set_caps(self, in_caps, out_caps): ) return True - def do_process(self, buf): - try: - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map input buffer") - return Gst.FlowReturn.ERROR - - byte_data = bytes(map_info.data) - if not byte_data: - buf.unmap(map_info) - return Gst.FlowReturn.OK + def process_payload(self, payload: bytes) -> list[bytes]: + """Speaks the input text, one payload per stretch of generated audio.""" + if not payload: + return [] - try: - byte_data = byte_data.decode("utf-8", errors="replace") - except Exception as e: - self.logger.error(f"Error decoding text data: {e}") - buf.unmap(map_info) - return Gst.FlowReturn.ERROR + text = payload.decode("utf-8", errors="replace") + self.logger.info(f"TTS: received text: {text}") - self.logger.info(f"TTS: received text: {byte_data}") - - if self.streaming_enabled: - self.convert_text_to_audio_streaming_async(byte_data) - else: - self.convert_text_to_audio_async(byte_data) + chunks = self.split_text_into_chunks(text, 20) if self.streaming else [text] + payloads = [] + for chunk in chunks: + audio = asyncio.run(self.process_transcript(chunk)) + if audio is not None: + payloads.append(audio.tobytes()) - buf.unmap(map_info) + return payloads - except Exception as e: - self.logger.error(f"Error processing text buffer: {e}") - return Gst.FlowReturn.ERROR + def payload_duration_ns(self, payload_size): + samples = payload_size // BYTES_PER_SAMPLE + return int(samples / self.do_get_sample_rate() * NANOSECONDS_PER_SECOND) async def process_transcript(self, transcript): import soundfile as sf @@ -164,36 +149,11 @@ async def process_transcript(self, transcript): if sr != self.do_get_sample_rate(): raise ValueError("Sample rate mismatch in audio processing") - self.push_audio_to_pipeline(audio_bytes) + return audio_bytes except Exception as e: self.logger.error(f"Error processing TTS: {e}") - - def convert_text_to_audio_async(self, text): - asyncio.run(self.process_transcript(text)) - - def convert_text_to_audio_streaming_async(self, text): - """Converts the text in smaller chunks for streaming.""" - chunks = self.split_text_into_chunks(text, 20) - for chunk in chunks: - asyncio.run(self.process_transcript(chunk)) + return None def split_text_into_chunks(self, text, max_length=50): """Splits text into smaller chunks for streaming.""" return [text[i : i + max_length] for i in range(0, len(text), max_length)] - - def push_audio_to_pipeline(self, audio_data): - try: - duration = len(audio_data) / self.do_get_sample_rate() * Gst.SECOND - buffer = Gst.Buffer.new_wrapped(audio_data.tobytes()) - - buffer.pts = Gst.CLOCK_TIME_NONE - buffer.duration = duration - - ret = self.srcpad.push(buffer) - if ret != Gst.FlowReturn.OK: - raise RuntimeError(f"Error pushing audio to pipeline: {ret}") - - self.logger.info("TTS: audio generated and pushed downstream successfully.") - - except Exception as e: - self.logger.error(f"Error pushing audio to pipeline: {e}") diff --git a/plugins/python/caption_phi.py b/plugins/python/caption_phi.py index 2572cdd..5b99f2a 100644 --- a/plugins/python/caption_phi.py +++ b/plugins/python/caption_phi.py @@ -17,18 +17,14 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend + +from base_caption import BaseCaption CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - - from gi.repository import Gst, GObject # noqa: E402 - - from engine.pytorch_vision_engine import PyTorchVisionEngine + from engine.caption_phi_engine import CaptionPhiEngine from engine.engine_factory import EngineFactory - from base_caption import BaseCaption except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -37,58 +33,6 @@ ) -class CaptionPhiEngine(PyTorchVisionEngine): - def do_load_model(self, model_name, **kwargs): - """Load a Phi-3-vision model from Hugging Face.""" - import torch - from transformers import AutoModelForCausalLM, AutoProcessor, BitsAndBytesConfig - - try: - quantization_config = BitsAndBytesConfig(load_in_4bit=True) - self.model = AutoModelForCausalLM.from_pretrained( - model_name, - quantization_config=quantization_config, - device_map="auto", - torch_dtype=torch.float16, - trust_remote_code=True, - _attn_implementation="flash_attention_2", - ) - self.processor = AutoProcessor.from_pretrained( - model_name, trust_remote_code=True - ) - self.logger.info("Phi-3.5-vision model and processor loaded successfully.") - self.model.eval() - - # Skip .to() for 4-bit models - if not ( - hasattr(self.model, "is_loaded_in_4bit") - and self.model.is_loaded_in_4bit - ): - self.execute_with_stream(lambda: self.model.to(self.device)) - self.logger.info(f"Model moved to {self.device}") - - return True - - except Exception as e: - self.logger.error(f"Error loading model '{model_name}': {e}") - self.tokenizer = None - self.model = None - return False - - def _prepare_messages(self, images): - prompt_content = ( - "\n".join([f"<|image_{i+1}|>" for i in range(len(images))]) - + f"\n{self.prompt}" - ) - return [{"role": "user", "content": prompt_content}] - - def _process_inputs(self, prompt_text, images): - return self.processor(prompt_text, images, return_tensors="pt").to(self.device) - - def _trim_generated_ids(self, inputs, generate_ids): - return generate_ids[:, inputs["input_ids"].shape[1] :] - - class CaptionPhi(BaseCaption): """ GStreamer element for captioning video frames using Phi Vision. @@ -108,10 +52,11 @@ def __init__(self): EngineFactory.register(self.engine_name, CaptionPhiEngine) -if CAN_REGISTER_ELEMENT: - GObject.type_register(CaptionPhi, "pyml_caption_phi") - __gstelementfactory__ = ("pyml_caption_phi", Gst.Rank.NONE, CaptionPhi) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_caption_phi", CaptionPhi, "pyml_caption_phi" + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_caption_phi' element will not be registered because required modules are missing." ) diff --git a/plugins/python/caption_qwen.py b/plugins/python/caption_qwen.py index 0e528f3..5aee95b 100644 --- a/plugins/python/caption_qwen.py +++ b/plugins/python/caption_qwen.py @@ -17,18 +17,14 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend + +from base_caption import BaseCaption CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - - from gi.repository import Gst, GObject # noqa: E402 - - from engine.pytorch_vision_engine import PyTorchVisionEngine + from engine.caption_qwen_engine import CaptionQwenEngine from engine.engine_factory import EngineFactory - from base_caption import BaseCaption except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -37,63 +33,6 @@ ) -class CaptionQwenEngine(PyTorchVisionEngine): - def do_load_model(self, model_name, **kwargs): - """Load a Qwen2.5-VL model from Hugging Face.""" - import torch - from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor - - try: - self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained( - model_name, - torch_dtype="auto", - dtype=torch.float16, - device_map="auto", - ) - self.processor = AutoProcessor.from_pretrained(model_name) - - self.logger.info(f"{model_name} model and processor loaded successfully.") - self.model.eval() - - # Skip .to() for quantized models - if not (hasattr(self.model, "is_quantized") and self.model.is_quantized): - self.execute_with_stream(lambda: self.model.to(self.device)) - self.logger.info(f"Model moved to {self.device}") - - return True - - except Exception as e: - self.logger.error(f"Error loading model '{model_name}': {e}") - self.processor = None - self.model = None - return False - - def _prepare_messages(self, images): - content = [{"type": "image", "image": img} for img in images] - content.append({"type": "text", "text": self.prompt}) - return [{"role": "user", "content": content}] - - def _process_inputs(self, prompt_text, images): - from qwen_vl_utils import process_vision_info - - image_inputs, video_inputs = process_vision_info( - self._prepare_messages(images) - ) # Note: Uses messages directly - return self.processor( - text=[prompt_text], - images=image_inputs, - videos=video_inputs, - padding=True, - return_tensors="pt", - ).to(self.device) - - def _trim_generated_ids(self, inputs, generate_ids): - return [ - out_ids[len(in_ids) :] - for in_ids, out_ids in zip(inputs.input_ids, generate_ids) - ] - - class CaptionQwen(BaseCaption): """ GStreamer element for captioning video frames using Qwen Vision. @@ -113,10 +52,11 @@ def __init__(self): EngineFactory.register(self.engine_name, CaptionQwenEngine) -if CAN_REGISTER_ELEMENT: - GObject.type_register(CaptionQwen, "pyml_caption_qwen") - __gstelementfactory__ = ("pyml_caption_qwen", Gst.Rank.NONE, CaptionQwen) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_caption_qwen", CaptionQwen, "pyml_caption_qwen" + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_caption_qwen' element will not be registered because required modules are missing." ) diff --git a/plugins/python/clap.py b/plugins/python/clap.py index 6edbfbd..2b2bcbe 100644 --- a/plugins/python/clap.py +++ b/plugins/python/clap.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -26,10 +27,11 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") - from gi.repository import Gst, GObject, GstBase + from gi.repository import Gst, GstBase + from backend import GObject from log.logger_factory import LoggerFactory - from engine.pytorch_engine import PyTorchEngine + from engine.clap_engine import ClapEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -39,92 +41,6 @@ # Header prefix for CLAP classification metadata CLAP_META_HEADER = b"GST-CLAP:" -CLAP_SAMPLE_RATE = 48000 - - -class ClapEngine(PyTorchEngine): - """ - PyTorch engine for CLAP audio-text contrastive inference. - - Uses the HuggingFace transformers ClapModel + ClapProcessor to encode - audio waveforms and compare them against precomputed text label embeddings. - """ - - def __init__(self): - super().__init__() - self.processor = None - self.text_embeddings = None - self._labels = [] - - def do_load_model(self, model_name, **kwargs): - try: - from transformers import ClapModel, ClapProcessor - - self.processor = ClapProcessor.from_pretrained(model_name) - self.model = ClapModel.from_pretrained(model_name) - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info(f"CLAP model '{model_name}' loaded on {self.device}") - labels = kwargs.get("labels", []) - if labels: - self._precompute_text_embeddings(labels) - except Exception as e: - raise ValueError(f"Failed to load CLAP model '{model_name}': {e}") - - def _precompute_text_embeddings(self, labels): - """Precompute and cache normalized text embeddings for the label list.""" - import torch - - self._labels = list(labels) - if not self._labels or self.processor is None: - self.text_embeddings = None - return - inputs = self.processor(text=self._labels, return_tensors="pt", padding=True) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - with torch.no_grad(): - text_emb = self.model.get_text_features(**inputs) - self.text_embeddings = text_emb / text_emb.norm(dim=-1, keepdim=True) - self.logger.info(f"Precomputed text embeddings for {len(self._labels)} labels") - - def do_forward(self, audio_waveform): - """ - Encode an audio waveform and compute similarity against text labels. - - Args: - audio_waveform: numpy float32 array of audio samples (mono). - - Returns: - List of (label, score) tuples sorted by descending score, - or None on failure. - """ - import torch - - if self.text_embeddings is None or len(self._labels) == 0: - self.logger.warning("No text labels configured for CLAP inference") - return None - - try: - inputs = self.processor( - audios=audio_waveform, - sampling_rate=CLAP_SAMPLE_RATE, - return_tensors="pt", - ) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - with torch.no_grad(): - audio_emb = self.model.get_audio_features(**inputs) - audio_emb = audio_emb / audio_emb.norm(dim=-1, keepdim=True) - similarities = (audio_emb @ self.text_embeddings.T).squeeze(0) - scores = similarities.cpu().numpy() - - results = [ - (label, float(score)) for label, score in zip(self._labels, scores) - ] - results.sort(key=lambda x: x[1], reverse=True) - return results - except Exception as e: - self.logger.error(f"CLAP inference error: {e}") - return None - class ClapTransform(GstBase.BaseTransform): """ @@ -154,17 +70,19 @@ class ClapTransform(GstBase.BaseTransform): "Aaron Boxer ", ) - AUDIO_CAPS = Gst.Caps.from_string( - "audio/x-raw,format=F32LE,layout=interleaved,rate=48000,channels=1" - ) + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + AUDIO_CAPS = Gst.Caps.from_string( + "audio/x-raw,format=F32LE,layout=interleaved,rate=48000,channels=1" + ) - sink_template = Gst.PadTemplate.new( - "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, AUDIO_CAPS - ) - src_template = Gst.PadTemplate.new( - "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, AUDIO_CAPS - ) - __gsttemplates__ = (sink_template, src_template) + sink_template = Gst.PadTemplate.new( + "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, AUDIO_CAPS + ) + src_template = Gst.PadTemplate.new( + "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, AUDIO_CAPS + ) + __gsttemplates__ = (sink_template, src_template) model_name = GObject.Property( type=str, @@ -279,10 +197,9 @@ def do_transform_ip(self, buf): return Gst.FlowReturn.OK -if CAN_REGISTER_ELEMENT: - GObject.type_register(ClapTransform) - __gstelementfactory__ = ("pyml_clap", Gst.Rank.NONE, ClapTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_clap", ClapTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_clap' element will not be registered because required modules are missing." ) diff --git a/plugins/python/classifier.py b/plugins/python/classifier.py index d9e2538..4af23ce 100644 --- a/plugins/python/classifier.py +++ b/plugins/python/classifier.py @@ -17,15 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject # noqa: E402 from base_classifier import BaseClassifier except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -50,10 +45,9 @@ def __init__(self): super().__init__() -if CAN_REGISTER_ELEMENT: - GObject.type_register(Classifier) - __gstelementfactory__ = ("pyml_classifier", Gst.Rank.NONE, Classifier) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_classifier", Classifier) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_classifier' element will not be registered because a module is missing." ) diff --git a/plugins/python/clip.py b/plugins/python/clip.py index 46241be..6e8d4df 100644 --- a/plugins/python/clip.py +++ b/plugins/python/clip.py @@ -17,109 +17,24 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: import threading - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib - from video_transform import VideoTransform - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.clip_engine import ClipEngine from engine.engine_factory import EngineFactory + from backend import GObject + from tasks.clip import ClipTask except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning(f"The 'clip' element will not be available. Error {e}") -class ClipEngine(PyTorchEngine): - """ - PyTorch engine for CLIP and SigLIP zero-shot image classification. - - Works with any HuggingFace CLIP-compatible model: - openai/clip-vit-base-patch32 - openai/clip-vit-large-patch14 - google/siglip-base-patch16-224 - google/siglip-large-patch16-384 - """ - - def __init__(self): - super().__init__() - self._labels = [] - - @property - def clip_labels(self): - return self._labels - - @clip_labels.setter - def clip_labels(self, value): - self._labels = value - - def do_load_model(self, model_name, **kwargs): - try: - from transformers import AutoProcessor, AutoModel - - self.image_processor = AutoProcessor.from_pretrained(model_name) - self.model = AutoModel.from_pretrained(model_name) - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info(f"CLIP model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load CLIP model '{model_name}': {e}") - - def do_forward(self, frame): - """ - Run zero-shot classification. - - Args: - frame: RGB numpy array [H, W, 3] - - Returns: - List of (label, probability) tuples sorted by probability descending, - or None if no labels are set. - """ - import numpy as np - import torch - from PIL import Image - - if not self._labels: - self.logger.warning("No labels set — set the 'labels' property") - return None - - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - inputs = self.image_processor( - text=self._labels, - images=pil_img, - return_tensors="pt", - padding=True, - ) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = self.model(**inputs) - - # logits_per_image: [1, num_labels] - probs = outputs.logits_per_image.softmax(dim=1)[0] - results = [(label, prob.item()) for label, prob in zip(self._labels, probs)] - results.sort(key=lambda x: x[1], reverse=True) - return results - - except Exception as e: - self.logger.error(f"CLIP inference error: {e}") - return None - - -class CLIPTransform(VideoTransform): +class CLIPTransform(VideoTransform, ClipTask): """ GStreamer element for zero-shot image classification using CLIP or SigLIP. @@ -134,7 +49,7 @@ class CLIPTransform(VideoTransform): google/siglip-large-patch16-384 (SigLIP large) Example pipeline: - gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin \\ + python pyml-launch.py filesrc location=data/people.mp4 ! decodebin \\ ! videoconvert ! videoscale ! video/x-raw,width=640,height=480 \\ ! pyml_clip model-name=openai/clip-vit-base-patch32 device=cuda \\ labels="person, bicycle, car, dog, cat" top-k=3 \\ @@ -212,8 +127,7 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_clip") - def do_start(self): - result = super().do_start() + def on_start(self): # Push labels into the engine after it has been initialised if self.engine and self._labels_list: self.engine.clip_labels = self._labels_list @@ -223,7 +137,6 @@ def do_start(self): target=self._inference_worker, daemon=True ) self._infer_thread.start() - return result def do_stop(self): self._running = False @@ -248,64 +161,31 @@ def _inference_worker(self): with self._infer_lock: self._last_results = results - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, _ = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - # Use first frame for classification (batch not typical for CLIP) - frame = frames[0] if num_sources > 1 else frames - - if self.engine: - self.engine.clip_labels = self._labels_list - - # Post frame to background thread; never block the streaming thread - with self._infer_lock: - self._pending_frame = frame.copy() - self._infer_event.set() - - with self._infer_lock: - results = self._last_results + def process_frames(self, frames, num_sources, fmt, target): + """Hand the frame to the inference thread and decode the latest result.""" + # Use first frame for classification (batch not typical for CLIP) + frame = frames[0] if num_sources > 1 else frames - if results is None: - return Gst.FlowReturn.OK + if self.engine: + self.engine.clip_labels = self._labels_list - self._attach_metadata(buf, results) - return Gst.FlowReturn.OK + # Post frame to background thread; never block the streaming thread + with self._infer_lock: + self._pending_frame = frame.copy() + self._infer_event.set() - except Exception as e: - self.logger.error(f"CLIP transform error: {e}") - return Gst.FlowReturn.ERROR + with self._infer_lock: + results = self._last_results - def _attach_metadata(self, buf, results): - """Attach top-k classification results above threshold as GstAnalytics metadata.""" - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if not meta: - self.logger.error("Failed to add analytics relation metadata") + if results is None: return - attached = 0 - for label, prob in results: - if attached >= self.top_k: - break - if prob < self.threshold: - break - - qk = GLib.quark_from_string(f"clip_{label.replace(' ', '_')}") - ret, _ = meta.add_od_mtd(qk, 0, 0, self.width, self.height, prob) - if ret: - attached += 1 - self.logger.info(f"CLIP: {label} = {prob:.3f}") + self.decode(target, results) -if CAN_REGISTER_ELEMENT: - GObject.type_register(CLIPTransform) - __gstelementfactory__ = ("pyml_clip", Gst.Rank.NONE, CLIPTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_clip", CLIPTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_clip' element will not be registered because required modules are missing." ) diff --git a/plugins/python/coalescehistory.py b/plugins/python/coalescehistory.py index 8d77b52..7d6245e 100644 --- a/plugins/python/coalescehistory.py +++ b/plugins/python/coalescehistory.py @@ -17,9 +17,10 @@ # Boston, MA 02110-1301, USA. +import backend + CAN_REGISTER_ELEMENT = True try: - import collections import gi @@ -42,20 +43,22 @@ class CoalesceHistory(Gst.Element): "Olivier Crête ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - ) + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string("text/x-raw,format=utf8"), + ), + Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string("text/x-raw,format=utf8"), + ), + ) @GObject.Property(type=GObject.TYPE_UINT) def history_length(self): @@ -120,6 +123,7 @@ def do_change_state(self, state_change): return ret -if CAN_REGISTER_ELEMENT: - GObject.type_register(CoalesceHistory) - __gstelementfactory__ = ("coalescehistory", Gst.Rank.NONE, CoalesceHistory) +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "coalescehistory", CoalesceHistory + ) diff --git a/plugins/python/coquitts.py b/plugins/python/coquitts.py index ef4d3bc..c504b92 100644 --- a/plugins/python/coquitts.py +++ b/plugins/python/coquitts.py @@ -17,14 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - from gi.repository import Gst, GObject, GstBase # noqa: E402 from base_tts import BaseTts except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -32,17 +28,14 @@ f"The 'pyml_coquitts' element will not be available. Error: {e}" ) -TTS_SAMPLE_RATE = 22050 +if backend.BACKEND == "gst": + import gi -OCAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=TTS_SAMPLE_RATE, - channels=1, - ) -) + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 + +TTS_SAMPLE_RATE = 22050 class CoquiTTS(BaseTts): @@ -53,15 +46,20 @@ class CoquiTTS(BaseTts): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OCAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) + # the rate has to match TTS_SAMPLE_RATE, which the element reports downstream + OUTPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=22050,channels=1" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + ) def do_load_model(self): from TTS.api import TTS @@ -85,10 +83,9 @@ def do_get_sample_rate(self): return TTS_SAMPLE_RATE -if CAN_REGISTER_ELEMENT: - GObject.type_register(CoquiTTS) - __gstelementfactory__ = ("pyml_coquitts", Gst.Rank.NONE, CoquiTTS) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_coquitts", CoquiTTS) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_coquitts' element will not be registered because required modules are missing." ) diff --git a/plugins/python/demo_soccer.py b/plugins/python/demo_soccer.py index 2fb2997..1b133ef 100644 --- a/plugins/python/demo_soccer.py +++ b/plugins/python/demo_soccer.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -25,45 +26,19 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib # noqa: E402 + from gi.repository import Gst # noqa: E402 + from backend import analytics, GObject # noqa: E402 from base_objectdetector import BaseObjectDetector import os from collections import deque - from engine.pytorch_engine import PyTorchEngine from engine.engine_factory import EngineFactory - - BOT_OK = BYTE_OK = CFG_OK = True - BOTSORT = BYTETracker = get_cfg = Boxes = None - - def _init_ultralytics(): - global BOT_OK, BYTE_OK, CFG_OK, BOTSORT, BYTETracker, get_cfg, Boxes - try: - from ultralytics.trackers.bot_sort import BOTSORT as _BS - - BOTSORT = _BS - except Exception: - BOT_OK = False - try: - from ultralytics.trackers.byte_tracker import BYTETracker as _BT - - BYTETracker = _BT - except Exception: - BYTE_OK = False - try: - from ultralytics.cfg import get_cfg as _gcfg - - get_cfg = _gcfg - except Exception: - CFG_OK = False - try: - from ultralytics.engine.results import Boxes as _Boxes - - Boxes = _Boxes - except Exception: - pass + from engine.yolo_advanced_engine import ( + YoloAdvancedEngine, + BoTSORTWrapper, + ByteTrackWrapper, + tlbr_of, + ) except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -108,1014 +83,6 @@ def _init_ultralytics(): } -def eye3(): - import numpy as np - - return np.eye(3, dtype=np.float32) - - -def estimate_global_motion( - prev_gray, - gray, - gmc_mode, - gmc_scale, - gft_max_corners, - gft_quality, - gft_min_dist, - lk_win, - lk_levels, - ransac_thresh, - frame_idx, - verbose=False, -): - import cv2 - import numpy as np - - if gmc_mode == "off": - if verbose: - print(f"[frame {frame_idx}] GMC OFF → I") - return eye3() - - def down(img): - if gmc_scale == 1.0: - return img - w = max(2, int(img.shape[1] * gmc_scale)) - h = max(2, int(img.shape[0] * gmc_scale)) - return cv2.resize(img, (w, h), interpolation=cv2.INTER_AREA) - - pg, cg = down(prev_gray), down(gray) - pts_prev = cv2.goodFeaturesToTrack( - pg, - maxCorners=gft_max_corners, - qualityLevel=gft_quality, - minDistance=gft_min_dist, - ) - if pts_prev is None or len(pts_prev) < 6: - if verbose: - print(f"[frame {frame_idx}] GMC: insufficient corners → I") - return eye3() - - pts_curr, st, _ = cv2.calcOpticalFlowPyrLK( - pg, - cg, - pts_prev, - None, - winSize=(lk_win, lk_win), - maxLevel=lk_levels, - criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01), - ) - if pts_curr is None or st is None: - if verbose: - print(f"[frame {frame_idx}] GMC: LK failed → I") - return eye3() - - m = st.reshape(-1).astype(bool) - if m.sum() < (4 if gmc_mode == "homography" else 3): - if verbose: - print(f"[frame {frame_idx}] GMC: not enough inliers → I") - return eye3() - - src = pts_prev[m] - dst = pts_curr[m] - if gmc_scale != 1.0: - s = 1.0 / gmc_scale - src *= s - dst *= s - - if gmc_mode == "homography": - H, _ = cv2.findHomography( - src, - dst, - cv2.RANSAC, - ransacReprojThreshold=ransac_thresh, - maxIters=1000, - ) - H = H.astype(np.float32) if H is not None else eye3() - else: - A, _ = cv2.estimateAffine2D( - src, dst, ransacReprojThreshold=ransac_thresh, maxIters=1000 - ) - H = np.vstack([A, [0, 0, 1]]).astype(np.float32) if A is not None else eye3() - - avg = float(np.mean(np.linalg.norm(dst - src, axis=1))) if len(src) > 0 else 0.0 - if verbose: - print( - f"[frame {frame_idx}] GMC {gmc_mode} inliers={int(m.sum())} avg_motion={avg:.2f}px" - ) - return H - - -def warp_points(points_xy, M): - import numpy as np - - if not points_xy: - return [] - P = np.c_[ - np.array(points_xy, dtype=np.float32), np.ones((len(points_xy), 1), np.float32) - ] - Q = (M @ P.T).T - Q = Q[:, :2] / np.clip(Q[:, 2:3], 1e-6, None) - return [tuple(q) for q in Q] - - -def classwise_keep(result, person_thr, ball_thr): - import numpy as np - - if result is None or result.boxes is None or len(result.boxes) == 0: - return np.zeros((0, 6), np.float32), np.zeros((0, 6), np.float32) - - b = result.boxes - xyxy = b.xyxy.cpu().numpy() - conf = ( - b.conf.cpu().numpy() if b.conf is not None else np.ones((len(b),), np.float32) - ) - cls = ( - b.cls.cpu().numpy().astype(int) - if b.cls is not None - else np.zeros((len(b),), np.int32) - ) - - keep_p = (cls == 0) & (conf >= person_thr) - keep_b = (cls == 32) & (conf >= ball_thr) - - dets_p = np.c_[xyxy[keep_p], conf[keep_p], cls[keep_p]].astype(np.float32) - dets_b = np.c_[xyxy[keep_b], conf[keep_b], cls[keep_b]].astype(np.float32) - return dets_p, dets_b - - -def dets_to_boxes(dets_xyxy_conf_cls, frame_shape): - _init_ultralytics() - if dets_xyxy_conf_cls is None or dets_xyxy_conf_cls.size == 0: - import torch as _torch - - data = _torch.zeros((0, 6), dtype=_torch.float32) - return Boxes(data, frame_shape) - import torch as _torch - - data = _torch.from_numpy(dets_xyxy_conf_cls).to(_torch.float32) - return Boxes(data, frame_shape) - - -def clamp_imgsz_for_device(imgsz, device_str): - if device_str in ("cpu", "auto"): - return min(imgsz, 1280) - return imgsz - - -def expand_roi(xyxy, scale, W, H, min_side=256, max_side=1920): - x1, y1, x2, y2 = map(float, xyxy) - cx, cy = (x1 + x2) * 0.5, (y1 + y2) * 0.5 - w, h = (x2 - x1), (y2 - y1) - side = max(w, h) * float(scale) - side = max(min_side, min(side, max_side)) - x1n = max(0, int(cx - side * 0.5)) - y1n = max(0, int(cy - side * 0.5)) - x2n = min(W - 1, int(cx + side * 0.5)) - y2n = min(H - 1, int(cy + side * 0.5)) - return x1n, y1n, x2n, y2n - - -def _normalize_tracker_args(args, kind="byte"): - def has(a): - return hasattr(args, a) and getattr(args, a) is not None - - def setif(a, v): - setattr(args, a, v) - - def copy_if_missing(target, *sources, default=None): - if not has(target): - for s in sources: - if has(s): - setif(target, getattr(args, s)) - return - if default is not None: - setif(target, default) - - copy_if_missing("track_high_thresh", "track_thresh", default=0.5) - copy_if_missing("track_thresh", "track_high_thresh", default=0.5) - copy_if_missing("track_low_thresh", default=0.1) - copy_if_missing("new_track_thresh", default=0.4) - copy_if_missing("match_thresh", default=0.8) - copy_if_missing("asso_thresh", "match_thresh", default=0.8) - copy_if_missing("track_buffer", default=30) - copy_if_missing("frame_rate", default=30) - copy_if_missing("fuse_score", default=True) - copy_if_missing("fuse_score_coef", default=1.0) - copy_if_missing("mot20", default=False) - - if kind == "botsort": - copy_if_missing("with_reid", default=True) - copy_if_missing("proximity_thresh", default=0.5) - copy_if_missing("appearance_thresh", default=0.25) - if has("cmc_method") and not has("gmc_method"): - setif("gmc_method", getattr(args, "cmc_method")) - copy_if_missing("gmc_method", default="sparseOptFlow") - - -class ByteTrackWrapper: - """Ultralytics BYTETracker; load params from YAML using get_cfg, accept Boxes.""" - - def __init__(self, yaml_path, frame_rate): - if not BYTE_OK: - raise RuntimeError("Ultralytics BYTETracker not available") - if not CFG_OK: - raise RuntimeError( - "Ultralytics get_cfg not available; update ultralytics package." - ) - args = get_cfg(yaml_path) - args.frame_rate = int(frame_rate) - _normalize_tracker_args(args, kind="byte") - self.tracker = BYTETracker(args, frame_rate=int(frame_rate)) - - def update(self, boxes: Boxes, frame): - return self.tracker.update(boxes, frame) - - -class BoTSORTWrapper: - """Ultralytics BOTSORT; load params via get_cfg, accept Boxes, safe ReID encoder.""" - - def __init__(self, yaml_path, frame_rate, enable_reid=True): - if not BOT_OK: - raise RuntimeError("Ultralytics BOTSORT not available") - if not CFG_OK: - raise RuntimeError( - "Ultralytics get_cfg not available; update ultralytics package." - ) - args = get_cfg(yaml_path) - args.frame_rate = int(frame_rate) - args.with_reid = bool(enable_reid) - _normalize_tracker_args(args, kind="botsort") - self.tracker = BOTSORT(args, frame_rate=int(frame_rate)) - self._install_safe_encoder() - - def _install_safe_encoder(self): - import cv2 - import numpy as np - import torch as _torch - - def _safe_hsv_hist(img_bgr, bboxes): - feats = [] - if img_bgr is None or bboxes is None: - return feats - - if hasattr(bboxes, "detach"): - bb = bboxes.detach().cpu().numpy() - elif isinstance(bboxes, np.ndarray): - bb = bboxes - else: - try: - bb = np.asarray(bboxes, dtype=np.float32) - except Exception: - bb = None - - H, W = img_bgr.shape[:2] - - def process_one(b): - b = np.asarray(b, dtype=np.float32).reshape(-1) - x1, y1, x2, y2 = map(float, b[:4]) - x1i = max(0, min(int(x1), W - 1)) - y1i = max(0, min(int(y1), H - 1)) - x2i = max(0, min(int(x2), W - 1)) - y2i = max(0, min(int(y2), H - 1)) - if x2i <= x1i or y2i <= y1i: - return _torch.zeros(512, dtype=_torch.float32) - crop = img_bgr[y1i:y2i, x1i:x2i] - if crop.size == 0: - return _torch.zeros(512, dtype=_torch.float32) - hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) - hist = cv2.calcHist( - [hsv], [0, 1, 2], None, [8, 8, 8], [0, 180, 0, 256, 0, 256] - ).flatten() - norm = np.linalg.norm(hist) + 1e-6 - hist = (hist / norm).astype(np.float32) - return _torch.from_numpy(hist) - - if bb is not None: - for b in bb: - feats.append(process_one(b)) - else: - for b in bboxes: - feats.append(process_one(b)) - return feats - - self.tracker.encoder = lambda img, tlbrs: _safe_hsv_hist(img, tlbrs) - - def update(self, boxes: Boxes, frame): - return self.tracker.update(boxes, frame) - - -def _callable_or_attr(obj, name): - v = getattr(obj, name, None) - if v is None: - return None - return v() if callable(v) else v - - -def tlbr_of(tr): - a = _callable_or_attr(tr, "tlbr") - if a is not None: - a = a.tolist() if hasattr(a, "tolist") else a - if len(a) == 4: - return a - a = _callable_or_attr(tr, "tlwh") - if a is not None: - a = a.tolist() if hasattr(a, "tolist") else a - if len(a) == 4: - x, y, w, h = a - return [x, y, x + w, y + h] - if hasattr(tr, "bbox"): - b = tr.bbox - return b.tolist() if hasattr(b, "tolist") else list(b) - return None - - -class BallState: - def __init__(self): - self.cx = None - self.cy = None - self.vx = 0.0 - self.vy = 0.0 - self.frame = -1 - - def predict(self, frame_idx, decay=0.85): - if self.cx is None: - return None - return (self.cx + decay * self.vx, self.cy + decay * self.vy) - - def update_from_xyxy(self, xyxy, frame_idx): - x1, y1, x2, y2 = map(float, xyxy) - cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2) - if self.cx is not None and self.frame >= 0: - dt = max(1, frame_idx - self.frame) - self.vx = (cx - self.cx) / dt - self.vy = (cy - self.cy) / dt - self.cx, self.cy, self.frame = cx, cy, frame_idx - - def update_from_center(self, cx, cy, frame_idx): - if self.cx is not None and self.frame >= 0: - dt = max(1, frame_idx - self.frame) - self.vx = (cx - self.cx) / dt - self.vy = (cy - self.cy) / dt - self.cx, self.cy, self.frame = float(cx), float(cy), int(frame_idx) - - -def _aspect_round_penalty(w, h): - import numpy as np - - ar = w / max(h, 1e-6) - roundness = np.exp(-((ar - 1.0) ** 2) / 0.15) - return 1.0 - float(roundness) - - -def _size_penalty(w, h, H, W): - import numpy as np - - s = max(w, h) - tgt = 0.03 * min(H, W) - return float(np.clip(abs(s - tgt) / (tgt + 1e-6), 0.0, 2.0)) * 0.5 - - -def select_best_ball( - dets_b, - frame_shape, - ball_state, - frame_idx, - w_conf=1.0, - w_dist=0.015, - w_size=0.5, - w_round=0.4, -): - import numpy as np - - if dets_b is None or len(dets_b) == 0: - return None - H, W = frame_shape - pred = ball_state.predict(frame_idx) - scores = [] - for d in dets_b: - x1, y1, x2, y2, conf, _ = d - cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2) - w, h = (x2 - x1), (y2 - y1) - s_conf = float(conf) - if pred is None: - dist_pen = 0.0 - else: - px, py = pred - dist = np.hypot(cx - px, cy - py) - dist_pen = float(dist / (0.5 * (H + W))) - size_pen = _size_penalty(w, h, H, W) - round_pen = _aspect_round_penalty(w, h) - s = ( - w_conf * s_conf - - w_dist * dist_pen - - w_size * size_pen - - w_round * round_pen - ) - scores.append(s) - if not scores: - return None - return int(np.argmax(scores)) - - -def nms_class(dets, iou_thr=0.5): - import numpy as np - - if dets is None or len(dets) == 0: - return dets - boxes = dets[:, :4].copy() - scores = dets[:, 4].copy() - order = scores.argsort()[::-1] - keep = [] - - def iou(a, b): - xx1 = np.maximum(a[0], b[0]) - yy1 = np.maximum(a[1], b[1]) - xx2 = np.minimum(a[2], b[2]) - yy2 = np.minimum(a[3], b[3]) - w = np.maximum(0.0, xx2 - xx1) - h = np.maximum(0.0, yy2 - yy1) - inter = w * h - area_a = (a[2] - a[0]) * (a[3] - a[1]) - area_b = (b[2] - b[0]) * (b[3] - b[1]) - return inter / (area_a + area_b - inter + 1e-6) - - while order.size > 0: - i = order[0] - keep.append(i) - if order.size == 1: - break - ious = np.array([iou(boxes[i], boxes[j]) for j in order[1:]]) - remain = np.where(ious <= iou_thr)[0] - order = order[remain + 1] - return dets[keep] - - -def add_trail_point(seq: deque, x: int, y: int, k: int, densify=True, max_gap=5): - if len(seq) > 0 and densify: - _, _, k_prev = seq[-1] - gap = k - k_prev - if 1 < gap <= max_gap: - x_prev, y_prev, _ = seq[-1] - for t in range(1, gap): - alpha = t / gap - xi = int(round((1 - alpha) * x_prev + alpha * x)) - yi = int(round((1 - alpha) * y_prev + alpha * y)) - seq.append((xi, yi, k_prev + t)) - seq.append((int(x), int(y), int(k))) - - -def iou_xyxy(a, b): - if a is None or b is None: - return 0.0 - ax1, ay1, ax2, ay2 = a - bx1, by1, bx2, by2 = b - xx1 = max(ax1, bx1) - yy1 = max(ay1, by1) - xx2 = min(ax2, bx2) - yy2 = min(ay2, by2) - w = max(0.0, xx2 - xx1) - h = max(0.0, yy2 - yy1) - inter = w * h - if inter <= 0: - return 0.0 - area_a = max(0.0, (ax2 - ax1)) * max(0.0, (ay2 - ay1)) - area_b = max(0.0, (bx2 - bx1)) * max(0.0, (by2 - by1)) - denom = area_a + area_b - inter + 1e-6 - return float(inter / denom) - - -def lerp(a, b, t): - return a * (1.0 - t) + b * t - - -def gate_accept( - center, - cand_box, - trail_seq, - last_shown_box, - recent_speed, - frame_shape, - args, - pred=None, - from_track=True, -): - import numpy as np - - if center is None: - return False - - H, W = frame_shape - hard_cap = float(args.ball_max_jump_rel) * float(min(H, W)) - - if len(trail_seq) == 0: - dist_prev = 0.0 - else: - x_prev, y_prev, _ = trail_seq[-1] - dist_prev = float(np.hypot(center[0] - x_prev, center[1] - y_prev)) - - if dist_prev > hard_cap: - return False - - base_gate = max( - float(args.ball_gate_min), float(args.ball_gate_rel) * float(min(H, W)) - ) - gate_px = base_gate if from_track else base_gate * 1.25 - pass_prev = (len(trail_seq) == 0) or (dist_prev <= gate_px) - - pred_ok = False - if getattr(args, "ball_gate_use_pred", False) and pred is not None: - d_pred = float(np.hypot(center[0] - pred[0], center[1] - pred[1])) - pred_ok = d_pred <= gate_px * 1.25 - - if not from_track: - return pass_prev or pred_ok - - iou_ok = (last_shown_box is None) or ( - iou_xyxy(cand_box, last_shown_box) >= float(args.ball_min_iou) - ) - - speed_ok = True - if recent_speed is not None and recent_speed > 0: - speed_ok = dist_prev <= float(args.ball_speed_mult) * float(recent_speed + 1e-6) - - return (pass_prev and iou_ok and speed_ok) or pred_ok - - -def safe_int_pair(wx, wy, W, H): - import numpy as np - - if wx is None or wy is None: - return None - if not (np.isfinite(wx) and np.isfinite(wy)): - return None - try: - xi = int(round(float(wx))) - yi = int(round(float(wy))) - except Exception: - return None - if abs(xi) > 10 * W or abs(yi) > 10 * H: - return None - return xi, yi - - -class YoloAdvancedEngine(PyTorchEngine): - def __init__(self, device=None, **kwargs): - super().__init__(device=device) - # Then set self.device_str = device if device else 'auto' - self.device_str = device if device else "auto" - self.det_model = None - self.fb_model = None - self.people_tracker = None - self.ball_tracker = None - self.ball_state = BallState() - self.single_ball_trail = deque(maxlen=kwargs.get("trail", 200)) - self.cum_H_history = [eye3()] - self.cum_H = eye3() - self.prev_gray = None - self.last_ball_xyxy = None - self.last_shown_box = None - self.recent_speed = None - self.ema_cxcy = None - self.coast_streak = 0 - self.det_reject_streak = 0 - self.dropped_by_gate = 0 - self.coast_used = 0 - self.frame_idx = 0 - self.frame_rate = kwargs.get("frame_rate", 30.0) - # Set all params from kwargs - self.device_str = kwargs.get("device", "auto") - self.imgsz = kwargs.get("imgsz", 1280) - self.conf = kwargs.get("conf", 0.25) - self.iou = kwargs.get("iou", 0.45) - self.classes = kwargs.get("classes", [0, 32]) - self.person_conf_keep = kwargs.get("person_conf_keep", 0.25) - self.ball_conf_keep = kwargs.get("ball_conf_keep", 0.04) - self.ball_mode = kwargs.get("ball_mode", True) - self.hires_fallback = kwargs.get("hires_fallback", True) - self.hires_imgsz = kwargs.get("hires_imgsz", 1536) - self.fallback_every = kwargs.get("fallback_every", 6) - self.fallback_tiles = kwargs.get("fallback_tiles", False) - self.tile_size = kwargs.get("tile_size", 1280) - self.tile_overlap = kwargs.get("tile_overlap", 256) - self.fallback_budget_ms = kwargs.get("fallback_budget_ms", 300) - self.ball_roi_boost = kwargs.get("ball_roi_boost", False) - self.roi_scale = kwargs.get("roi_scale", 2.5) - self.roi_min = kwargs.get("roi_min", 256) - self.roi_max = kwargs.get("roi_max", 1920) - self.tracker_people = kwargs.get("tracker_people", "botsort_people_reid.yaml") - self.tracker_ball = kwargs.get("tracker_ball", "bytetrack_ball.yaml") - self.people_reid = kwargs.get("people_reid", True) - self.trail = kwargs.get("trail", 200) - self.gmc = kwargs.get("gmc", "affine") - self.gmc_scale = kwargs.get("gmc_scale", 0.5) - self.gft_max_corners = kwargs.get("gft_max_corners", 400) - self.gft_quality = kwargs.get("gft_quality", 0.01) - self.gft_min_dist = kwargs.get("gft_min_dist", 8) - self.lk_win = kwargs.get("lk_win", 21) - self.lk_levels = kwargs.get("lk_levels", 3) - self.ransac_thresh = kwargs.get("ransac_thresh", 3.0) - self.ball_gate_rel = kwargs.get("ball_gate_rel", 0.06) - self.ball_gate_min = kwargs.get("ball_gate_min", 12) - self.ball_gate_use_pred = kwargs.get("ball_gate_use_pred", False) - self.ball_min_iou = kwargs.get("ball_min_iou", 0.20) - self.ball_max_jump_rel = kwargs.get("ball_max_jump_rel", 0.12) - self.ball_speed_mult = kwargs.get("ball_speed_mult", 3.0) - self.ball_smooth_ema = kwargs.get("ball_smooth_ema", 0.0) - self.det_override_conf = kwargs.get("det_override_conf", 0.28) - self.det_override_after = kwargs.get("det_override_after", 2) - self.reacquire_frames = kwargs.get("reacquire_frames", 6) - self.ball_coast = kwargs.get("ball_coast", False) - self.coast_max = kwargs.get("coast_max", 6) - self.coast_decay = kwargs.get("coast_decay", 0.90) - self.verbose = kwargs.get("verbose", False) - - def do_load_model(self, model_name, **kwargs): - _init_ultralytics() - try: - from ultralytics import YOLO - - # YOLO load unchanged... - self.det_model = YOLO(f"{model_name}.pt") - self.execute_with_stream(lambda: self.det_model.to(self.device)) - self.logger.info( - f"YOLO primary model '{model_name}' loaded on {self.device}" - ) - - if self.hires_fallback: - self.fb_model = YOLO(f"{model_name}.pt") - self.execute_with_stream(lambda: self.fb_model.to(self.device)) - - self.model = self.det_model # Alias for base compat - - # Trackers with fallback - if self.tracker_people: - try: - self.people_tracker = BoTSORTWrapper( - self.tracker_people, self.frame_rate, self.people_reid - ) - self.logger.info( - f"People tracker loaded from {self.tracker_people}" - ) - except Exception as te: - self.logger.warning(f"People tracker failed ({te}); disabling.") - self.people_tracker = None - - if self.tracker_ball: - try: - self.ball_tracker = ByteTrackWrapper( - self.tracker_ball, self.frame_rate - ) - self.logger.info(f"Ball tracker loaded from {self.tracker_ball}") - except Exception as te: - self.logger.warning(f"Ball tracker failed ({te}); disabling.") - self.ball_tracker = None - - # ... kwargs update unchanged ... - return self.tracker_people and self.tracker_ball - - except Exception as e: - self.logger.error(f"Core model load failed: {e}") - return False # No raise—let base handle - - def do_forward(self, frames): - import cv2 - import numpy as np - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if is_batch: - frame_bgr = frames[0] # Assume single for stateful; extend if needed - else: - frame_bgr = np.array(frames, copy=True) - gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) - Hh, Ww = frame_bgr.shape[:2] - - # GMC - if self.prev_gray is not None: - H = estimate_global_motion( - self.prev_gray, - gray, - self.gmc, - self.gmc_scale, - self.gft_max_corners, - self.gft_quality, - self.gft_min_dist, - self.lk_win, - self.lk_levels, - self.ransac_thresh, - self.frame_idx, - self.verbose, - ) - self.cum_H = H @ self.cum_H - self.cum_H_history.append(self.cum_H.copy()) - self.prev_gray = gray - - # Detection - det_res = self.execute_with_stream( - lambda: self.det_model.predict( - frame_bgr, - imgsz=self.imgsz, - conf=self.conf, - iou=self.iou, - classes=self.classes, - device=self.device_str if self.device_str != "auto" else None, - verbose=False, - )[0] - ) - - dets_p, dets_b = classwise_keep( - det_res, self.person_conf_keep, self.ball_conf_keep - ) - - dets_b = nms_class(dets_b, iou_thr=0.35) - best_idx = select_best_ball( - dets_b, frame_bgr.shape[:2], self.ball_state, self.frame_idx - ) - if best_idx is not None: - dets_b = dets_b[[best_idx]] - self.ball_state.update_from_xyxy(dets_b[0, :4], self.frame_idx) - self.last_ball_xyxy = dets_b[0, :4].astype(int).tolist() - else: - dets_b = dets_b[:0] - - # Fallback logic - if ( - self.ball_mode - and self.hires_fallback - and dets_b.shape[0] == 0 - and self.frame_idx % max(1, self.fallback_every) == 0 - ): - clamp_imgsz = clamp_imgsz_for_device(self.hires_imgsz, self.device_str) - collected = [] - - if ( - self.ball_roi_boost - and self.last_ball_xyxy is not None - and self.fb_model is not None - ): - x1r, y1r, x2r, y2r = expand_roi( - self.last_ball_xyxy, - self.roi_scale, - Ww, - Hh, - min_side=self.roi_min, - max_side=self.roi_max, - ) - crop = frame_bgr[y1r:y2r, x1r:x2r] - pred = self.execute_with_stream( - lambda: self.fb_model.predict( - crop, - imgsz=min(max(x2r - x1r, y2r - y1r), clamp_imgsz), - conf=max(self.ball_conf_keep, 0.02), - iou=max(self.iou, 0.50), - classes=[32], - device=self.device_str if self.device_str != "auto" else None, - verbose=False, - )[0] - ) - if pred.boxes is not None and len(pred.boxes) > 0: - b = pred.boxes - xyxy = b.xyxy.cpu().numpy() - conf = ( - b.conf.cpu().numpy() - if b.conf is not None - else np.ones((len(b),), np.float32) - ) - cls = np.full((len(b),), 32, dtype=np.float32) - xyxy[:, [0, 2]] += x1r - xyxy[:, [1, 3]] += y1r - collected.append(np.c_[xyxy, conf, cls]) - - if self.fb_model is not None and not collected: - pred = self.execute_with_stream( - lambda: self.fb_model.predict( - frame_bgr, - imgsz=clamp_imgsz, - conf=max(self.ball_conf_keep, 0.02), - iou=max(self.iou, 0.50), - classes=[32], - device=self.device_str if self.device_str != "auto" else None, - verbose=False, - )[0] - ) - if pred.boxes is not None and len(pred.boxes) > 0: - b = pred.boxes - xyxy = b.xyxy.cpu().numpy() - conf = ( - b.conf.cpu().numpy() - if b.conf is not None - else np.ones((len(b),), np.float32) - ) - cls = np.full((len(b),), 32, dtype=np.float32) - collected.append(np.c_[xyxy, conf, cls]) - - if collected: - dets_b = np.vstack(collected).astype(np.float32) - dets_b = nms_class(dets_b, iou_thr=0.35) - best_idx = select_best_ball( - dets_b, frame_bgr.shape[:2], self.ball_state, self.frame_idx - ) - if best_idx is not None: - dets_b = dets_b[[best_idx]] - self.ball_state.update_from_xyxy(dets_b[0, :4], self.frame_idx) - self.last_ball_xyxy = dets_b[0, :4].astype(int).tolist() - else: - dets_b = dets_b[:0] - - frame_shape = frame_bgr.shape[:2] - boxes_p = dets_to_boxes(dets_p, frame_shape) - boxes_b = dets_to_boxes(dets_b, frame_shape) - tracks_p = ( - self.people_tracker.update(boxes_p, frame_bgr) - if self.people_tracker - else [] - ) - tracks_b = ( - self.ball_tracker.update(boxes_b, frame_bgr) if self.ball_tracker else [] - ) - - # Ball candidate selection and gating - cand_box = None - ball_center_candidate = None - cand_conf = None - from_track = False - - if len(tracks_b) >= 1: - tr = tracks_b[0] - tb = tlbr_of(tr) - if tb is not None: - x1, y1, x2, y2 = map(int, tb) - cand_box = [x1, y1, x2, y2] - ball_center_candidate = ((x1 + x2) // 2, (y1 + y2) // 2) - from_track = True - cand_conf = None - elif dets_b.shape[0] == 1: - x1, y1, x2, y2 = map(int, dets_b[0, :4]) - cand_box = [x1, y1, x2, y2] - ball_center_candidate = ((x1 + x2) // 2, (y1 + y2) // 2) - cand_conf = float(dets_b[0, 4]) - from_track = False - - pred_pos = ( - self.ball_state.predict(self.frame_idx) if self.ball_gate_use_pred else None - ) - accept = gate_accept( - ball_center_candidate, - cand_box, - self.single_ball_trail, - self.last_shown_box, - self.recent_speed, - frame_bgr.shape[:2], - self, # Use self as args - pred=pred_pos, - from_track=from_track, - ) - - # Det override logic - if (not accept) and (ball_center_candidate is not None) and (not from_track): - self.det_reject_streak += 1 - gap_frames = ( - (self.frame_idx - self.single_ball_trail[-1][2]) - if len(self.single_ball_trail) - else 9999 - ) - Hmin = float(min(Hh, Ww)) - base_gate = max(float(self.ball_gate_min), float(self.ball_gate_rel) * Hmin) - - if cand_conf is not None and cand_conf >= float(self.det_override_conf): - x_prev, y_prev = ( - (self.single_ball_trail[-1][0], self.single_ball_trail[-1][1]) - if self.single_ball_trail - else (ball_center_candidate[0], ball_center_candidate[1]) - ) - dist_prev = float( - np.hypot( - ball_center_candidate[0] - x_prev, - ball_center_candidate[1] - y_prev, - ) - ) - if ( - (self.det_reject_streak >= int(self.det_override_after)) - or (gap_frames >= int(self.reacquire_frames)) - or (dist_prev <= 2.5 * base_gate) - ): - accept = True # force accept the detection - else: - self.det_reject_streak = 0 - - if accept and ball_center_candidate is not None: - cx_raw, cy_raw = ball_center_candidate - alpha = float(self.ball_smooth_ema) - if 0.0 < alpha <= 1.0: - if self.ema_cxcy is None: - self.ema_cxcy = (float(cx_raw), float(cy_raw)) - else: - self.ema_cxcy = ( - lerp(self.ema_cxcy[0], float(cx_raw), alpha), - lerp(self.ema_cxcy[1], float(cy_raw), alpha), - ) - cx, cy = int(round(self.ema_cxcy[0])), int(round(self.ema_cxcy[1])) - else: - cx, cy = cx_raw, cy_raw - - if from_track: - x1, y1, x2, y2 = cand_box - # No drawing here, as it's engine - - # Update trail - if ( - len(self.single_ball_trail) >= 1 - and self.single_ball_trail[-1][2] <= self.frame_idx - 2 - ): - x_prev, y_prev, k_prev = self.single_ball_trail[-1] - if self.frame_idx - k_prev == 2: - add_trail_point( - self.single_ball_trail, - (x_prev + cx) // 2, - (y_prev + cy) // 2, - k_prev + 1, - densify=False, - ) - - add_trail_point( - self.single_ball_trail, cx, cy, self.frame_idx, densify=True, max_gap=5 - ) - - if from_track: - self.ball_state.update_from_xyxy(cand_box, self.frame_idx) - else: - if cand_box is not None: - self.ball_state.update_from_center(cx, cy, self.frame_idx) - - self.last_shown_box = ( - cand_box if cand_box is not None else self.last_shown_box - ) - - if len(self.single_ball_trail) >= 2: - x0, y0, _ = self.single_ball_trail[-2] - step = float(np.hypot(cx - x0, cy - y0)) - self.recent_speed = ( - step - if self.recent_speed is None - else 0.8 * self.recent_speed + 0.2 * step - ) - - self.coast_streak = 0 - self.det_reject_streak = 0 - - else: - if ball_center_candidate is not None: - self.dropped_by_gate += 1 - - if ( - self.ball_coast - and len(self.single_ball_trail) > 0 - and self.coast_streak < int(self.coast_max) - ): - pred = self.ball_state.predict( - self.frame_idx, decay=float(self.coast_decay) - ) - if pred is not None and np.all(np.isfinite(pred)): - px, py = int(round(pred[0])), int(round(pred[1])) - x_prev, y_prev, _ = self.single_ball_trail[-1] - hard_cap = float(self.ball_max_jump_rel) * float(min(Hh, Ww)) - if ( - 0 <= px < Ww - and 0 <= py < Hh - and float(np.hypot(px - x_prev, py - y_prev)) <= 1.25 * hard_cap - ): - add_trail_point( - self.single_ball_trail, - px, - py, - self.frame_idx, - densify=False, - ) - self.ball_state.update_from_center(px, py, self.frame_idx) - if len(self.single_ball_trail) >= 2: - step = float(np.hypot(px - x_prev, py - y_prev)) - self.recent_speed = ( - step - if self.recent_speed is None - else 0.8 * self.recent_speed + 0.2 * step - ) - self.coast_streak += 1 - self.coast_used += 1 - else: - self.coast_streak = 0 - - self.frame_idx += 1 - - # Return result for decode - class AdvancedResult: - def __init__(self, tracks_p, tracks_b, ball_trail, boxes): - self.tracks_p = tracks_p - self.tracks_b = tracks_b - self.ball_trail = list(ball_trail) - self.boxes = boxes - - return AdvancedResult(tracks_p, tracks_b, self.single_ball_trail, det_res.boxes) - - class DemoSoccer(BaseObjectDetector): """ GStreamer element for advanced YOLO inference focused on person and ball tracking with fallback and gating. @@ -1753,7 +720,7 @@ def do_decode(self, buf, result, stream_idx=0): ball_trail = result.ball_trail boxes = result.boxes - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if not meta: self.logger.error( f"Stream {stream_idx} - Failed to add analytics relation metadata" @@ -1773,42 +740,38 @@ def do_decode(self, buf, result, stream_idx=0): score = 1.0 # Track confidence track_id = getattr(tr, "track_id", 0) qk_string = f"stream_{stream_idx}_person_id_{track_id}" - qk = GLib.quark_from_string(qk_string) - ret, od_mtd = meta.add_od_mtd( - qk, + od_mtd = analytics.add_object( + meta, + qk_string, x1, y1, x2 - x1, y2 - y1, score, ) - if not ret: + if od_mtd is None: self.logger.error( f"Stream {stream_idx} - Failed to add person detection metadata" ) continue self.logger.debug( - f"Stream {stream_idx} - Added person od_mtd: id={track_id}, x1={x1}, y1={y1}, w={x2-x1}, h={y2-y1}, score={score}" + f"Stream {stream_idx} - Added person od_mtd: id={track_id}, x1={x1}, y1={y1}, w={x2 - x1}, h={y2 - y1}, score={score}" ) - ret, tracking_mtd = meta.add_tracking_mtd( - track_id, Gst.util_get_timestamp() - ) - if not ret: + tracking_mtd = analytics.add_tracking(meta, track_id) + if tracking_mtd is None: self.logger.error( f"Stream {stream_idx} - Failed to add person tracking metadata" ) continue - ret = GstAnalytics.RelationMeta.set_relation( - meta, GstAnalytics.RelTypes.RELATE_TO, od_mtd.id, tracking_mtd.id - ) + ret = analytics.relate(meta, od_mtd, tracking_mtd) if not ret: self.logger.error( f"Stream {stream_idx} - Failed to relate person od and tracking metadata" ) else: self.logger.debug( - f"Stream {stream_idx} - Linked person od_mtd {od_mtd.id} to tracking_mtd {tracking_mtd.id}" + f"Stream {stream_idx} - Linked person od_mtd {od_mtd} to tracking_mtd {tracking_mtd}" ) # Ball tracks (unchanged) @@ -1820,42 +783,38 @@ def do_decode(self, buf, result, stream_idx=0): score = 1.0 track_id = getattr(tr, "track_id", 0) qk_string = f"stream_{stream_idx}_ball_id_{track_id}" - qk = GLib.quark_from_string(qk_string) - ret, od_mtd = meta.add_od_mtd( - qk, + od_mtd = analytics.add_object( + meta, + qk_string, x1, y1, x2 - x1, y2 - y1, score, ) - if not ret: + if od_mtd is None: self.logger.error( f"Stream {stream_idx} - Failed to add ball detection metadata" ) continue self.logger.debug( - f"Stream {stream_idx} - Added ball od_mtd: id={track_id}, x1={x1}, y1={y1}, w={x2-x1}, h={y2-y1}, score={score}" + f"Stream {stream_idx} - Added ball od_mtd: id={track_id}, x1={x1}, y1={y1}, w={x2 - x1}, h={y2 - y1}, score={score}" ) - ret, tracking_mtd = meta.add_tracking_mtd( - track_id, Gst.util_get_timestamp() - ) - if not ret: + tracking_mtd = analytics.add_tracking(meta, track_id) + if tracking_mtd is None: self.logger.error( f"Stream {stream_idx} - Failed to add ball tracking metadata" ) continue - ret = GstAnalytics.RelationMeta.set_relation( - meta, GstAnalytics.RelTypes.RELATE_TO, od_mtd.id, tracking_mtd.id - ) + ret = analytics.relate(meta, od_mtd, tracking_mtd) if not ret: self.logger.error( f"Stream {stream_idx} - Failed to relate ball od and tracking metadata" ) else: self.logger.debug( - f"Stream {stream_idx} - Linked ball od_mtd {od_mtd.id} to tracking_mtd {tracking_mtd.id}" + f"Stream {stream_idx} - Linked ball od_mtd {od_mtd} to tracking_mtd {tracking_mtd}" ) # Ball trail - attach as custom GstStructure meta (fixed API, uncommented) @@ -1896,9 +855,9 @@ def do_decode(self, buf, result, stream_idx=0): # Add any non-person/ball or raw detections if desired pass # Optional - attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + attached_meta = analytics.get_relation_meta(buf) if attached_meta: - count = GstAnalytics.relation_get_length(attached_meta) + count = analytics.relation_length(attached_meta) self.logger.info( f"Stream {stream_idx} - Advanced metadata attached to buffer {hex(id(buf))}: {count} relations, ball trail: {len(ball_trail)}" ) @@ -1908,10 +867,9 @@ def do_decode(self, buf, result, stream_idx=0): ) -if CAN_REGISTER_ELEMENT: - GObject.type_register(DemoSoccer) - __gstelementfactory__ = ("demo_soccer", Gst.Rank.NONE, DemoSoccer) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("demo_soccer", DemoSoccer) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'demo_soccer' element will not be registered because required modules are missing." ) diff --git a/plugins/python/demucs.py b/plugins/python/demucs.py index 7e905cd..37f119d 100644 --- a/plugins/python/demucs.py +++ b/plugins/python/demucs.py @@ -17,18 +17,14 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GObject", "2.0") - from gi.repository import Gst, GObject # noqa: E402 + from backend import GObject from base_separate import BaseSeparate - from engine.pytorch_engine import PyTorchEngine + from engine.demucs_engine import DemucsEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -38,62 +34,6 @@ ) -class DemucsEngine(PyTorchEngine): - def __init__(self): - super().__init__() - self.sample_rate = 0 - - def do_load_model(self, model_name, **kwargs): - from torchaudio.pipelines import HDEMUCS_HIGH_MUSDB_PLUS - - if not model_name: - return - self.logger.info(f"Loading Demucs model on device: {self.device}") - bundle = ( - HDEMUCS_HIGH_MUSDB_PLUS # You can choose other bundles like DEMUCS_MUSDB - ) - self.model = bundle.get_model() - if hasattr(self.model, "to") and callable(getattr(self.model, "to")): - self.model = self.model.to(self.device) - self.sample_rate = bundle.sample_rate # 44100 Hz - self.sources = self.model.sources # ['drums', 'bass', 'other', 'vocals'] - - def separate_sources( - self, - mix, - segment=10.0, - overlap=0.1, - ): - import torch - from torchaudio.transforms import Fade - - device = mix.device - batch, channels, length = mix.shape - chunk_len = int(self.sample_rate * segment * (1 + overlap)) - start = 0 - end = chunk_len - overlap_frames = int(overlap * self.sample_rate) - fade = Fade(fade_in_len=0, fade_out_len=overlap_frames, fade_shape="linear") - - final = torch.zeros(batch, len(self.sources), channels, length, device=device) - - while start < length - overlap_frames: - chunk = mix[:, :, start:end] - with torch.no_grad(): - out = self.model.forward(chunk) - out = fade(out) - final[:, :, :, start:end] += out - if start == 0: - fade.fade_in_len = overlap_frames - start += int(chunk_len - overlap_frames) - else: - start += chunk_len - end += chunk_len - if end >= length: - fade.fade_out_len = 0 - return final - - class Demucs(BaseSeparate): __gstmetadata__ = ( "Demucs", @@ -173,10 +113,9 @@ def do_separate(self, audio_data): return selected_resampled.cpu().numpy() # float32 -if CAN_REGISTER_ELEMENT: - GObject.type_register(Demucs) - __gstelementfactory__ = ("pyml_demucs", Gst.Rank.NONE, Demucs) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_demucs", Demucs) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_demucs' element will not be registered because base_separate module is missing." ) diff --git a/plugins/python/depth.py b/plugins/python/depth.py index fc6a3ec..a0b93ec 100644 --- a/plugins/python/depth.py +++ b/plugins/python/depth.py @@ -17,23 +17,16 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.depth_anything_engine import DepthAnythingEngine from engine.engine_factory import EngineFactory + from backend import GObject + from tasks.depth import DepthTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -42,75 +35,8 @@ # Header prefix for depth map buffer metadata DEPTH_META_HEADER = b"GST-DEPTH:" -# cv2 colormap IDs for depth visualization -COLORMAP_IDS = { - "inferno": 9, - "jet": 2, - "viridis": 16, - "plasma": 18, - "magma": 13, -} - -class DepthAnythingEngine(PyTorchEngine): - """ - PyTorch engine for DepthAnything V2 monocular depth estimation. - - Supports HuggingFace model IDs: - depth-anything/Depth-Anything-V2-Small-hf (fastest) - depth-anything/Depth-Anything-V2-Base-hf - depth-anything/Depth-Anything-V2-Large-hf (most accurate) - """ - - def do_load_model(self, model_name, **kwargs): - try: - from transformers import AutoImageProcessor, AutoModelForDepthEstimation - - self.image_processor = AutoImageProcessor.from_pretrained(model_name) - self.model = AutoModelForDepthEstimation.from_pretrained(model_name) - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info( - f"DepthAnything model '{model_name}' loaded on {self.device}" - ) - except Exception as e: - raise ValueError(f"Failed to load depth model '{model_name}': {e}") - - def do_forward(self, frames): - import numpy as np - import torch - import torch.nn.functional as F - from PIL import Image - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - results = [] - for frame in frames: - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - H, W = frame.shape[:2] - inputs = self.image_processor(images=pil_img, return_tensors="pt") - inputs = {k: v.to(self.device) for k, v in inputs.items()} - with torch.no_grad(): - outputs = self.model(**inputs) - # outputs.predicted_depth: [1, H', W'] - depth_up = F.interpolate( - outputs.predicted_depth.unsqueeze(0), - size=(H, W), - mode="bicubic", - align_corners=False, - ).squeeze() - results.append(depth_up.cpu().numpy()) - except Exception as e: - self.logger.error(f"Depth inference error on frame: {e}") - results.append(None) - - return results[0] if not is_batch else results - - -class DepthTransform(VideoTransform): +class DepthTransform(VideoTransform, DepthTask): """ GStreamer element for monocular depth estimation using DepthAnything V2. @@ -132,6 +58,8 @@ class DepthTransform(VideoTransform): pyml_depth model-name=depth-anything/Depth-Anything-V2-Small-hf frame-stride=2 """ + META_HEADER = DEPTH_META_HEADER + __gstmetadata__ = ( "Depth", "Transform", @@ -170,103 +98,10 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_depth") - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - if num_sources == 1: - depth = self._do_forward(frames) - if depth is None: - return Gst.FlowReturn.ERROR - self._apply_depth(buf, depth, fmt) - else: - depths = self._do_forward(frames) - if depths: - # For batch: apply only the first depth map (primary frame) - self._apply_depth(buf, depths[0], fmt) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"Depth transform error: {e}") - return Gst.FlowReturn.ERROR - - def _do_forward(self, frames): - if self.engine: - return self.engine.do_forward(frames) - return None - - def _apply_depth(self, buf, depth_map, fmt): - """Normalize depth, optionally visualize, then append as metadata.""" - import cv2 - import numpy as np - - d_min, d_max = depth_map.min(), depth_map.max() - if d_max > d_min: - depth_norm = ((depth_map - d_min) / (d_max - d_min) * 255).astype(np.uint8) - else: - depth_norm = np.zeros_like(depth_map, dtype=np.uint8) - - # Visualize first, before appending any read-only metadata memory. - # (A READONLY chunk on the buffer would prevent buf.map(WRITE) from succeeding.) - if self.visualize: - cmap_id = COLORMAP_IDS.get(self.colormap, COLORMAP_IDS["inferno"]) - depth_bgr = cv2.applyColorMap(depth_norm, cmap_id) - output = self._convert_bgr_to_format(depth_bgr, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - - # Append uint8 depth map as a custom buffer memory chunk. - # Use new_allocate+fill: PyGI hides the maxsize arg in new_wrapped - # (it derives it from data length), so passing it explicitly shifts - # all subsequent args and causes a GI assertion crash. - depth_bytes = DEPTH_META_HEADER + depth_norm.tobytes() - tmp = Gst.Buffer.new_allocate(None, len(depth_bytes), None) - tmp.fill(0, depth_bytes) - buf.append_memory(tmp.get_memory(0)) - - @staticmethod - def _convert_bgr_to_format(bgr, fmt): - """Convert a BGR numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - elif fmt == "BGR": - return bgr - elif fmt == "RGBA": - return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGBA) - return np.roll(rgba, 1, axis=-1) # RGBA -> ARGB - elif fmt == "ABGR": - bgra = cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) - return np.roll(bgra, 1, axis=-1) # BGRA -> ABGR - else: - return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - -if CAN_REGISTER_ELEMENT: - GObject.type_register(DepthTransform) - __gstelementfactory__ = ("pyml_depth", Gst.Rank.NONE, DepthTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_depth", DepthTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_depth' element will not be registered because required modules are missing." ) diff --git a/plugins/python/embedding.py b/plugins/python/embedding.py index 6dfabee..5acf900 100644 --- a/plugins/python/embedding.py +++ b/plugins/python/embedding.py @@ -17,23 +17,15 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import json - import struct - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform - from utils.format_converter import FormatConverter - from engine.pytorch_engine import PyTorchEngine + from engine.embedding_engine import EmbeddingEngine from engine.engine_factory import EngineFactory + from backend import frameio, GObject + from tasks.embedding import EmbeddingTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -43,132 +35,7 @@ EMBEDDING_META_HEADER = b"GST-EMBEDDING:" -class EmbeddingEngine(PyTorchEngine): - """ - PyTorch engine for image/text embedding extraction. - - Supports CLIP and DINOv2 models via HuggingFace transformers: - openai/clip-vit-large-patch14 (CLIP — image + text) - facebook/dinov2-base (DINOv2 — image only) - """ - - def __init__(self): - super().__init__() - self.processor = None - self.tokenizer = None - self.output_dim = 0 - self._is_clip = False - - def do_load_model(self, model_name, **kwargs): - try: - - if "clip" in model_name.lower(): - self._load_clip(model_name) - elif "dino" in model_name.lower(): - self._load_dinov2(model_name) - else: - # Default to CLIP-style loading - self._load_clip(model_name) - - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info( - f"Embedding model '{model_name}' loaded on {self.device} " - f"(dim={self.output_dim})" - ) - except Exception as e: - raise ValueError(f"Failed to load embedding model '{model_name}': {e}") - - def _load_clip(self, model_name): - from transformers import CLIPModel, CLIPProcessor - - self.model = CLIPModel.from_pretrained(model_name) - self.processor = CLIPProcessor.from_pretrained(model_name) - self._is_clip = True - # Determine output dim from config - self.output_dim = self.model.config.projection_dim - - def _load_dinov2(self, model_name): - from transformers import AutoModel, AutoImageProcessor - - self.model = AutoModel.from_pretrained(model_name) - self.processor = AutoImageProcessor.from_pretrained(model_name) - self._is_clip = False - self.output_dim = self.model.config.hidden_size - - def do_forward(self, frame, normalize=True): - """ - Extract an embedding vector from a video frame. - - Args: - frame: numpy RGB array (H, W, 3). - normalize: if True, L2-normalize the embedding. - - Returns: - numpy float32 array of shape (output_dim,), or None on failure. - """ - import numpy as np - import torch - from PIL import Image - - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - inputs = self.processor(images=pil_img, return_tensors="pt") - inputs = {k: v.to(self.device) for k, v in inputs.items()} - - with torch.no_grad(): - if self._is_clip: - emb = self.model.get_image_features(**inputs) - else: - outputs = self.model(**inputs) - # Use CLS token embedding - emb = outputs.last_hidden_state[:, 0] - - emb = emb.squeeze(0).cpu().numpy().astype(np.float32) - if normalize: - norm = np.linalg.norm(emb) - if norm > 0: - emb = emb / norm - return emb - except Exception as e: - self.logger.error(f"Embedding inference error: {e}") - return None - - def do_text_embedding(self, text, normalize=True): - """ - Extract a text embedding (CLIP only). - - Args: - text: input string. - normalize: if True, L2-normalize the embedding. - - Returns: - numpy float32 array of shape (output_dim,), or None. - """ - import numpy as np - import torch - - if not self._is_clip: - self.logger.warning("Text embeddings only supported for CLIP models") - return None - - try: - inputs = self.processor(text=[text], return_tensors="pt", padding=True) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - with torch.no_grad(): - emb = self.model.get_text_features(**inputs) - emb = emb.squeeze(0).cpu().numpy().astype(np.float32) - if normalize: - norm = np.linalg.norm(emb) - if norm > 0: - emb = emb / norm - return emb - except Exception as e: - self.logger.error(f"Text embedding error: {e}") - return None - - -class EmbeddingTransform(VideoTransform): +class EmbeddingTransform(VideoTransform, EmbeddingTask): """ GStreamer element for extracting frame embeddings for similarity search, clustering, or RAG. @@ -221,7 +88,6 @@ def __init__(self): super().__init__() self.mgr.engine_name = "pyml_embedding_engine" EngineFactory.register(self.mgr.engine_name, EmbeddingEngine) - self.format_converter = FormatConverter() self._frame_count = 0 self._text_embedding = None self._cached_text = None @@ -259,80 +125,39 @@ def _update_text_embedding(self): self._text_embedding = None self._cached_text = None - def do_transform_ip(self, buf): - import numpy as np - - try: - self._frame_count += 1 - if self.frame_stride > 1 and (self._frame_count % self.frame_stride) != 1: - return Gst.FlowReturn.OK - - if self.engine is None: - return Gst.FlowReturn.OK - - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map video buffer for reading") - return Gst.FlowReturn.ERROR - - try: - frame = self.format_converter.to_rgb( - map_info.data, self.width, self.height, buf, self.sinkpad - ) - finally: - buf.unmap(map_info) - - if frame is None: - return Gst.FlowReturn.ERROR - - emb = self.engine.do_forward(frame, normalize=self.normalize) - if emb is None: - return Gst.FlowReturn.OK + def process_frames(self, frames, num_sources, fmt, target): + """Embed the frame and append the payload, skipping strided-out frames.""" + self._frame_count += 1 + if self.frame_stride > 1 and (self._frame_count % self.frame_stride) != 1: + return - # Update text embedding if needed - self._update_text_embedding() + if self.engine is None: + return - # Build JSON header with dimension info and optional similarity score - header = {"dim": int(emb.shape[0]), "dtype": "float32"} - if self._text_embedding is not None: - similarity = float(np.dot(emb, self._text_embedding)) - header["text"] = self.text - header["similarity"] = round(similarity, 6) + frame = frames[0] if num_sources > 1 else frames - header_bytes = json.dumps(header).encode("utf-8") - header_len = struct.pack("= threshold + + results.append( + { + "score": anomaly_score, + "is_anomaly": is_anomaly, + "heatmap": heatmap, + } + ) + except Exception as e: + self.logger.error(f"Anomaly inference error on frame: {e}") + results.append( + { + "score": 0.0, + "is_anomaly": False, + "heatmap": None, + } + ) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/caption_phi_engine.py b/plugins/python/engine/caption_phi_engine.py new file mode 100644 index 0000000..819fb0e --- /dev/null +++ b/plugins/python/engine/caption_phi_engine.py @@ -0,0 +1,71 @@ +# CaptionPhiEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_vision_engine import PyTorchVisionEngine + + +class CaptionPhiEngine(PyTorchVisionEngine): + def do_load_model(self, model_name, **kwargs): + """Load a Phi-3-vision model from Hugging Face.""" + import torch + from transformers import AutoModelForCausalLM, AutoProcessor, BitsAndBytesConfig + + try: + quantization_config = BitsAndBytesConfig(load_in_4bit=True) + self.model = AutoModelForCausalLM.from_pretrained( + model_name, + quantization_config=quantization_config, + device_map="auto", + torch_dtype=torch.float16, + trust_remote_code=True, + _attn_implementation="flash_attention_2", + ) + self.processor = AutoProcessor.from_pretrained( + model_name, trust_remote_code=True + ) + self.logger.info("Phi-3.5-vision model and processor loaded successfully.") + self.model.eval() + + # Skip .to() for 4-bit models + if not ( + hasattr(self.model, "is_loaded_in_4bit") + and self.model.is_loaded_in_4bit + ): + self.execute_with_stream(lambda: self.model.to(self.device)) + self.logger.info(f"Model moved to {self.device}") + + return True + + except Exception as e: + self.logger.error(f"Error loading model '{model_name}': {e}") + self.tokenizer = None + self.model = None + return False + + def _prepare_messages(self, images): + prompt_content = ( + "\n".join([f"<|image_{i+1}|>" for i in range(len(images))]) + + f"\n{self.prompt}" + ) + return [{"role": "user", "content": prompt_content}] + + def _process_inputs(self, prompt_text, images): + return self.processor(prompt_text, images, return_tensors="pt").to(self.device) + + def _trim_generated_ids(self, inputs, generate_ids): + return generate_ids[:, inputs["input_ids"].shape[1] :] diff --git a/plugins/python/engine/caption_qwen_engine.py b/plugins/python/engine/caption_qwen_engine.py new file mode 100644 index 0000000..7e64253 --- /dev/null +++ b/plugins/python/engine/caption_qwen_engine.py @@ -0,0 +1,76 @@ +# CaptionQwenEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_vision_engine import PyTorchVisionEngine + + +class CaptionQwenEngine(PyTorchVisionEngine): + def do_load_model(self, model_name, **kwargs): + """Load a Qwen2.5-VL model from Hugging Face.""" + import torch + from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor + + try: + self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained( + model_name, + torch_dtype="auto", + dtype=torch.float16, + device_map="auto", + ) + self.processor = AutoProcessor.from_pretrained(model_name) + + self.logger.info(f"{model_name} model and processor loaded successfully.") + self.model.eval() + + # Skip .to() for quantized models + if not (hasattr(self.model, "is_quantized") and self.model.is_quantized): + self.execute_with_stream(lambda: self.model.to(self.device)) + self.logger.info(f"Model moved to {self.device}") + + return True + + except Exception as e: + self.logger.error(f"Error loading model '{model_name}': {e}") + self.processor = None + self.model = None + return False + + def _prepare_messages(self, images): + content = [{"type": "image", "image": img} for img in images] + content.append({"type": "text", "text": self.prompt}) + return [{"role": "user", "content": content}] + + def _process_inputs(self, prompt_text, images): + from qwen_vl_utils import process_vision_info + + image_inputs, video_inputs = process_vision_info( + self._prepare_messages(images) + ) # Note: Uses messages directly + return self.processor( + text=[prompt_text], + images=image_inputs, + videos=video_inputs, + padding=True, + return_tensors="pt", + ).to(self.device) + + def _trim_generated_ids(self, inputs, generate_ids): + return [ + out_ids[len(in_ids) :] + for in_ids, out_ids in zip(inputs.input_ids, generate_ids) + ] diff --git a/plugins/python/engine/clap_engine.py b/plugins/python/engine/clap_engine.py new file mode 100644 index 0000000..07204c3 --- /dev/null +++ b/plugins/python/engine/clap_engine.py @@ -0,0 +1,105 @@ +# ClapEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + +CLAP_SAMPLE_RATE = 48000 + + +class ClapEngine(PyTorchEngine): + """ + PyTorch engine for CLAP audio-text contrastive inference. + + Uses the HuggingFace transformers ClapModel + ClapProcessor to encode + audio waveforms and compare them against precomputed text label embeddings. + """ + + def __init__(self): + super().__init__() + self.processor = None + self.text_embeddings = None + self._labels = [] + + def do_load_model(self, model_name, **kwargs): + try: + from transformers import ClapModel, ClapProcessor + + self.processor = ClapProcessor.from_pretrained(model_name) + self.model = ClapModel.from_pretrained(model_name) + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info(f"CLAP model '{model_name}' loaded on {self.device}") + labels = kwargs.get("labels", []) + if labels: + self._precompute_text_embeddings(labels) + except Exception as e: + raise ValueError(f"Failed to load CLAP model '{model_name}': {e}") + + def _precompute_text_embeddings(self, labels): + """Precompute and cache normalized text embeddings for the label list.""" + import torch + + self._labels = list(labels) + if not self._labels or self.processor is None: + self.text_embeddings = None + return + inputs = self.processor(text=self._labels, return_tensors="pt", padding=True) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + text_emb = self.model.get_text_features(**inputs) + self.text_embeddings = text_emb / text_emb.norm(dim=-1, keepdim=True) + self.logger.info(f"Precomputed text embeddings for {len(self._labels)} labels") + + def do_forward(self, audio_waveform): + """ + Encode an audio waveform and compute similarity against text labels. + + Args: + audio_waveform: numpy float32 array of audio samples (mono). + + Returns: + List of (label, score) tuples sorted by descending score, + or None on failure. + """ + import torch + + if self.text_embeddings is None or len(self._labels) == 0: + self.logger.warning("No text labels configured for CLAP inference") + return None + + try: + inputs = self.processor( + audios=audio_waveform, + sampling_rate=CLAP_SAMPLE_RATE, + return_tensors="pt", + ) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + audio_emb = self.model.get_audio_features(**inputs) + audio_emb = audio_emb / audio_emb.norm(dim=-1, keepdim=True) + similarities = (audio_emb @ self.text_embeddings.T).squeeze(0) + scores = similarities.cpu().numpy() + + results = [ + (label, float(score)) for label, score in zip(self._labels, scores) + ] + results.sort(key=lambda x: x[1], reverse=True) + return results + except Exception as e: + self.logger.error(f"CLAP inference error: {e}") + return None diff --git a/plugins/python/engine/clip_engine.py b/plugins/python/engine/clip_engine.py new file mode 100644 index 0000000..9081ef4 --- /dev/null +++ b/plugins/python/engine/clip_engine.py @@ -0,0 +1,97 @@ +# ClipEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class ClipEngine(PyTorchEngine): + """ + PyTorch engine for CLIP and SigLIP zero-shot image classification. + + Works with any HuggingFace CLIP-compatible model: + openai/clip-vit-base-patch32 + openai/clip-vit-large-patch14 + google/siglip-base-patch16-224 + google/siglip-large-patch16-384 + """ + + def __init__(self): + super().__init__() + self._labels = [] + + @property + def clip_labels(self): + return self._labels + + @clip_labels.setter + def clip_labels(self, value): + self._labels = value + + def do_load_model(self, model_name, **kwargs): + try: + from transformers import AutoProcessor, AutoModel + + self.image_processor = AutoProcessor.from_pretrained(model_name) + self.model = AutoModel.from_pretrained(model_name) + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info(f"CLIP model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load CLIP model '{model_name}': {e}") + + def do_forward(self, frame): + """ + Run zero-shot classification. + + Args: + frame: RGB numpy array [H, W, 3] + + Returns: + List of (label, probability) tuples sorted by probability descending, + or None if no labels are set. + """ + import numpy as np + import torch + from PIL import Image + + if not self._labels: + self.logger.warning("No labels set — set the 'labels' property") + return None + + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + inputs = self.image_processor( + text=self._labels, + images=pil_img, + return_tensors="pt", + padding=True, + ) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + + with torch.no_grad(): + outputs = self.model(**inputs) + + # logits_per_image: [1, num_labels] + probs = outputs.logits_per_image.softmax(dim=1)[0] + results = [(label, prob.item()) for label, prob in zip(self._labels, probs)] + results.sort(key=lambda x: x[1], reverse=True) + return results + + except Exception as e: + self.logger.error(f"CLIP inference error: {e}") + return None diff --git a/plugins/python/engine/demucs_engine.py b/plugins/python/engine/demucs_engine.py new file mode 100644 index 0000000..ec543fc --- /dev/null +++ b/plugins/python/engine/demucs_engine.py @@ -0,0 +1,75 @@ +# DemucsEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class DemucsEngine(PyTorchEngine): + def __init__(self): + super().__init__() + self.sample_rate = 0 + + def do_load_model(self, model_name, **kwargs): + from torchaudio.pipelines import HDEMUCS_HIGH_MUSDB_PLUS + + if not model_name: + return + self.logger.info(f"Loading Demucs model on device: {self.device}") + bundle = ( + HDEMUCS_HIGH_MUSDB_PLUS # You can choose other bundles like DEMUCS_MUSDB + ) + self.model = bundle.get_model() + if hasattr(self.model, "to") and callable(getattr(self.model, "to")): + self.model = self.model.to(self.device) + self.sample_rate = bundle.sample_rate # 44100 Hz + self.sources = self.model.sources # ['drums', 'bass', 'other', 'vocals'] + + def separate_sources( + self, + mix, + segment=10.0, + overlap=0.1, + ): + import torch + from torchaudio.transforms import Fade + + device = mix.device + batch, channels, length = mix.shape + chunk_len = int(self.sample_rate * segment * (1 + overlap)) + start = 0 + end = chunk_len + overlap_frames = int(overlap * self.sample_rate) + fade = Fade(fade_in_len=0, fade_out_len=overlap_frames, fade_shape="linear") + + final = torch.zeros(batch, len(self.sources), channels, length, device=device) + + while start < length - overlap_frames: + chunk = mix[:, :, start:end] + with torch.no_grad(): + out = self.model.forward(chunk) + out = fade(out) + final[:, :, :, start:end] += out + if start == 0: + fade.fade_in_len = overlap_frames + start += int(chunk_len - overlap_frames) + else: + start += chunk_len + end += chunk_len + if end >= length: + fade.fade_out_len = 0 + return final diff --git a/plugins/python/engine/depth_anything_engine.py b/plugins/python/engine/depth_anything_engine.py new file mode 100644 index 0000000..9de31c0 --- /dev/null +++ b/plugins/python/engine/depth_anything_engine.py @@ -0,0 +1,77 @@ +# DepthAnythingEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class DepthAnythingEngine(PyTorchEngine): + """ + PyTorch engine for DepthAnything V2 monocular depth estimation. + + Supports HuggingFace model IDs: + depth-anything/Depth-Anything-V2-Small-hf (fastest) + depth-anything/Depth-Anything-V2-Base-hf + depth-anything/Depth-Anything-V2-Large-hf (most accurate) + """ + + def do_load_model(self, model_name, **kwargs): + try: + from transformers import AutoImageProcessor, AutoModelForDepthEstimation + + self.image_processor = AutoImageProcessor.from_pretrained(model_name) + self.model = AutoModelForDepthEstimation.from_pretrained(model_name) + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info( + f"DepthAnything model '{model_name}' loaded on {self.device}" + ) + except Exception as e: + raise ValueError(f"Failed to load depth model '{model_name}': {e}") + + def do_forward(self, frames): + import numpy as np + import torch + import torch.nn.functional as F + from PIL import Image + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if not is_batch: + frames = frames[np.newaxis] + + results = [] + for frame in frames: + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + H, W = frame.shape[:2] + inputs = self.image_processor(images=pil_img, return_tensors="pt") + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + outputs = self.model(**inputs) + # outputs.predicted_depth: [1, H', W'] + depth_up = F.interpolate( + outputs.predicted_depth.unsqueeze(0), + size=(H, W), + mode="bicubic", + align_corners=False, + ).squeeze() + results.append(depth_up.cpu().numpy()) + except Exception as e: + self.logger.error(f"Depth inference error on frame: {e}") + results.append(None) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/drpai_engine.py b/plugins/python/engine/drpai_engine.py new file mode 100644 index 0000000..485b1a1 --- /dev/null +++ b/plugins/python/engine/drpai_engine.py @@ -0,0 +1,145 @@ +# DRPAIEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os +import numpy as np + +from .ml_engine import MLEngine + + +def _anchor_count(imgsz): + """Total anchors a YOLO model emits for a square input at strides 8/16/32.""" + return sum((imgsz // s) ** 2 for s in (8, 16, 32)) + + +class DRPAIEngine(MLEngine): + """DRP-AI TVM runtime engine for Renesas RZ/V boards (RZ/V2H). + + Runs a model compiled with the Renesas DRP-AI TVM compiler on the DRP-AI + NPU. `model_name` is the path to the compiled deploy directory containing + ``deploy.so`` / ``deploy.json`` / ``deploy.params``. + + Inference goes through the ``drpai_runtime`` pybind11 module (built from + ``extern/rzv2h/`` against the board's DRP-AI TVM runtime. + """ + + def __init__(self): + super().__init__() + self.runtime = None + self.model_name = None + self.kwargs = None + self.imgsz = 640 + + self.input_format = "nchw" + self.post_process = "anchor_free" + + def do_load_model(self, model_name, **kwargs): + self.model_name = model_name + self.kwargs = kwargs + imgsz = kwargs.get("imgsz") + if imgsz: + try: + self.imgsz = int(imgsz) + except (TypeError, ValueError): + pass + + try: + import drpai_runtime + except ImportError as e: + self.logger.error( + "drpai_runtime module not found. Build the pybind11 binding in " + "extern/rzv2h/ inside the RZ/V2H DRP-AI TVM SDK and put it on PYTHONPATH " + f"(see extern/rzv2h/README.md). Import error: {e}" + ) + return False + + if not os.path.isdir(model_name): + self.logger.error( + f"DRP-AI model directory not found: {model_name!r} " + "(expected a folder with deploy.so/json/params)" + ) + return False + + try: + self.runtime = drpai_runtime.Runtime() + if not self.runtime.load(model_name): + self.logger.error(f"DRP-AI failed to load model from {model_name}") + self.runtime = None + return False + self.logger.info( + f"DRP-AI model loaded from {model_name} (imgsz={self.imgsz})" + ) + return True + except Exception as e: + self.logger.error(f"DRP-AI load error: {e}") + self.runtime = None + return False + + def do_set_device(self, device): + self.device = device + self.logger.info(f"DRP-AI engine device set to {device}") + + def do_generate(self, input_text, max_length=1000, system_prompt=None): + raise NotImplementedError( + "DRP-AI engine is a vision-inference engine; text generation is not " + "supported." + ) + + def _preprocess(self, frame_hwc): + """HWC uint8 RGB(A) frame -> contiguous (1, 3, H, W) float32 in [0, 1].""" + x = np.asarray(frame_hwc, dtype=np.float32) + if x.shape[-1] > 3: + x = x[..., :3] + x = x / 255.0 + x = np.transpose(x, (2, 0, 1)) + x = np.expand_dims(x, 0) + return np.ascontiguousarray(x, dtype=np.float32) + + def _gather_output(self): + """Read output 0 and reshape the flat buffer to (1, 4+nc, anchors).""" + out = np.asarray(self.runtime.get_output(0), dtype=np.float32).reshape(-1) + anchors = _anchor_count(self.imgsz) + if anchors and out.size % anchors == 0: + channels = out.size // anchors + return out.reshape(1, channels, anchors) + self.logger.warning( + f"DRP-AI output size {out.size} not divisible by {anchors} anchors; " + "passing raw to post-process" + ) + return out + + def do_forward(self, frames): + if self.runtime is None: + self.logger.error("DRP-AI runtime not loaded") + return None + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + batch = frames if is_batch else frames[np.newaxis, ...] + + results = [] + for img in batch: + try: + self.runtime.set_input(0, self._preprocess(img)) + self.runtime.run() + raw = self._gather_output() + results.append(self._apply_post_process(raw, is_batch=False)) + except Exception as e: + self.logger.error(f"DRP-AI inference error: {e}") + results.append(None) + + return results if is_batch else results[0] diff --git a/plugins/python/engine/embedding_engine.py b/plugins/python/engine/embedding_engine.py new file mode 100644 index 0000000..1f45889 --- /dev/null +++ b/plugins/python/engine/embedding_engine.py @@ -0,0 +1,144 @@ +# EmbeddingEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class EmbeddingEngine(PyTorchEngine): + """ + PyTorch engine for image/text embedding extraction. + + Supports CLIP and DINOv2 models via HuggingFace transformers: + openai/clip-vit-large-patch14 (CLIP — image + text) + facebook/dinov2-base (DINOv2 — image only) + """ + + def __init__(self): + super().__init__() + self.processor = None + self.tokenizer = None + self.output_dim = 0 + self._is_clip = False + + def do_load_model(self, model_name, **kwargs): + try: + + if "clip" in model_name.lower(): + self._load_clip(model_name) + elif "dino" in model_name.lower(): + self._load_dinov2(model_name) + else: + # Default to CLIP-style loading + self._load_clip(model_name) + + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info( + f"Embedding model '{model_name}' loaded on {self.device} " + f"(dim={self.output_dim})" + ) + except Exception as e: + raise ValueError(f"Failed to load embedding model '{model_name}': {e}") + + def _load_clip(self, model_name): + from transformers import CLIPModel, CLIPProcessor + + self.model = CLIPModel.from_pretrained(model_name) + self.processor = CLIPProcessor.from_pretrained(model_name) + self._is_clip = True + # Determine output dim from config + self.output_dim = self.model.config.projection_dim + + def _load_dinov2(self, model_name): + from transformers import AutoModel, AutoImageProcessor + + self.model = AutoModel.from_pretrained(model_name) + self.processor = AutoImageProcessor.from_pretrained(model_name) + self._is_clip = False + self.output_dim = self.model.config.hidden_size + + def do_forward(self, frame, normalize=True): + """ + Extract an embedding vector from a video frame. + + Args: + frame: numpy RGB array (H, W, 3). + normalize: if True, L2-normalize the embedding. + + Returns: + numpy float32 array of shape (output_dim,), or None on failure. + """ + import numpy as np + import torch + from PIL import Image + + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + inputs = self.processor(images=pil_img, return_tensors="pt") + inputs = {k: v.to(self.device) for k, v in inputs.items()} + + with torch.no_grad(): + if self._is_clip: + emb = self.model.get_image_features(**inputs) + else: + outputs = self.model(**inputs) + # Use CLS token embedding + emb = outputs.last_hidden_state[:, 0] + + emb = emb.squeeze(0).cpu().numpy().astype(np.float32) + if normalize: + norm = np.linalg.norm(emb) + if norm > 0: + emb = emb / norm + return emb + except Exception as e: + self.logger.error(f"Embedding inference error: {e}") + return None + + def do_text_embedding(self, text, normalize=True): + """ + Extract a text embedding (CLIP only). + + Args: + text: input string. + normalize: if True, L2-normalize the embedding. + + Returns: + numpy float32 array of shape (output_dim,), or None. + """ + import numpy as np + import torch + + if not self._is_clip: + self.logger.warning("Text embeddings only supported for CLIP models") + return None + + try: + inputs = self.processor(text=[text], return_tensors="pt", padding=True) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + emb = self.model.get_text_features(**inputs) + emb = emb.squeeze(0).cpu().numpy().astype(np.float32) + if normalize: + norm = np.linalg.norm(emb) + if norm > 0: + emb = emb / norm + return emb + except Exception as e: + self.logger.error(f"Text embedding error: {e}") + return None diff --git a/plugins/python/engine/engine_factory.py b/plugins/python/engine/engine_factory.py index 2a0e5fb..d361bdf 100644 --- a/plugins/python/engine/engine_factory.py +++ b/plugins/python/engine/engine_factory.py @@ -44,6 +44,7 @@ class EngineFactory: MIGRAPHX_ENGINE = "migraphx" IREE_ENGINE = "iree" NCNN_ENGINE = "ncnn" + DRPAI_ENGINE = "drpai" _builtins_registered: bool = False # Class-level flag for singleton-like lazy init @@ -154,6 +155,13 @@ def _register_builtins(cls) -> None: except ImportError: pass + try: + from .drpai_engine import DRPAIEngine + + _try_register(cls.DRPAI_ENGINE, DRPAIEngine) + except ImportError: + pass + @staticmethod def register(engine_type: str, engine_class: Type) -> None: _engine_registry[engine_type] = engine_class diff --git a/plugins/python/engine/face_engine.py b/plugins/python/engine/face_engine.py new file mode 100644 index 0000000..6679038 --- /dev/null +++ b/plugins/python/engine/face_engine.py @@ -0,0 +1,118 @@ +# FaceEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os + +from .pytorch_engine import PyTorchEngine + + +class FaceEngine(PyTorchEngine): + """ + PyTorch engine for face detection and recognition using InsightFace. + + Supports InsightFace model packs: + buffalo_l (large, most accurate) + buffalo_s (small, fastest) + buffalo_sc (small with recognition) + """ + + def do_load_model(self, model_name, **kwargs): + try: + from insightface.app import FaceAnalysis + + self.app = FaceAnalysis( + name=model_name, + providers=["CUDAExecutionProvider", "CPUExecutionProvider"], + ) + self.app.prepare(ctx_id=0, det_size=(640, 640)) + self.gallery = {} + self.logger.info(f"InsightFace model '{model_name}' loaded") + except Exception as e: + raise ValueError(f"Failed to load InsightFace model '{model_name}': {e}") + + def load_gallery(self, gallery_path): + """Load known face embeddings from a directory of images.""" + import numpy as np + from PIL import Image + + if not gallery_path or not os.path.isdir(gallery_path): + return + + self.gallery = {} + for fname in os.listdir(gallery_path): + fpath = os.path.join(gallery_path, fname) + if not os.path.isfile(fpath): + continue + try: + img = np.array(Image.open(fpath).convert("RGB")) + faces = self.app.get(img) + if faces: + name = os.path.splitext(fname)[0] + self.gallery[name] = faces[0].embedding + self.logger.info(f"Loaded gallery face: {name}") + except Exception as e: + self.logger.warning(f"Failed to load gallery image '{fname}': {e}") + + def do_forward(self, frames, threshold=0.5): + import numpy as np + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if not is_batch: + frames = frames[np.newaxis] + + results = [] + for frame in frames: + try: + faces = self.app.get(frame.astype(np.uint8)) + detections = [] + for face in faces: + bbox = face.bbox.astype(float).tolist() + score = float(face.det_score) + embedding = face.embedding + + identity = "unknown" + best_sim = 0.0 + if self.gallery and embedding is not None: + for name, gallery_emb in self.gallery.items(): + sim = float( + np.dot(embedding, gallery_emb) + / ( + np.linalg.norm(embedding) + * np.linalg.norm(gallery_emb) + + 1e-8 + ) + ) + if sim > best_sim: + best_sim = sim + if sim >= threshold: + identity = name + + detections.append( + { + "bbox": bbox, + "score": score, + "identity": identity, + "similarity": best_sim, + } + ) + results.append(detections) + except Exception as e: + self.logger.error(f"Face inference error on frame: {e}") + results.append([]) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/ml_engine.py b/plugins/python/engine/ml_engine.py index 3d5a74c..58f2e9d 100644 --- a/plugins/python/engine/ml_engine.py +++ b/plugins/python/engine/ml_engine.py @@ -96,7 +96,12 @@ def _apply_post_process(self, raw, is_batch): if pp != "none" and not isinstance(raw, list): from utils.detection_decoder import decode - results = decode(raw, pp) + results = decode( + raw, + pp, + conf_threshold=getattr(self, "conf", 0.25), + iou_threshold=getattr(self, "iou", 0.45), + ) return results[0] if not is_batch else results return raw diff --git a/plugins/python/engine/ocr_engine.py b/plugins/python/engine/ocr_engine.py new file mode 100644 index 0000000..432c8dc --- /dev/null +++ b/plugins/python/engine/ocr_engine.py @@ -0,0 +1,93 @@ +# OcrEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class OcrEngine(PyTorchEngine): + """ + PyTorch engine for TrOCR text recognition. + + Supports HuggingFace model IDs: + microsoft/trocr-base-printed + microsoft/trocr-large-printed + microsoft/trocr-base-handwritten + """ + + def do_load_model(self, model_name, **kwargs): + try: + from transformers import TrOCRProcessor, VisionEncoderDecoderModel + + self.processor = TrOCRProcessor.from_pretrained(model_name) + self.model = VisionEncoderDecoderModel.from_pretrained(model_name) + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info(f"TrOCR model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load TrOCR model '{model_name}': {e}") + + def do_forward(self, frames): + import numpy as np + import torch + from PIL import Image + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if not is_batch: + frames = frames[np.newaxis] + + results = [] + for frame in frames: + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + H, W = frame.shape[:2] + + # Split frame into horizontal strips for text region detection + strip_height = max(H // 4, 32) + texts = [] + regions = [] + for y_start in range(0, H, strip_height): + y_end = min(y_start + strip_height, H) + strip = pil_img.crop((0, y_start, W, y_end)) + pixel_values = self.processor( + images=strip, return_tensors="pt" + ).pixel_values.to(self.device) + + with torch.no_grad(): + generated_ids = self.model.generate(pixel_values) + + text = self.processor.batch_decode( + generated_ids, skip_special_tokens=True + )[0].strip() + if text: + texts.append(text) + regions.append( + { + "x": 0, + "y": y_start, + "w": W, + "h": y_end - y_start, + "text": text, + } + ) + + results.append({"texts": texts, "regions": regions}) + except Exception as e: + self.logger.error(f"OCR inference error on frame: {e}") + results.append({"texts": [], "regions": []}) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/onnx_engine.py b/plugins/python/engine/onnx_engine.py index ba85e84..ad3dce7 100644 --- a/plugins/python/engine/onnx_engine.py +++ b/plugins/python/engine/onnx_engine.py @@ -49,6 +49,62 @@ def _input_is_nchw(self): shape = self.session.get_inputs()[0].shape return len(shape) == 4 and shape[1] in (1, 3, 4) + def _model_input_hw(self): + """(H, W) the model's input expects, or None if dynamic/unknown.""" + if self.session is None: + return None + shape = self.session.get_inputs()[0].shape + if len(shape) != 4: + return None + h, w = shape[2], shape[3] + if isinstance(h, int) and isinstance(w, int) and h > 0 and w > 0: + return (h, w) + return None + + def _letterbox(self, frames, is_batch): + """Resize frame(s) to the model input size, preserving aspect ratio with + grey padding (YOLO-style). Returns (processed, transform); transform = + (ratio, pad_x, pad_y, orig_w, orig_h) maps model coords back to the + original frame. Returns (frames, None) when no resize is needed (already + model-sized, or dynamic input) -- so pre-sized callers are unaffected.""" + import numpy as np + import cv2 + + mhw = self._model_input_hw() + if mhw is None: + return frames, None + mh, mw = mhw + imgs = frames if is_batch else frames[None] + h, w = int(imgs.shape[1]), int(imgs.shape[2]) + if (h, w) == (mh, mw): + return frames, None + r = min(mh / h, mw / w) + nh, nw = int(round(h * r)), int(round(w * r)) + pad_x, pad_y = (mw - nw) // 2, (mh - nh) // 2 + out = np.full((imgs.shape[0], mh, mw, imgs.shape[3]), 114, dtype=imgs.dtype) + for i in range(imgs.shape[0]): + out[i, pad_y : pad_y + nh, pad_x : pad_x + nw] = cv2.resize( + imgs[i], (nw, nh), interpolation=cv2.INTER_LINEAR + ) + proc = out if is_batch else out[0] + return proc, (r, float(pad_x), float(pad_y), w, h) + + def _unletterbox(self, results, transform): + """Map detection boxes from model coords back to original-frame coords.""" + import numpy as np + + r, pad_x, pad_y, ow, oh = transform + for res in results if isinstance(results, list) else [results]: + if not isinstance(res, dict): + continue + b = res.get("boxes") + if b is None or len(b) == 0: + continue + b = np.asarray(b, dtype=np.float32).copy() + b[:, [0, 2]] = ((b[:, [0, 2]] - pad_x) / r).clip(0, ow) + b[:, [1, 3]] = ((b[:, [1, 3]] - pad_y) / r).clip(0, oh) + res["boxes"] = b + def do_load_model(self, model_name, **kwargs): """Load a pre-trained model by name from TorchVision, Transformers (via Optimum ONNX), or a local ONNX path.""" processor_name = kwargs.get("processor_name") @@ -369,10 +425,21 @@ def do_forward(self, frames): fmt = self.input_format if fmt == "auto" and self._input_is_nchw(): self.input_format = "nchw" - img = self._apply_input_format(frames.astype(np.float32) / 255.0, is_batch) + # Letterbox to the model's fixed input size for inference, keeping + # the transform so boxes map back to the original frame -- lets the + # caller feed full-res frames and overlay on them. + proc, transform = self._letterbox(frames, is_batch) + img = self._apply_input_format(proc.astype(np.float32) / 255.0, is_batch) + if "float16" in self.session.get_inputs()[0].type: + img = img.astype(np.float16) outputs = self.session.run(self.output_names, {self.input_names[0]: img}) raw = outputs if len(outputs) > 1 else outputs[0] - return self._apply_post_process(raw, is_batch) + if isinstance(raw, np.ndarray) and raw.dtype != np.float32: + raw = raw.astype(np.float32) + results = self._apply_post_process(raw, is_batch) + if transform is not None: + self._unletterbox(results, transform) + return results else: raise ValueError("Unsupported model type.") diff --git a/plugins/python/engine/optical_flow_engine.py b/plugins/python/engine/optical_flow_engine.py new file mode 100644 index 0000000..c90e190 --- /dev/null +++ b/plugins/python/engine/optical_flow_engine.py @@ -0,0 +1,86 @@ +# OpticalFlowEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class OpticalFlowEngine(PyTorchEngine): + """ + PyTorch engine for dense optical flow estimation using RAFT. + + Supports torchvision RAFT model variants: + raft_large (most accurate) + raft_small (fastest) + """ + + def do_load_model(self, model_name, **kwargs): + try: + from torchvision.models.optical_flow import ( + raft_large, + raft_small, + Raft_Large_Weights, + Raft_Small_Weights, + ) + + if model_name == "raft_small": + weights = Raft_Small_Weights.DEFAULT + self.model = raft_small(weights=weights) + else: + weights = Raft_Large_Weights.DEFAULT + self.model = raft_large(weights=weights) + + self.transforms = weights.transforms() + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info(f"RAFT model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load RAFT model '{model_name}': {e}") + + def do_forward(self, prev_frame, curr_frame): + import torch + + try: + H, W = curr_frame.shape[:2] + + # Convert HWC uint8 -> CHW float tensor + prev_t = torch.from_numpy(prev_frame).permute(2, 0, 1).float() + curr_t = torch.from_numpy(curr_frame).permute(2, 0, 1).float() + + # RAFT requires dimensions divisible by 8 + pad_h = (8 - H % 8) % 8 + pad_w = (8 - W % 8) % 8 + if pad_h > 0 or pad_w > 0: + prev_t = torch.nn.functional.pad(prev_t, (0, pad_w, 0, pad_h)) + curr_t = torch.nn.functional.pad(curr_t, (0, pad_w, 0, pad_h)) + + prev_t, curr_t = self.transforms(prev_t, curr_t) + prev_batch = prev_t.unsqueeze(0).to(self.device) + curr_batch = curr_t.unsqueeze(0).to(self.device) + + with torch.no_grad(): + flow_predictions = self.model(prev_batch, curr_batch) + + # RAFT returns a list of flow predictions; take the last (finest) + flow = flow_predictions[-1].squeeze(0).cpu().numpy() + # flow shape: (2, H', W') -> transpose to (H, W, 2) and crop + flow = flow.transpose(1, 2, 0)[:H, :W] + return flow + + except Exception as e: + self.logger.error(f"Optical flow inference error: {e}") + return None diff --git a/plugins/python/engine/sam_engine.py b/plugins/python/engine/sam_engine.py new file mode 100644 index 0000000..a104e96 --- /dev/null +++ b/plugins/python/engine/sam_engine.py @@ -0,0 +1,106 @@ +# SamEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class SamEngine(PyTorchEngine): + """ + PyTorch engine for Segment Anything Model 2 (SAM2). + + Supports HuggingFace model IDs: + facebook/sam2-hiera-large + facebook/sam2-hiera-base-plus + facebook/sam2-hiera-small + facebook/sam2-hiera-tiny + """ + + def do_load_model(self, model_name, **kwargs): + try: + from transformers import Sam2Model, Sam2Processor + + self.processor = Sam2Processor.from_pretrained(model_name) + self.model = Sam2Model.from_pretrained(model_name) + self.execute_with_stream(lambda: self.model.to(self.device)) + self.model.eval() + self.logger.info(f"SAM2 model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load SAM2 model '{model_name}': {e}") + + def do_forward(self, frames, max_masks=10): + import numpy as np + import torch + from PIL import Image + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if not is_batch: + frames = frames[np.newaxis] + + results = [] + for frame in frames: + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + H, W = frame.shape[:2] + + # Automatic mask generation: grid of input points + grid_size = int(np.ceil(np.sqrt(max_masks))) + xs = np.linspace(0, W - 1, grid_size).astype(int) + ys = np.linspace(0, H - 1, grid_size).astype(int) + points = [[int(x), int(y)] for y in ys for x in xs][:max_masks] + input_points = [points] + + inputs = self.processor( + images=pil_img, + input_points=input_points, + return_tensors="pt", + ) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + + with torch.no_grad(): + outputs = self.model(**inputs) + + masks = self.processor.post_process_masks( + outputs.pred_masks, + inputs["original_sizes"], + inputs["reshaped_input_sizes"], + ) + scores = outputs.iou_scores + + mask_list = [] + if len(masks) > 0: + frame_masks = masks[0].cpu().numpy() + frame_scores = scores[0].cpu().numpy() + for j in range(min(frame_masks.shape[0], max_masks)): + best_idx = frame_scores[j].argmax() + mask = frame_masks[j, best_idx] + score = float(frame_scores[j, best_idx]) + mask_list.append( + {"mask_idx": j, "score": score, "shape": list(mask.shape)} + ) + + results.append( + { + "masks": mask_list, + "raw_masks": masks[0].cpu().numpy() if len(masks) > 0 else None, + } + ) + except Exception as e: + self.logger.error(f"SAM inference error on frame: {e}") + results.append({"masks": [], "raw_masks": None}) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/sepformer_engine.py b/plugins/python/engine/sepformer_engine.py new file mode 100644 index 0000000..63555b5 --- /dev/null +++ b/plugins/python/engine/sepformer_engine.py @@ -0,0 +1,101 @@ +# SepformerEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os + +from .pytorch_engine import PyTorchEngine + + +class SepformerEngine(PyTorchEngine): + def __init__(self): + super().__init__() + self.sample_rate = 0 + + def do_load_model(self, model_name, **kwargs): + from speechbrain.pretrained import SepformerSeparation + from huggingface_hub import snapshot_download + + if not model_name: + return + self.logger.info(f"Loading Sepformer-WhamR model on device: {self.device}") + savedir = "pretrained_models/sepformer-whamr" + repo_id = "speechbrain/sepformer-whamr" + try: + # Download the model files manually to avoid deprecated argument issues + if not os.path.exists(savedir): + snapshot_download(repo_id=repo_id, local_dir=savedir) + # Load from local directory + self.model = SepformerSeparation.from_hparams( + source=savedir, savedir=savedir, run_opts={"device": self.device} + ) + self.sample_rate = 8000 # Hz, as per SpeechBrain Sepformer models + self.sources = ["source0", "source1"] # 2 sources for separation + except Exception as e: + self.logger.error(f"Failed to load Sepformer-WhamR model: {e}") + + def separate_sources( + self, + mix, + segment=10.0, + overlap=0.1, + ): + import torch + from torchaudio.transforms import Fade + + device = mix.device + batch, length = mix.shape # For SpeechBrain, input is (batch, time) + chunk_len = int(self.sample_rate * segment * (1 + overlap)) + start = 0 + end = chunk_len + overlap_frames = int(overlap * self.sample_rate) + fade = Fade(fade_in_len=0, fade_out_len=overlap_frames, fade_shape="linear") + + final = torch.zeros(batch, len(self.sources), length, device=device) + + min_chunk_samples = int(self.sample_rate * 0.5) # Avoid tiny chunks + + while start < length - overlap_frames: + actual_end = min(end, length) + chunk_length = actual_end - start + if chunk_length < min_chunk_samples: + break + + chunk = mix[:, start:actual_end] + if chunk_length < chunk_len: + pad = chunk_len - chunk_length + chunk = torch.nn.functional.pad(chunk, (0, pad)) + + # Add small epsilon noise to avoid zero std + chunk += 1e-8 * torch.randn_like(chunk) + + with torch.no_grad(): + out = self.model.separate_batch(chunk) # (batch, time, sources) + out = out.permute(0, 2, 1) # (batch, sources, time) + + out = out[:, :, :chunk_length] + out = fade(out) + final[:, :, start:actual_end] += out + if start == 0: + fade.fade_in_len = overlap_frames + start += int(chunk_len - overlap_frames) + else: + start += chunk_len + end += chunk_len + if end >= length: + fade.fade_out_len = 0 + return final diff --git a/plugins/python/engine/super_res_engine.py b/plugins/python/engine/super_res_engine.py new file mode 100644 index 0000000..7c87f40 --- /dev/null +++ b/plugins/python/engine/super_res_engine.py @@ -0,0 +1,92 @@ +# SuperResEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class SuperResEngine(PyTorchEngine): + """ + PyTorch engine for image super-resolution using Real-ESRGAN. + + Supports model variants: + real-esrgan-x4 (4x upscale, general purpose) + real-esrgan-x2 (2x upscale) + """ + + def do_load_model(self, model_name, **kwargs): + try: + from basicsr.archs.rrdbnet_arch import RRDBNet + from realesrgan import RealESRGANer + + scale = 4 + if "x2" in model_name: + scale = 2 + + model = RRDBNet( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_block=23, + num_grow_ch=32, + scale=scale, + ) + + model_url = ( + "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth" + if scale == 4 + else "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth" + ) + + gpu_id = 0 if str(self.device) != "cpu" else None + self.upsampler = RealESRGANer( + scale=scale, + model_path=model_url, + model=model, + tile=0, + tile_pad=10, + pre_pad=0, + half=False, + gpu_id=gpu_id, + ) + self._scale = scale + self.logger.info(f"Real-ESRGAN model '{model_name}' (scale={scale}) loaded") + except Exception as e: + raise ValueError(f"Failed to load Real-ESRGAN model '{model_name}': {e}") + + def do_forward(self, frames): + import cv2 + import numpy as np + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if not is_batch: + frames = frames[np.newaxis] + + results = [] + for frame in frames: + try: + # Real-ESRGAN expects BGR input + bgr = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_RGB2BGR) + output, _ = self.upsampler.enhance(bgr, outscale=self._scale) + # Convert back to RGB + rgb_out = cv2.cvtColor(output, cv2.COLOR_BGR2RGB) + results.append(rgb_out) + except Exception as e: + self.logger.error(f"Super-resolution inference error: {e}") + results.append(None) + + return results[0] if not is_batch else results diff --git a/plugins/python/engine/vlm_engine.py b/plugins/python/engine/vlm_engine.py new file mode 100644 index 0000000..903853d --- /dev/null +++ b/plugins/python/engine/vlm_engine.py @@ -0,0 +1,138 @@ +# VlmEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class VlmEngine(PyTorchEngine): + """ + PyTorch engine for Vision-Language Models. + + Supports HuggingFace VLM model IDs via AutoProcessor + AutoModelForVision2Seq: + llava-hf/llava-1.5-7b-hf + Qwen/Qwen2-VL-7B-Instruct + OpenGVLab/InternVL2-8B + """ + + def __init__(self): + super().__init__() + self.processor = None + + def do_load_model(self, model_name, **kwargs): + try: + import torch + from transformers import AutoProcessor, AutoModelForVision2Seq + + self.processor = AutoProcessor.from_pretrained(model_name) + self.model = AutoModelForVision2Seq.from_pretrained( + model_name, + torch_dtype=torch.float16, + device_map=self.device, + ) + self.model.eval() + self.logger.info(f"VLM model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load VLM model '{model_name}': {e}") + + def do_forward( + self, + frame, + prompt="Describe this image in detail.", + system_prompt=None, + max_tokens=256, + temperature=0.7, + ): + """ + Run VLM inference on a single video frame. + + Args: + frame: numpy RGB array (H, W, 3). + prompt: user prompt text. + system_prompt: optional system prompt. + max_tokens: maximum tokens to generate. + temperature: sampling temperature. + + Returns: + Generated text string, or None on failure. + """ + import numpy as np + from PIL import Image + + try: + pil_img = Image.fromarray(frame.astype(np.uint8)) + text = self.do_generate( + pil_img, prompt, system_prompt, max_tokens, temperature + ) + return text + except Exception as e: + self.logger.error(f"VLM inference error: {e}") + return None + + def do_generate(self, image, prompt, system_prompt, max_tokens, temperature): + """ + Apply chat template, process image + text, and generate a response. + + Args: + image: PIL Image. + prompt: user prompt text. + system_prompt: optional system prompt. + max_tokens: maximum new tokens. + temperature: sampling temperature. + + Returns: + Generated text string. + """ + import torch + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append( + { + "role": "user", + "content": [ + {"type": "image"}, + {"type": "text", "text": prompt}, + ], + } + ) + + # Apply chat template if the processor supports it + if hasattr(self.processor, "apply_chat_template"): + text_input = self.processor.apply_chat_template( + messages, add_generation_prompt=True + ) + else: + text_input = prompt + + inputs = self.processor(text=text_input, images=image, return_tensors="pt") + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + with torch.no_grad(): + output_ids = self.model.generate( + **inputs, + max_new_tokens=max_tokens, + temperature=temperature, + do_sample=temperature > 0, + ) + + # Decode only the newly generated tokens + input_len = inputs.get("input_ids", torch.tensor([])).shape[-1] + generated = output_ids[0][input_len:] + text = self.processor.decode(generated, skip_special_tokens=True) + return text.strip() diff --git a/plugins/python/engine/whisper_engine.py b/plugins/python/engine/whisper_engine.py new file mode 100644 index 0000000..33d9803 --- /dev/null +++ b/plugins/python/engine/whisper_engine.py @@ -0,0 +1,34 @@ +# WhisperEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class WhisperEngine(PyTorchEngine): + def do_load_model(self, model_name, **kwargs): + from faster_whisper import WhisperModel + + if not model_name: + return + compute_type = "float16" if self.device.startswith("cuda") else "int8" + self.logger.info( + f"Loading Whisper model on device: {self.device} with compute_type: {compute_type}" + ) + self.model = WhisperModel( + model_name, device=self.device, compute_type=compute_type + ) diff --git a/plugins/python/engine/yolo_advanced_engine.py b/plugins/python/engine/yolo_advanced_engine.py new file mode 100644 index 0000000..28c7273 --- /dev/null +++ b/plugins/python/engine/yolo_advanced_engine.py @@ -0,0 +1,1060 @@ +# YoloAdvancedEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from collections import deque + +from .pytorch_engine import PyTorchEngine + +BOT_OK = BYTE_OK = CFG_OK = True +BOTSORT = BYTETracker = get_cfg = Boxes = None + + +def _init_ultralytics(): + global BOT_OK, BYTE_OK, CFG_OK, BOTSORT, BYTETracker, get_cfg, Boxes + try: + from ultralytics.trackers.bot_sort import BOTSORT as _BS + + BOTSORT = _BS + except Exception: + BOT_OK = False + try: + from ultralytics.trackers.byte_tracker import BYTETracker as _BT + + BYTETracker = _BT + except Exception: + BYTE_OK = False + try: + from ultralytics.cfg import get_cfg as _gcfg + + get_cfg = _gcfg + except Exception: + CFG_OK = False + try: + from ultralytics.engine.results import Boxes as _Boxes + + Boxes = _Boxes + except Exception: + pass + + +def eye3(): + import numpy as np + + return np.eye(3, dtype=np.float32) + + +def estimate_global_motion( + prev_gray, + gray, + gmc_mode, + gmc_scale, + gft_max_corners, + gft_quality, + gft_min_dist, + lk_win, + lk_levels, + ransac_thresh, + frame_idx, + verbose=False, +): + import cv2 + import numpy as np + + if gmc_mode == "off": + if verbose: + print(f"[frame {frame_idx}] GMC OFF → I") + return eye3() + + def down(img): + if gmc_scale == 1.0: + return img + w = max(2, int(img.shape[1] * gmc_scale)) + h = max(2, int(img.shape[0] * gmc_scale)) + return cv2.resize(img, (w, h), interpolation=cv2.INTER_AREA) + + pg, cg = down(prev_gray), down(gray) + pts_prev = cv2.goodFeaturesToTrack( + pg, + maxCorners=gft_max_corners, + qualityLevel=gft_quality, + minDistance=gft_min_dist, + ) + if pts_prev is None or len(pts_prev) < 6: + if verbose: + print(f"[frame {frame_idx}] GMC: insufficient corners → I") + return eye3() + + pts_curr, st, _ = cv2.calcOpticalFlowPyrLK( + pg, + cg, + pts_prev, + None, + winSize=(lk_win, lk_win), + maxLevel=lk_levels, + criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01), + ) + if pts_curr is None or st is None: + if verbose: + print(f"[frame {frame_idx}] GMC: LK failed → I") + return eye3() + + m = st.reshape(-1).astype(bool) + if m.sum() < (4 if gmc_mode == "homography" else 3): + if verbose: + print(f"[frame {frame_idx}] GMC: not enough inliers → I") + return eye3() + + src = pts_prev[m] + dst = pts_curr[m] + if gmc_scale != 1.0: + s = 1.0 / gmc_scale + src *= s + dst *= s + + if gmc_mode == "homography": + H, _ = cv2.findHomography( + src, + dst, + cv2.RANSAC, + ransacReprojThreshold=ransac_thresh, + maxIters=1000, + ) + H = H.astype(np.float32) if H is not None else eye3() + else: + A, _ = cv2.estimateAffine2D( + src, dst, ransacReprojThreshold=ransac_thresh, maxIters=1000 + ) + H = np.vstack([A, [0, 0, 1]]).astype(np.float32) if A is not None else eye3() + + avg = float(np.mean(np.linalg.norm(dst - src, axis=1))) if len(src) > 0 else 0.0 + if verbose: + print( + f"[frame {frame_idx}] GMC {gmc_mode} inliers={int(m.sum())} avg_motion={avg:.2f}px" + ) + return H + + +def warp_points(points_xy, M): + import numpy as np + + if not points_xy: + return [] + P = np.c_[ + np.array(points_xy, dtype=np.float32), np.ones((len(points_xy), 1), np.float32) + ] + Q = (M @ P.T).T + Q = Q[:, :2] / np.clip(Q[:, 2:3], 1e-6, None) + return [tuple(q) for q in Q] + + +def classwise_keep(result, person_thr, ball_thr): + import numpy as np + + if result is None or result.boxes is None or len(result.boxes) == 0: + return np.zeros((0, 6), np.float32), np.zeros((0, 6), np.float32) + + b = result.boxes + xyxy = b.xyxy.cpu().numpy() + conf = ( + b.conf.cpu().numpy() if b.conf is not None else np.ones((len(b),), np.float32) + ) + cls = ( + b.cls.cpu().numpy().astype(int) + if b.cls is not None + else np.zeros((len(b),), np.int32) + ) + + keep_p = (cls == 0) & (conf >= person_thr) + keep_b = (cls == 32) & (conf >= ball_thr) + + dets_p = np.c_[xyxy[keep_p], conf[keep_p], cls[keep_p]].astype(np.float32) + dets_b = np.c_[xyxy[keep_b], conf[keep_b], cls[keep_b]].astype(np.float32) + return dets_p, dets_b + + +def dets_to_boxes(dets_xyxy_conf_cls, frame_shape): + _init_ultralytics() + if dets_xyxy_conf_cls is None or dets_xyxy_conf_cls.size == 0: + import torch as _torch + + data = _torch.zeros((0, 6), dtype=_torch.float32) + return Boxes(data, frame_shape) + import torch as _torch + + data = _torch.from_numpy(dets_xyxy_conf_cls).to(_torch.float32) + return Boxes(data, frame_shape) + + +def clamp_imgsz_for_device(imgsz, device_str): + if device_str in ("cpu", "auto"): + return min(imgsz, 1280) + return imgsz + + +def expand_roi(xyxy, scale, W, H, min_side=256, max_side=1920): + x1, y1, x2, y2 = map(float, xyxy) + cx, cy = (x1 + x2) * 0.5, (y1 + y2) * 0.5 + w, h = (x2 - x1), (y2 - y1) + side = max(w, h) * float(scale) + side = max(min_side, min(side, max_side)) + x1n = max(0, int(cx - side * 0.5)) + y1n = max(0, int(cy - side * 0.5)) + x2n = min(W - 1, int(cx + side * 0.5)) + y2n = min(H - 1, int(cy + side * 0.5)) + return x1n, y1n, x2n, y2n + + +def _normalize_tracker_args(args, kind="byte"): + def has(a): + return hasattr(args, a) and getattr(args, a) is not None + + def setif(a, v): + setattr(args, a, v) + + def copy_if_missing(target, *sources, default=None): + if not has(target): + for s in sources: + if has(s): + setif(target, getattr(args, s)) + return + if default is not None: + setif(target, default) + + copy_if_missing("track_high_thresh", "track_thresh", default=0.5) + copy_if_missing("track_thresh", "track_high_thresh", default=0.5) + copy_if_missing("track_low_thresh", default=0.1) + copy_if_missing("new_track_thresh", default=0.4) + copy_if_missing("match_thresh", default=0.8) + copy_if_missing("asso_thresh", "match_thresh", default=0.8) + copy_if_missing("track_buffer", default=30) + copy_if_missing("frame_rate", default=30) + copy_if_missing("fuse_score", default=True) + copy_if_missing("fuse_score_coef", default=1.0) + copy_if_missing("mot20", default=False) + + if kind == "botsort": + copy_if_missing("with_reid", default=True) + copy_if_missing("proximity_thresh", default=0.5) + copy_if_missing("appearance_thresh", default=0.25) + if has("cmc_method") and not has("gmc_method"): + setif("gmc_method", getattr(args, "cmc_method")) + copy_if_missing("gmc_method", default="sparseOptFlow") + + +class ByteTrackWrapper: + """Ultralytics BYTETracker; load params from YAML using get_cfg, accept Boxes.""" + + def __init__(self, yaml_path, frame_rate): + if not BYTE_OK: + raise RuntimeError("Ultralytics BYTETracker not available") + if not CFG_OK: + raise RuntimeError( + "Ultralytics get_cfg not available; update ultralytics package." + ) + args = get_cfg(yaml_path) + args.frame_rate = int(frame_rate) + _normalize_tracker_args(args, kind="byte") + self.tracker = BYTETracker(args, frame_rate=int(frame_rate)) + + def update(self, boxes: Boxes, frame): + return self.tracker.update(boxes, frame) + + +class BoTSORTWrapper: + """Ultralytics BOTSORT; load params via get_cfg, accept Boxes, safe ReID encoder.""" + + def __init__(self, yaml_path, frame_rate, enable_reid=True): + if not BOT_OK: + raise RuntimeError("Ultralytics BOTSORT not available") + if not CFG_OK: + raise RuntimeError( + "Ultralytics get_cfg not available; update ultralytics package." + ) + args = get_cfg(yaml_path) + args.frame_rate = int(frame_rate) + args.with_reid = bool(enable_reid) + _normalize_tracker_args(args, kind="botsort") + self.tracker = BOTSORT(args, frame_rate=int(frame_rate)) + self._install_safe_encoder() + + def _install_safe_encoder(self): + import cv2 + import numpy as np + import torch as _torch + + def _safe_hsv_hist(img_bgr, bboxes): + feats = [] + if img_bgr is None or bboxes is None: + return feats + + if hasattr(bboxes, "detach"): + bb = bboxes.detach().cpu().numpy() + elif isinstance(bboxes, np.ndarray): + bb = bboxes + else: + try: + bb = np.asarray(bboxes, dtype=np.float32) + except Exception: + bb = None + + H, W = img_bgr.shape[:2] + + def process_one(b): + b = np.asarray(b, dtype=np.float32).reshape(-1) + x1, y1, x2, y2 = map(float, b[:4]) + x1i = max(0, min(int(x1), W - 1)) + y1i = max(0, min(int(y1), H - 1)) + x2i = max(0, min(int(x2), W - 1)) + y2i = max(0, min(int(y2), H - 1)) + if x2i <= x1i or y2i <= y1i: + return _torch.zeros(512, dtype=_torch.float32) + crop = img_bgr[y1i:y2i, x1i:x2i] + if crop.size == 0: + return _torch.zeros(512, dtype=_torch.float32) + hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) + hist = cv2.calcHist( + [hsv], [0, 1, 2], None, [8, 8, 8], [0, 180, 0, 256, 0, 256] + ).flatten() + norm = np.linalg.norm(hist) + 1e-6 + hist = (hist / norm).astype(np.float32) + return _torch.from_numpy(hist) + + if bb is not None: + for b in bb: + feats.append(process_one(b)) + else: + for b in bboxes: + feats.append(process_one(b)) + return feats + + self.tracker.encoder = lambda img, tlbrs: _safe_hsv_hist(img, tlbrs) + + def update(self, boxes: Boxes, frame): + return self.tracker.update(boxes, frame) + + +def _callable_or_attr(obj, name): + v = getattr(obj, name, None) + if v is None: + return None + return v() if callable(v) else v + + +def tlbr_of(tr): + a = _callable_or_attr(tr, "tlbr") + if a is not None: + a = a.tolist() if hasattr(a, "tolist") else a + if len(a) == 4: + return a + a = _callable_or_attr(tr, "tlwh") + if a is not None: + a = a.tolist() if hasattr(a, "tolist") else a + if len(a) == 4: + x, y, w, h = a + return [x, y, x + w, y + h] + if hasattr(tr, "bbox"): + b = tr.bbox + return b.tolist() if hasattr(b, "tolist") else list(b) + return None + + +class BallState: + def __init__(self): + self.cx = None + self.cy = None + self.vx = 0.0 + self.vy = 0.0 + self.frame = -1 + + def predict(self, frame_idx, decay=0.85): + if self.cx is None: + return None + return (self.cx + decay * self.vx, self.cy + decay * self.vy) + + def update_from_xyxy(self, xyxy, frame_idx): + x1, y1, x2, y2 = map(float, xyxy) + cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2) + if self.cx is not None and self.frame >= 0: + dt = max(1, frame_idx - self.frame) + self.vx = (cx - self.cx) / dt + self.vy = (cy - self.cy) / dt + self.cx, self.cy, self.frame = cx, cy, frame_idx + + def update_from_center(self, cx, cy, frame_idx): + if self.cx is not None and self.frame >= 0: + dt = max(1, frame_idx - self.frame) + self.vx = (cx - self.cx) / dt + self.vy = (cy - self.cy) / dt + self.cx, self.cy, self.frame = float(cx), float(cy), int(frame_idx) + + +def _aspect_round_penalty(w, h): + import numpy as np + + ar = w / max(h, 1e-6) + roundness = np.exp(-((ar - 1.0) ** 2) / 0.15) + return 1.0 - float(roundness) + + +def _size_penalty(w, h, H, W): + import numpy as np + + s = max(w, h) + tgt = 0.03 * min(H, W) + return float(np.clip(abs(s - tgt) / (tgt + 1e-6), 0.0, 2.0)) * 0.5 + + +def select_best_ball( + dets_b, + frame_shape, + ball_state, + frame_idx, + w_conf=1.0, + w_dist=0.015, + w_size=0.5, + w_round=0.4, +): + import numpy as np + + if dets_b is None or len(dets_b) == 0: + return None + H, W = frame_shape + pred = ball_state.predict(frame_idx) + scores = [] + for d in dets_b: + x1, y1, x2, y2, conf, _ = d + cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2) + w, h = (x2 - x1), (y2 - y1) + s_conf = float(conf) + if pred is None: + dist_pen = 0.0 + else: + px, py = pred + dist = np.hypot(cx - px, cy - py) + dist_pen = float(dist / (0.5 * (H + W))) + size_pen = _size_penalty(w, h, H, W) + round_pen = _aspect_round_penalty(w, h) + s = ( + w_conf * s_conf + - w_dist * dist_pen + - w_size * size_pen + - w_round * round_pen + ) + scores.append(s) + if not scores: + return None + return int(np.argmax(scores)) + + +def nms_class(dets, iou_thr=0.5): + import numpy as np + + if dets is None or len(dets) == 0: + return dets + boxes = dets[:, :4].copy() + scores = dets[:, 4].copy() + order = scores.argsort()[::-1] + keep = [] + + def iou(a, b): + xx1 = np.maximum(a[0], b[0]) + yy1 = np.maximum(a[1], b[1]) + xx2 = np.minimum(a[2], b[2]) + yy2 = np.minimum(a[3], b[3]) + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + inter = w * h + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + return inter / (area_a + area_b - inter + 1e-6) + + while order.size > 0: + i = order[0] + keep.append(i) + if order.size == 1: + break + ious = np.array([iou(boxes[i], boxes[j]) for j in order[1:]]) + remain = np.where(ious <= iou_thr)[0] + order = order[remain + 1] + return dets[keep] + + +def add_trail_point(seq: deque, x: int, y: int, k: int, densify=True, max_gap=5): + if len(seq) > 0 and densify: + _, _, k_prev = seq[-1] + gap = k - k_prev + if 1 < gap <= max_gap: + x_prev, y_prev, _ = seq[-1] + for t in range(1, gap): + alpha = t / gap + xi = int(round((1 - alpha) * x_prev + alpha * x)) + yi = int(round((1 - alpha) * y_prev + alpha * y)) + seq.append((xi, yi, k_prev + t)) + seq.append((int(x), int(y), int(k))) + + +def iou_xyxy(a, b): + if a is None or b is None: + return 0.0 + ax1, ay1, ax2, ay2 = a + bx1, by1, bx2, by2 = b + xx1 = max(ax1, bx1) + yy1 = max(ay1, by1) + xx2 = min(ax2, bx2) + yy2 = min(ay2, by2) + w = max(0.0, xx2 - xx1) + h = max(0.0, yy2 - yy1) + inter = w * h + if inter <= 0: + return 0.0 + area_a = max(0.0, (ax2 - ax1)) * max(0.0, (ay2 - ay1)) + area_b = max(0.0, (bx2 - bx1)) * max(0.0, (by2 - by1)) + denom = area_a + area_b - inter + 1e-6 + return float(inter / denom) + + +def lerp(a, b, t): + return a * (1.0 - t) + b * t + + +def gate_accept( + center, + cand_box, + trail_seq, + last_shown_box, + recent_speed, + frame_shape, + args, + pred=None, + from_track=True, +): + import numpy as np + + if center is None: + return False + + H, W = frame_shape + hard_cap = float(args.ball_max_jump_rel) * float(min(H, W)) + + if len(trail_seq) == 0: + dist_prev = 0.0 + else: + x_prev, y_prev, _ = trail_seq[-1] + dist_prev = float(np.hypot(center[0] - x_prev, center[1] - y_prev)) + + if dist_prev > hard_cap: + return False + + base_gate = max( + float(args.ball_gate_min), float(args.ball_gate_rel) * float(min(H, W)) + ) + gate_px = base_gate if from_track else base_gate * 1.25 + pass_prev = (len(trail_seq) == 0) or (dist_prev <= gate_px) + + pred_ok = False + if getattr(args, "ball_gate_use_pred", False) and pred is not None: + d_pred = float(np.hypot(center[0] - pred[0], center[1] - pred[1])) + pred_ok = d_pred <= gate_px * 1.25 + + if not from_track: + return pass_prev or pred_ok + + iou_ok = (last_shown_box is None) or ( + iou_xyxy(cand_box, last_shown_box) >= float(args.ball_min_iou) + ) + + speed_ok = True + if recent_speed is not None and recent_speed > 0: + speed_ok = dist_prev <= float(args.ball_speed_mult) * float(recent_speed + 1e-6) + + return (pass_prev and iou_ok and speed_ok) or pred_ok + + +def safe_int_pair(wx, wy, W, H): + import numpy as np + + if wx is None or wy is None: + return None + if not (np.isfinite(wx) and np.isfinite(wy)): + return None + try: + xi = int(round(float(wx))) + yi = int(round(float(wy))) + except Exception: + return None + if abs(xi) > 10 * W or abs(yi) > 10 * H: + return None + return xi, yi + + +class YoloAdvancedEngine(PyTorchEngine): + def __init__(self, device=None, **kwargs): + super().__init__(device=device) + # Then set self.device_str = device if device else 'auto' + self.device_str = device if device else "auto" + self.det_model = None + self.fb_model = None + self.people_tracker = None + self.ball_tracker = None + self.ball_state = BallState() + self.single_ball_trail = deque(maxlen=kwargs.get("trail", 200)) + self.cum_H_history = [eye3()] + self.cum_H = eye3() + self.prev_gray = None + self.last_ball_xyxy = None + self.last_shown_box = None + self.recent_speed = None + self.ema_cxcy = None + self.coast_streak = 0 + self.det_reject_streak = 0 + self.dropped_by_gate = 0 + self.coast_used = 0 + self.frame_idx = 0 + self.frame_rate = kwargs.get("frame_rate", 30.0) + # Set all params from kwargs + self.device_str = kwargs.get("device", "auto") + self.imgsz = kwargs.get("imgsz", 1280) + self.conf = kwargs.get("conf", 0.25) + self.iou = kwargs.get("iou", 0.45) + self.classes = kwargs.get("classes", [0, 32]) + self.person_conf_keep = kwargs.get("person_conf_keep", 0.25) + self.ball_conf_keep = kwargs.get("ball_conf_keep", 0.04) + self.ball_mode = kwargs.get("ball_mode", True) + self.hires_fallback = kwargs.get("hires_fallback", True) + self.hires_imgsz = kwargs.get("hires_imgsz", 1536) + self.fallback_every = kwargs.get("fallback_every", 6) + self.fallback_tiles = kwargs.get("fallback_tiles", False) + self.tile_size = kwargs.get("tile_size", 1280) + self.tile_overlap = kwargs.get("tile_overlap", 256) + self.fallback_budget_ms = kwargs.get("fallback_budget_ms", 300) + self.ball_roi_boost = kwargs.get("ball_roi_boost", False) + self.roi_scale = kwargs.get("roi_scale", 2.5) + self.roi_min = kwargs.get("roi_min", 256) + self.roi_max = kwargs.get("roi_max", 1920) + self.tracker_people = kwargs.get("tracker_people", "botsort_people_reid.yaml") + self.tracker_ball = kwargs.get("tracker_ball", "bytetrack_ball.yaml") + self.people_reid = kwargs.get("people_reid", True) + self.trail = kwargs.get("trail", 200) + self.gmc = kwargs.get("gmc", "affine") + self.gmc_scale = kwargs.get("gmc_scale", 0.5) + self.gft_max_corners = kwargs.get("gft_max_corners", 400) + self.gft_quality = kwargs.get("gft_quality", 0.01) + self.gft_min_dist = kwargs.get("gft_min_dist", 8) + self.lk_win = kwargs.get("lk_win", 21) + self.lk_levels = kwargs.get("lk_levels", 3) + self.ransac_thresh = kwargs.get("ransac_thresh", 3.0) + self.ball_gate_rel = kwargs.get("ball_gate_rel", 0.06) + self.ball_gate_min = kwargs.get("ball_gate_min", 12) + self.ball_gate_use_pred = kwargs.get("ball_gate_use_pred", False) + self.ball_min_iou = kwargs.get("ball_min_iou", 0.20) + self.ball_max_jump_rel = kwargs.get("ball_max_jump_rel", 0.12) + self.ball_speed_mult = kwargs.get("ball_speed_mult", 3.0) + self.ball_smooth_ema = kwargs.get("ball_smooth_ema", 0.0) + self.det_override_conf = kwargs.get("det_override_conf", 0.28) + self.det_override_after = kwargs.get("det_override_after", 2) + self.reacquire_frames = kwargs.get("reacquire_frames", 6) + self.ball_coast = kwargs.get("ball_coast", False) + self.coast_max = kwargs.get("coast_max", 6) + self.coast_decay = kwargs.get("coast_decay", 0.90) + self.verbose = kwargs.get("verbose", False) + + def do_load_model(self, model_name, **kwargs): + _init_ultralytics() + try: + from ultralytics import YOLO + + # YOLO load unchanged... + self.det_model = YOLO(f"{model_name}.pt") + self.execute_with_stream(lambda: self.det_model.to(self.device)) + self.logger.info( + f"YOLO primary model '{model_name}' loaded on {self.device}" + ) + + if self.hires_fallback: + self.fb_model = YOLO(f"{model_name}.pt") + self.execute_with_stream(lambda: self.fb_model.to(self.device)) + + self.model = self.det_model # Alias for base compat + + # Trackers with fallback + if self.tracker_people: + try: + self.people_tracker = BoTSORTWrapper( + self.tracker_people, self.frame_rate, self.people_reid + ) + self.logger.info( + f"People tracker loaded from {self.tracker_people}" + ) + except Exception as te: + self.logger.warning(f"People tracker failed ({te}); disabling.") + self.people_tracker = None + + if self.tracker_ball: + try: + self.ball_tracker = ByteTrackWrapper( + self.tracker_ball, self.frame_rate + ) + self.logger.info(f"Ball tracker loaded from {self.tracker_ball}") + except Exception as te: + self.logger.warning(f"Ball tracker failed ({te}); disabling.") + self.ball_tracker = None + + # ... kwargs update unchanged ... + return self.tracker_people and self.tracker_ball + + except Exception as e: + self.logger.error(f"Core model load failed: {e}") + return False # No raise—let base handle + + def do_forward(self, frames): + import cv2 + import numpy as np + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + if is_batch: + frame_bgr = frames[0] # Assume single for stateful; extend if needed + else: + frame_bgr = np.array(frames, copy=True) + gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) + Hh, Ww = frame_bgr.shape[:2] + + # GMC + if self.prev_gray is not None: + H = estimate_global_motion( + self.prev_gray, + gray, + self.gmc, + self.gmc_scale, + self.gft_max_corners, + self.gft_quality, + self.gft_min_dist, + self.lk_win, + self.lk_levels, + self.ransac_thresh, + self.frame_idx, + self.verbose, + ) + self.cum_H = H @ self.cum_H + self.cum_H_history.append(self.cum_H.copy()) + self.prev_gray = gray + + # Detection + det_res = self.execute_with_stream( + lambda: self.det_model.predict( + frame_bgr, + imgsz=self.imgsz, + conf=self.conf, + iou=self.iou, + classes=self.classes, + device=self.device_str if self.device_str != "auto" else None, + verbose=False, + )[0] + ) + + dets_p, dets_b = classwise_keep( + det_res, self.person_conf_keep, self.ball_conf_keep + ) + + dets_b = nms_class(dets_b, iou_thr=0.35) + best_idx = select_best_ball( + dets_b, frame_bgr.shape[:2], self.ball_state, self.frame_idx + ) + if best_idx is not None: + dets_b = dets_b[[best_idx]] + self.ball_state.update_from_xyxy(dets_b[0, :4], self.frame_idx) + self.last_ball_xyxy = dets_b[0, :4].astype(int).tolist() + else: + dets_b = dets_b[:0] + + # Fallback logic + if ( + self.ball_mode + and self.hires_fallback + and dets_b.shape[0] == 0 + and self.frame_idx % max(1, self.fallback_every) == 0 + ): + clamp_imgsz = clamp_imgsz_for_device(self.hires_imgsz, self.device_str) + collected = [] + + if ( + self.ball_roi_boost + and self.last_ball_xyxy is not None + and self.fb_model is not None + ): + x1r, y1r, x2r, y2r = expand_roi( + self.last_ball_xyxy, + self.roi_scale, + Ww, + Hh, + min_side=self.roi_min, + max_side=self.roi_max, + ) + crop = frame_bgr[y1r:y2r, x1r:x2r] + pred = self.execute_with_stream( + lambda: self.fb_model.predict( + crop, + imgsz=min(max(x2r - x1r, y2r - y1r), clamp_imgsz), + conf=max(self.ball_conf_keep, 0.02), + iou=max(self.iou, 0.50), + classes=[32], + device=self.device_str if self.device_str != "auto" else None, + verbose=False, + )[0] + ) + if pred.boxes is not None and len(pred.boxes) > 0: + b = pred.boxes + xyxy = b.xyxy.cpu().numpy() + conf = ( + b.conf.cpu().numpy() + if b.conf is not None + else np.ones((len(b),), np.float32) + ) + cls = np.full((len(b),), 32, dtype=np.float32) + xyxy[:, [0, 2]] += x1r + xyxy[:, [1, 3]] += y1r + collected.append(np.c_[xyxy, conf, cls]) + + if self.fb_model is not None and not collected: + pred = self.execute_with_stream( + lambda: self.fb_model.predict( + frame_bgr, + imgsz=clamp_imgsz, + conf=max(self.ball_conf_keep, 0.02), + iou=max(self.iou, 0.50), + classes=[32], + device=self.device_str if self.device_str != "auto" else None, + verbose=False, + )[0] + ) + if pred.boxes is not None and len(pred.boxes) > 0: + b = pred.boxes + xyxy = b.xyxy.cpu().numpy() + conf = ( + b.conf.cpu().numpy() + if b.conf is not None + else np.ones((len(b),), np.float32) + ) + cls = np.full((len(b),), 32, dtype=np.float32) + collected.append(np.c_[xyxy, conf, cls]) + + if collected: + dets_b = np.vstack(collected).astype(np.float32) + dets_b = nms_class(dets_b, iou_thr=0.35) + best_idx = select_best_ball( + dets_b, frame_bgr.shape[:2], self.ball_state, self.frame_idx + ) + if best_idx is not None: + dets_b = dets_b[[best_idx]] + self.ball_state.update_from_xyxy(dets_b[0, :4], self.frame_idx) + self.last_ball_xyxy = dets_b[0, :4].astype(int).tolist() + else: + dets_b = dets_b[:0] + + frame_shape = frame_bgr.shape[:2] + boxes_p = dets_to_boxes(dets_p, frame_shape) + boxes_b = dets_to_boxes(dets_b, frame_shape) + tracks_p = ( + self.people_tracker.update(boxes_p, frame_bgr) + if self.people_tracker + else [] + ) + tracks_b = ( + self.ball_tracker.update(boxes_b, frame_bgr) if self.ball_tracker else [] + ) + + # Ball candidate selection and gating + cand_box = None + ball_center_candidate = None + cand_conf = None + from_track = False + + if len(tracks_b) >= 1: + tr = tracks_b[0] + tb = tlbr_of(tr) + if tb is not None: + x1, y1, x2, y2 = map(int, tb) + cand_box = [x1, y1, x2, y2] + ball_center_candidate = ((x1 + x2) // 2, (y1 + y2) // 2) + from_track = True + cand_conf = None + elif dets_b.shape[0] == 1: + x1, y1, x2, y2 = map(int, dets_b[0, :4]) + cand_box = [x1, y1, x2, y2] + ball_center_candidate = ((x1 + x2) // 2, (y1 + y2) // 2) + cand_conf = float(dets_b[0, 4]) + from_track = False + + pred_pos = ( + self.ball_state.predict(self.frame_idx) if self.ball_gate_use_pred else None + ) + accept = gate_accept( + ball_center_candidate, + cand_box, + self.single_ball_trail, + self.last_shown_box, + self.recent_speed, + frame_bgr.shape[:2], + self, # Use self as args + pred=pred_pos, + from_track=from_track, + ) + + # Det override logic + if (not accept) and (ball_center_candidate is not None) and (not from_track): + self.det_reject_streak += 1 + gap_frames = ( + (self.frame_idx - self.single_ball_trail[-1][2]) + if len(self.single_ball_trail) + else 9999 + ) + Hmin = float(min(Hh, Ww)) + base_gate = max(float(self.ball_gate_min), float(self.ball_gate_rel) * Hmin) + + if cand_conf is not None and cand_conf >= float(self.det_override_conf): + x_prev, y_prev = ( + (self.single_ball_trail[-1][0], self.single_ball_trail[-1][1]) + if self.single_ball_trail + else (ball_center_candidate[0], ball_center_candidate[1]) + ) + dist_prev = float( + np.hypot( + ball_center_candidate[0] - x_prev, + ball_center_candidate[1] - y_prev, + ) + ) + if ( + (self.det_reject_streak >= int(self.det_override_after)) + or (gap_frames >= int(self.reacquire_frames)) + or (dist_prev <= 2.5 * base_gate) + ): + accept = True # force accept the detection + else: + self.det_reject_streak = 0 + + if accept and ball_center_candidate is not None: + cx_raw, cy_raw = ball_center_candidate + alpha = float(self.ball_smooth_ema) + if 0.0 < alpha <= 1.0: + if self.ema_cxcy is None: + self.ema_cxcy = (float(cx_raw), float(cy_raw)) + else: + self.ema_cxcy = ( + lerp(self.ema_cxcy[0], float(cx_raw), alpha), + lerp(self.ema_cxcy[1], float(cy_raw), alpha), + ) + cx, cy = int(round(self.ema_cxcy[0])), int(round(self.ema_cxcy[1])) + else: + cx, cy = cx_raw, cy_raw + + if from_track: + x1, y1, x2, y2 = cand_box + # No drawing here, as it's engine + + # Update trail + if ( + len(self.single_ball_trail) >= 1 + and self.single_ball_trail[-1][2] <= self.frame_idx - 2 + ): + x_prev, y_prev, k_prev = self.single_ball_trail[-1] + if self.frame_idx - k_prev == 2: + add_trail_point( + self.single_ball_trail, + (x_prev + cx) // 2, + (y_prev + cy) // 2, + k_prev + 1, + densify=False, + ) + + add_trail_point( + self.single_ball_trail, cx, cy, self.frame_idx, densify=True, max_gap=5 + ) + + if from_track: + self.ball_state.update_from_xyxy(cand_box, self.frame_idx) + else: + if cand_box is not None: + self.ball_state.update_from_center(cx, cy, self.frame_idx) + + self.last_shown_box = ( + cand_box if cand_box is not None else self.last_shown_box + ) + + if len(self.single_ball_trail) >= 2: + x0, y0, _ = self.single_ball_trail[-2] + step = float(np.hypot(cx - x0, cy - y0)) + self.recent_speed = ( + step + if self.recent_speed is None + else 0.8 * self.recent_speed + 0.2 * step + ) + + self.coast_streak = 0 + self.det_reject_streak = 0 + + else: + if ball_center_candidate is not None: + self.dropped_by_gate += 1 + + if ( + self.ball_coast + and len(self.single_ball_trail) > 0 + and self.coast_streak < int(self.coast_max) + ): + pred = self.ball_state.predict( + self.frame_idx, decay=float(self.coast_decay) + ) + if pred is not None and np.all(np.isfinite(pred)): + px, py = int(round(pred[0])), int(round(pred[1])) + x_prev, y_prev, _ = self.single_ball_trail[-1] + hard_cap = float(self.ball_max_jump_rel) * float(min(Hh, Ww)) + if ( + 0 <= px < Ww + and 0 <= py < Hh + and float(np.hypot(px - x_prev, py - y_prev)) <= 1.25 * hard_cap + ): + add_trail_point( + self.single_ball_trail, + px, + py, + self.frame_idx, + densify=False, + ) + self.ball_state.update_from_center(px, py, self.frame_idx) + if len(self.single_ball_trail) >= 2: + step = float(np.hypot(px - x_prev, py - y_prev)) + self.recent_speed = ( + step + if self.recent_speed is None + else 0.8 * self.recent_speed + 0.2 * step + ) + self.coast_streak += 1 + self.coast_used += 1 + else: + self.coast_streak = 0 + + self.frame_idx += 1 + + # Return result for decode + class AdvancedResult: + def __init__(self, tracks_p, tracks_b, ball_trail, boxes): + self.tracks_p = tracks_p + self.tracks_b = tracks_b + self.ball_trail = list(ball_trail) + self.boxes = boxes + + return AdvancedResult(tracks_p, tracks_b, self.single_ball_trail, det_res.boxes) diff --git a/plugins/python/engine/yolo_engine.py b/plugins/python/engine/yolo_engine.py new file mode 100644 index 0000000..ba53ba5 --- /dev/null +++ b/plugins/python/engine/yolo_engine.py @@ -0,0 +1,103 @@ +# YoloEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import time + +from .pytorch_engine import PyTorchEngine + + +class YoloEngine(PyTorchEngine): + def do_load_model(self, model_name, **kwargs): + try: + from ultralytics import YOLO + + self.model = YOLO(f"{model_name}.pt") + self.execute_with_stream(lambda: self.model.to(self.device)) + self.logger.info(f"YOLO model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load YOLO model '{model_name}'. Error: {e}") + + def do_forward(self, frames): + import numpy as np + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + writable_frames = np.array(frames, copy=True) + batch_size = writable_frames.shape[0] if is_batch else 1 + + model = self.get_model() + if model is None: + self.logger.error("Model is not loaded.") + return None if not is_batch else [None] * batch_size + + try: + start_pre = time.time() + img_list = ( + [ + writable_frames[i] if is_batch else writable_frames + for i in range(batch_size) + ] + if is_batch + else [writable_frames] + ) + self.logger.debug( + f"Input shape: {writable_frames.shape}, min={writable_frames.min()}, max={writable_frames.max()}" + ) + end_pre = time.time() + + conf = getattr(self, "conf", 0.25) + iou = getattr(self, "iou", 0.5) + agnostic = getattr(self, "agnostic_nms", True) + if self.track: + # Ensure tracker persists across batches + results = self.execute_with_stream( + lambda: model.track( + source=img_list, + persist=True, + imgsz=640, + conf=conf, + iou=iou, + agnostic_nms=agnostic, + verbose=True, + tracker="botsort.yaml", + ) + ) + else: + results = self.execute_with_stream( + lambda: model( + img_list, + imgsz=640, + conf=conf, + iou=iou, + agnostic_nms=agnostic, + verbose=True, + ) + ) + end_inf = time.time() + + if results is None or (isinstance(results, list) and not results): + self.logger.warning("Inference returned None or empty list.") + return None if not is_batch else [None] * batch_size + + self.logger.info( + f"Preprocessing: {(end_pre - start_pre)*1000:.2f} ms, Inference: {(end_inf - end_pre)*1000:.2f} ms for {batch_size} frames" + ) + return results[0] if not is_batch else results + + except Exception as e: + self.logger.error(f"Error during inference: {e}") + return None if not is_batch else [None] * batch_size diff --git a/plugins/python/engine/yolo_pose_engine.py b/plugins/python/engine/yolo_pose_engine.py new file mode 100644 index 0000000..3d33953 --- /dev/null +++ b/plugins/python/engine/yolo_pose_engine.py @@ -0,0 +1,59 @@ +# YoloPoseEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from .pytorch_engine import PyTorchEngine + + +class YoloPoseEngine(PyTorchEngine): + """PyTorch engine for YOLO pose estimation models.""" + + def do_load_model(self, model_name, **kwargs): + try: + from ultralytics import YOLO + + self.model = YOLO(f"{model_name}.pt") + self.execute_with_stream(lambda: self.model.to(self.device)) + self.logger.info(f"YOLO pose model '{model_name}' loaded on {self.device}") + except Exception as e: + raise ValueError(f"Failed to load YOLO pose model '{model_name}': {e}") + + def do_forward(self, frames): + import numpy as np + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + writable = np.array(frames, copy=True) + batch_size = writable.shape[0] if is_batch else 1 + + model = self.get_model() + if model is None: + self.logger.error("Pose model not loaded") + return None if not is_batch else [None] * batch_size + + try: + img_list = ( + [writable[i] for i in range(batch_size)] if is_batch else [writable] + ) + results = self.execute_with_stream( + lambda: model(img_list, imgsz=640, conf=0.25, verbose=False) + ) + if not results: + return None if not is_batch else [None] * batch_size + return results[0] if not is_batch else results + except Exception as e: + self.logger.error(f"Pose inference error: {e}") + return None if not is_batch else [None] * batch_size diff --git a/plugins/python/face.py b/plugins/python/face.py index 4b03976..3d7d4d8 100644 --- a/plugins/python/face.py +++ b/plugins/python/face.py @@ -17,23 +17,22 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: import json - import os import gi gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib + from gi.repository import Gst + from backend import analytics, GObject from base_objectdetector import BaseObjectDetector - from engine.pytorch_engine import PyTorchEngine + from engine.face_engine import FaceEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -44,103 +43,6 @@ FACE_META_HEADER = b"GST-FACE:" -class FaceEngine(PyTorchEngine): - """ - PyTorch engine for face detection and recognition using InsightFace. - - Supports InsightFace model packs: - buffalo_l (large, most accurate) - buffalo_s (small, fastest) - buffalo_sc (small with recognition) - """ - - def do_load_model(self, model_name, **kwargs): - try: - from insightface.app import FaceAnalysis - - self.app = FaceAnalysis( - name=model_name, - providers=["CUDAExecutionProvider", "CPUExecutionProvider"], - ) - self.app.prepare(ctx_id=0, det_size=(640, 640)) - self.gallery = {} - self.logger.info(f"InsightFace model '{model_name}' loaded") - except Exception as e: - raise ValueError(f"Failed to load InsightFace model '{model_name}': {e}") - - def load_gallery(self, gallery_path): - """Load known face embeddings from a directory of images.""" - import numpy as np - from PIL import Image - - if not gallery_path or not os.path.isdir(gallery_path): - return - - self.gallery = {} - for fname in os.listdir(gallery_path): - fpath = os.path.join(gallery_path, fname) - if not os.path.isfile(fpath): - continue - try: - img = np.array(Image.open(fpath).convert("RGB")) - faces = self.app.get(img) - if faces: - name = os.path.splitext(fname)[0] - self.gallery[name] = faces[0].embedding - self.logger.info(f"Loaded gallery face: {name}") - except Exception as e: - self.logger.warning(f"Failed to load gallery image '{fname}': {e}") - - def do_forward(self, frames, threshold=0.5): - import numpy as np - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - results = [] - for frame in frames: - try: - faces = self.app.get(frame.astype(np.uint8)) - detections = [] - for face in faces: - bbox = face.bbox.astype(float).tolist() - score = float(face.det_score) - embedding = face.embedding - - identity = "unknown" - best_sim = 0.0 - if self.gallery and embedding is not None: - for name, gallery_emb in self.gallery.items(): - sim = float( - np.dot(embedding, gallery_emb) - / ( - np.linalg.norm(embedding) - * np.linalg.norm(gallery_emb) - + 1e-8 - ) - ) - if sim > best_sim: - best_sim = sim - if sim >= threshold: - identity = name - - detections.append( - { - "bbox": bbox, - "score": score, - "identity": identity, - "similarity": best_sim, - } - ) - results.append(detections) - except Exception as e: - self.logger.error(f"Face inference error on frame: {e}") - results.append([]) - - return results[0] if not is_batch else results - - class FaceTransform(BaseObjectDetector): """ GStreamer element for face detection and recognition using InsightFace. @@ -204,7 +106,7 @@ def do_decode(self, buf, result, stream_idx=0): self.logger.info(f"Stream {stream_idx}: no faces detected") return - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if not meta: self.logger.error("Failed to add analytics relation metadata") return @@ -217,16 +119,16 @@ def do_decode(self, buf, result, stream_idx=0): identity = det["identity"] similarity = det["similarity"] - qk = GLib.quark_from_string(f"stream_{stream_idx}_face_{identity}") - ret, _ = meta.add_od_mtd( - qk, + mtd = analytics.add_object( + meta, + f"stream_{stream_idx}_face_{identity}", x1, y1, x2 - x1, y2 - y1, score, ) - if not ret: + if mtd is None: self.logger.error(f"Failed to add od_mtd for face {i}") continue @@ -251,10 +153,9 @@ def do_decode(self, buf, result, stream_idx=0): ) -if CAN_REGISTER_ELEMENT: - GObject.type_register(FaceTransform) - __gstelementfactory__ = ("pyml_face", Gst.Rank.NONE, FaceTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_face", FaceTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_face' element will not be registered because required modules are missing." ) diff --git a/plugins/python/football_analyzer.py b/plugins/python/football_analyzer.py new file mode 100644 index 0000000..1f4190c --- /dev/null +++ b/plugins/python/football_analyzer.py @@ -0,0 +1,946 @@ +# FootballAnalyzer +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os +import pickle + +from log.global_logger import GlobalLogger +import backend + +CAN_REGISTER_ELEMENT = True +try: + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + gi.require_version("GstVideo", "1.0") + from gi.repository import Gst, GstBase, GstVideo # noqa: E402 + from backend import GObject # noqa: E402 + + # Define caps before the optional heavy imports so the element's pad + # templates still resolve when an optional dep (e.g. supervision) is missing; + # only registration is then skipped (CAN_REGISTER_ELEMENT=False). + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + VIDEO_CAPS = Gst.Caps.from_string("video/x-raw, format=BGR") + + from log.logger_factory import LoggerFactory # noqa: E402 + +except ImportError as e: + CAN_REGISTER_ELEMENT = False + GlobalLogger().warning( + f"The 'pyml_football_analyzer' element will not be available. Error: {e}" + ) + + +def get_center_of_bbox(bbox): + x1, y1, x2, y2 = bbox + return int((x1 + x2) / 2), int((y1 + y2) / 2) + + +def get_bbox_width(bbox): + return bbox[2] - bbox[0] + + +class Tracker: + """Tracker.""" + + def __init__(self, model_path): + import cv2 + import supervision as sv + from ultralytics import YOLO + + self.model = YOLO(model_path) + self.tracker = sv.ByteTrack() + self.sift = cv2.SIFT_create() + self.matcher = cv2.BFMatcher(cv2.NORM_L2) + + def _foreground_mask(self, shape, frame_tracks, dilation=15): + import numpy as np + + h, w = shape[:2] + mask = np.full((h, w), 255, dtype=np.uint8) + bboxes = [] + for key in ("players", "referees", "ball"): + for obj in frame_tracks.get(key, {}).values(): + bboxes.append(obj["bbox"]) + for bbox in bboxes: + x1 = max(0, int(bbox[0]) - dilation) + y1 = max(0, int(bbox[1]) - dilation) + x2 = min(w, int(bbox[2]) + dilation) + y2 = min(h, int(bbox[3]) + dilation) + mask[y1:y2, x1:x2] = 0 + return mask + + def get_camera_motion( + self, + frames, + tracks, + read_from_stub=False, + stub_path=None, + ratio=0.75, + ransac_thresh=3.0, + min_matches=8, + ): + import cv2 + import numpy as np + + if read_from_stub and stub_path is not None and os.path.exists(stub_path): + with open(stub_path, "rb") as f: + return pickle.load(f) + + cumulative = [np.eye(3, dtype=np.float64)] + prev_gray = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY) + prev_mask = self._foreground_mask( + frames[0].shape, {k: tracks[k][0] for k in tracks} + ) + prev_kp, prev_desc = self.sift.detectAndCompute(prev_gray, prev_mask) + + for i in range(1, len(frames)): + curr_gray = cv2.cvtColor(frames[i], cv2.COLOR_BGR2GRAY) + curr_mask = self._foreground_mask( + frames[i].shape, {k: tracks[k][i] for k in tracks} + ) + curr_kp, curr_desc = self.sift.detectAndCompute(curr_gray, curr_mask) + + H_step = np.eye(3, dtype=np.float64) + if ( + prev_desc is not None + and curr_desc is not None + and len(prev_desc) >= 2 + and len(curr_desc) >= 2 + ): + knn = self.matcher.knnMatch(prev_desc, curr_desc, k=2) + good = [ + m + for pair in knn + if len(pair) == 2 + for m, n in [pair] + if m.distance < ratio * n.distance + ] + if len(good) >= min_matches: + pts_prev = np.float32( + [prev_kp[m.queryIdx].pt for m in good] + ).reshape(-1, 1, 2) + pts_curr = np.float32( + [curr_kp[m.trainIdx].pt for m in good] + ).reshape(-1, 1, 2) + H, _ = cv2.findHomography( + pts_prev, pts_curr, cv2.RANSAC, ransac_thresh + ) + if H is not None: + H_step = H + + cumulative.append(H_step @ cumulative[-1]) + prev_kp, prev_desc = curr_kp, curr_desc + + if stub_path is not None: + with open(stub_path, "wb") as f: + pickle.dump(cumulative, f) + return cumulative + + def detect_frames(self, frames): + batch_size = 20 + detections = [] + for i in range(0, len(frames), batch_size): + detections_batch = self.model.predict(frames[i : i + batch_size], conf=0.1) + detections += detections_batch + return detections + + def get_object_tracks(self, frames, read_from_stub=False, stub_path=None): + import supervision as sv + + if read_from_stub and stub_path is not None and os.path.exists(stub_path): + with open(stub_path, "rb") as f: + tracks = pickle.load(f) + return tracks + + detections = self.detect_frames(frames) + tracks = {"players": [], "referees": [], "ball": []} + + per_frame = [] + class_votes = {} + for detection in detections: + cls_names = detection.names + cls_names_inv = {v: k for k, v in cls_names.items()} + + detection_supervision = sv.Detections.from_ultralytics(detection) + + for object_ind, class_id in enumerate(detection_supervision.class_id): + if cls_names[class_id] == "goalkeeper": + detection_supervision.class_id[object_ind] = cls_names_inv["player"] + + tracked = self.tracker.update_with_detections(detection_supervision) + per_frame.append((tracked, detection_supervision, cls_names, cls_names_inv)) + + for fd in tracked: + cls_name = cls_names[fd[3]] + track_id = fd[4] + if cls_name in ("player", "referee"): + v = class_votes.setdefault(track_id, {"player": 0, "referee": 0}) + v[cls_name] += 1 + + track_class = { + tid: ("player" if v["player"] >= v["referee"] else "referee") + for tid, v in class_votes.items() + } + + for frame_num, (tracked, raw_detections, cls_names, cls_names_inv) in enumerate( + per_frame + ): + tracks["players"].append({}) + tracks["referees"].append({}) + tracks["ball"].append({}) + + for fd in tracked: + bbox = fd[0].tolist() + track_id = fd[4] + stable_cls = track_class.get(track_id) + if stable_cls == "player": + tracks["players"][frame_num][track_id] = {"bbox": bbox} + elif stable_cls == "referee": + tracks["referees"][frame_num][track_id] = {"bbox": bbox} + + for fd in raw_detections: + if fd[3] == cls_names_inv["ball"]: + tracks["ball"][frame_num][1] = {"bbox": fd[0].tolist()} + + if stub_path is not None: + with open(stub_path, "wb") as f: + pickle.dump(tracks, f) + + return tracks + + def draw_ellipse(self, frame, bbox, color, track_id=None): + import cv2 + + y2 = int(bbox[3]) + x_center, _ = get_center_of_bbox(bbox) + width = get_bbox_width(bbox) + + cv2.ellipse( + frame, + center=(x_center, y2), + axes=(int(width), int(0.35 * width)), + angle=0.0, + startAngle=-45, + endAngle=235, + color=color, + thickness=2, + lineType=cv2.LINE_4, + ) + + rectangle_width = 40 + rectangle_height = 20 + x1_rect = x_center - rectangle_width // 2 + x2_rect = x_center + rectangle_width // 2 + y1_rect = (y2 - rectangle_height // 2) + 15 + y2_rect = (y2 + rectangle_height // 2) + 15 + + if track_id is not None: + cv2.rectangle( + frame, + (int(x1_rect), int(y1_rect)), + (int(x2_rect), int(y2_rect)), + color, + cv2.FILLED, + ) + x1_text = x1_rect + 12 + if track_id > 99: + x1_text -= 10 + cv2.putText( + frame, + f"{track_id}", + (int(x1_text), int(y1_rect + 15)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 0, 0), + 2, + ) + return frame + + def draw_traingle(self, frame, bbox, color): + import cv2 + import numpy as np + + y = int(bbox[1]) + x, _ = get_center_of_bbox(bbox) + triangle_points = np.array( + [ + [x, y], + [x - 10, y - 20], + [x + 10, y - 20], + ] + ) + cv2.drawContours(frame, [triangle_points], 0, color, cv2.FILLED) + cv2.drawContours(frame, [triangle_points], 0, (0, 0, 0), 2) + return frame + + def classify_jersey(self, frame, bbox): + import cv2 + + x1, y1, x2, y2 = [int(v) for v in bbox] + h_box, w_box = y2 - y1, x2 - x1 + if h_box <= 0 or w_box <= 0: + return None + jy1 = y1 + int(0.15 * h_box) + jy2 = y1 + int(0.55 * h_box) + jx1 = x1 + int(0.25 * w_box) + jx2 = x1 + int(0.75 * w_box) + H, W = frame.shape[:2] + jy1, jy2 = max(0, jy1), min(H, jy2) + jx1, jx2 = max(0, jx1), min(W, jx2) + if jy2 - jy1 < 3 or jx2 - jx1 < 3: + return None + patch = frame[jy1:jy2, jx1:jx2] + hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV) + s_v = (hsv[..., 1] > 80) & (hsv[..., 2] > 50) + h = hsv[..., 0] + red = (((h <= 10) | (h >= 170)) & s_v).sum() + blue = ((h >= 100) & (h <= 130) & s_v).sum() + min_pixels = max(20, int(0.02 * patch.shape[0] * patch.shape[1])) + if red < min_pixels and blue < min_pixels: + return None + return "red" if red >= blue else "blue" + + def _ref_bottom_center(self, bbox, H_inv): + import cv2 + import numpy as np + + xc, _ = get_center_of_bbox(bbox) + yb = int(bbox[3]) + pt = cv2.perspectiveTransform(np.array([[[xc, yb]]], dtype=np.float32), H_inv)[ + 0 + ][0] + return float(pt[0]), float(pt[1]) + + def _minimap_extent(self, tracks, camera_motion): + import numpy as np + + xs, ys = [], [] + n = len(tracks["players"]) + for i in range(n): + H_inv = ( + np.linalg.inv(camera_motion[i]) + if camera_motion is not None + else np.eye(3) + ) + for key in ("players", "referees"): + for p in tracks[key][i].values(): + x, y = self._ref_bottom_center(p["bbox"], H_inv) + xs.append(x) + ys.append(y) + if not xs: + return None + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + pad_x = 0.05 * max(1.0, max_x - min_x) + pad_y = 0.05 * max(1.0, max_y - min_y) + return min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y + + def _make_minimap_bg(self, mm_w, mm_h): + import cv2 + import numpy as np + + bg = np.full((mm_h, mm_w, 3), (40, 110, 40), dtype=np.uint8) + cv2.rectangle(bg, (2, 2), (mm_w - 3, mm_h - 3), (240, 240, 240), 2) + cv2.line(bg, (mm_w // 2, 2), (mm_w // 2, mm_h - 3), (240, 240, 240), 1) + cv2.circle(bg, (mm_w // 2, mm_h // 2), max(10, mm_h // 8), (240, 240, 240), 1) + return bg + + def _project_to_minimap(self, extent, mm_w, mm_h, x, y): + min_x, min_y, max_x, max_y = extent + dx = max(1e-6, max_x - min_x) + dy = max(1e-6, max_y - min_y) + scale = min((mm_w - 10) / dx, (mm_h - 10) / dy) + off_x = (mm_w - scale * dx) / 2.0 + off_y = (mm_h - scale * dy) / 2.0 + return int(off_x + (x - min_x) * scale), int(off_y + (y - min_y) * scale) + + def _smooth_points(self, pts, window): + import numpy as np + + if window <= 1 or len(pts) < 2: + return pts + pts = np.asarray(pts, dtype=np.float32) + n = len(pts) + half = window // 2 + smoothed = np.empty_like(pts) + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + smoothed[i] = pts[lo:hi].mean(axis=0) + return smoothed + + def draw_trail(self, frame, points, color): + import cv2 + import numpy as np + + if len(points) < 2: + return frame + pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines( + frame, [pts], isClosed=False, color=color, thickness=2, lineType=cv2.LINE_AA + ) + return frame + + def _point_to_bbox_distance(self, px, py, bbox): + import numpy as np + + x1, y1, x2, y2 = bbox + dx = max(x1 - px, 0.0, px - x2) + dy = max(y1 - py, 0.0, py - y2) + return float(np.hypot(dx, dy)) + + def _ball_contact(self, player_dict, ball_bbox, contact_pad_ratio): + bx, by = get_center_of_bbox(ball_bbox) + best_tid, best_d, best_bbox = None, float("inf"), None + for tid, player in player_dict.items(): + d = self._point_to_bbox_distance(bx, by, player["bbox"]) + if d < best_d: + best_tid, best_d, best_bbox = tid, d, player["bbox"] + if best_bbox is None: + return None + w_box = best_bbox[2] - best_bbox[0] + h_box = best_bbox[3] - best_bbox[1] + if best_d > contact_pad_ratio * max(w_box, h_box): + return None + return best_tid + + def _count_total_contacts(self, tracks, contact_gap_frames, contact_pad_ratio): + totals = {} + last_contact_frame = {} + for frame_num, (player_dict, ball_dict) in enumerate( + zip(tracks["players"], tracks["ball"]) + ): + ball = ball_dict.get(1) + if ball is None or not player_dict: + continue + tid = self._ball_contact(player_dict, ball["bbox"], contact_pad_ratio) + if tid is None: + continue + last = last_contact_frame.get(tid) + if last is None or (frame_num - last) > contact_gap_frames: + totals[tid] = totals.get(tid, 0) + 1 + last_contact_frame[tid] = frame_num + return totals + + def draw_player_hud( + self, frame, player_id, contacts, distance_m, color, headshot=None + ): + import cv2 + + x, y = 10, 10 + bg_color = (131, 41, 92) + text_color = (47, 186, 64) + if headshot is not None: + hh, hw = headshot.shape[:2] + w, h = hw + 280, max(110, hh + 20) + text_x = x + hw + 20 + else: + w, h = 320, 100 + text_x = x + 12 + cv2.rectangle(frame, (x, y), (x + w, y + h), bg_color, cv2.FILLED) + cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) + if headshot is not None: + hy, hx = y + 10, x + 10 + frame[hy : hy + headshot.shape[0], hx : hx + headshot.shape[1]] = headshot + cv2.rectangle( + frame, + (hx, hy), + (hx + headshot.shape[1], hy + headshot.shape[0]), + color, + 2, + ) + cv2.putText( + frame, + "Player #8", + (text_x, y + 28), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + text_color, + 2, + ) + cv2.putText( + frame, + f"Ball contacts: {contacts}", + (text_x, y + 58), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + text_color, + 1, + ) + cv2.putText( + frame, + f"Distance: {distance_m:.1f} m", + (text_x, y + 85), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + text_color, + 1, + ) + return frame + + def draw_annotations( + self, + video_frames, + tracks, + camera_motion=None, + trail_length=30, + contact_gap_frames=5, + contact_pad_ratio=0.25, + player_height_m=1.8, + headshot_path=None, + headshot_size=90, + logo_path=None, + logo_height=80, + logo_margin=15, + trail_smooth_window=11, + show_minimap=True, + minimap_size=(320, 200), + minimap_margin=15, + ): + import cv2 + import numpy as np + + output_video_frames = [] + player_trails = {} + team_votes = {} + team_bgr = {"red": (0, 0, 255), "blue": (255, 0, 0)} + default_color = (200, 200, 200) + + frames_count = {} + for frame_players in tracks["players"]: + for tid in frame_players: + frames_count[tid] = frames_count.get(tid, 0) + 1 + total_contacts = self._count_total_contacts( + tracks, contact_gap_frames, contact_pad_ratio + ) + + heights = [ + p["bbox"][3] - p["bbox"][1] + for frame_players in tracks["players"] + for p in frame_players.values() + if p["bbox"][3] > p["bbox"][1] + ] + px_per_meter = float(np.median(heights)) / player_height_m if heights else 1.0 + + headshot = None + if headshot_path is not None and os.path.exists(headshot_path): + img = cv2.imread(headshot_path) + if img is not None: + headshot = cv2.resize( + img, (headshot_size, headshot_size), interpolation=cv2.INTER_AREA + ) + + logo_bgr, logo_alpha = None, None + if logo_path is not None and os.path.exists(logo_path): + img = cv2.imread(logo_path, cv2.IMREAD_UNCHANGED) + if img is not None: + scale = logo_height / img.shape[0] + new_w = max(1, int(round(img.shape[1] * scale))) + img = cv2.resize( + img, (new_w, logo_height), interpolation=cv2.INTER_LANCZOS4 + ) + if img.ndim == 3 and img.shape[2] == 4: + logo_bgr = img[..., :3] + logo_alpha = (img[..., 3:4].astype(np.float32)) / 255.0 + else: + logo_bgr = ( + img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + ) + + minimap_bg, minimap_extent = None, None + if show_minimap: + minimap_extent = self._minimap_extent(tracks, camera_motion) + if minimap_extent is not None: + minimap_bg = self._make_minimap_bg(minimap_size[0], minimap_size[1]) + + if total_contacts: + focal_tid = max( + total_contacts, + key=lambda t: (total_contacts[t], frames_count.get(t, 0)), + ) + elif frames_count: + focal_tid = max(frames_count, key=frames_count.get) + else: + focal_tid = None + + last_ref_pt = {} + player_distance = {} + player_contacts = {} + last_contact_frame = {} + for frame_num, frame in enumerate(video_frames): + frame = frame.copy() + + player_dict = tracks["players"][frame_num] + ball_dict = tracks["ball"][frame_num] + referee_dict = tracks["referees"][frame_num] + + H_cum = camera_motion[frame_num] if camera_motion is not None else np.eye(3) + H_inv = np.linalg.inv(H_cum) + + active_ids = set(player_dict.keys()) + for track_id, player in player_dict.items(): + x_center, _ = get_center_of_bbox(player["bbox"]) + y_bottom = int(player["bbox"][3]) + ref_pt = cv2.perspectiveTransform( + np.array([[[x_center, y_bottom]]], dtype=np.float32), H_inv + )[0][0] + ref_tuple = (float(ref_pt[0]), float(ref_pt[1])) + player_trails.setdefault(track_id, []).append(ref_tuple) + if len(player_trails[track_id]) > trail_length: + player_trails[track_id] = player_trails[track_id][-trail_length:] + + if track_id in last_ref_pt: + dx = ref_tuple[0] - last_ref_pt[track_id][0] + dy = ref_tuple[1] - last_ref_pt[track_id][1] + player_distance[track_id] = player_distance.get( + track_id, 0.0 + ) + float(np.hypot(dx, dy)) + last_ref_pt[track_id] = ref_tuple + + vote = self.classify_jersey(frame, player["bbox"]) + if vote is not None: + counts = team_votes.setdefault(track_id, {"red": 0, "blue": 0}) + counts[vote] += 1 + for track_id in list(player_trails.keys()): + if track_id not in active_ids: + del player_trails[track_id] + last_ref_pt.pop(track_id, None) + + ball = ball_dict.get(1) + if ball is not None and player_dict: + tid = self._ball_contact(player_dict, ball["bbox"], contact_pad_ratio) + if tid is not None: + last = last_contact_frame.get(tid) + if last is None or (frame_num - last) > contact_gap_frames: + player_contacts[tid] = player_contacts.get(tid, 0) + 1 + last_contact_frame[tid] = frame_num + + focal_color = (131, 41, 92) + + def color_for(track_id): + if track_id == focal_tid: + return focal_color + counts = team_votes.get(track_id) + if not counts or (counts["red"] == 0 and counts["blue"] == 0): + return default_color + return ( + team_bgr["red"] + if counts["red"] >= counts["blue"] + else team_bgr["blue"] + ) + + for track_id, ref_points in player_trails.items(): + smoothed_ref = self._smooth_points(ref_points, trail_smooth_window) + pts = cv2.perspectiveTransform( + np.asarray(smoothed_ref, dtype=np.float32).reshape(-1, 1, 2), H_cum + ).reshape(-1, 2) + frame = self.draw_trail(frame, pts.tolist(), color_for(track_id)) + + for track_id, player in player_dict.items(): + frame = self.draw_ellipse(frame, player["bbox"], color_for(track_id)) + + for _, referee in referee_dict.items(): + frame = self.draw_ellipse(frame, referee["bbox"], (0, 255, 255)) + + for track_id, ball in ball_dict.items(): + frame = self.draw_traingle(frame, ball["bbox"], (0, 255, 0)) + + if focal_tid is not None: + frame = self.draw_player_hud( + frame, + focal_tid, + player_contacts.get(focal_tid, 0), + player_distance.get(focal_tid, 0.0) / px_per_meter, + color_for(focal_tid), + headshot=headshot, + ) + + if logo_bgr is not None: + lh, lw = logo_bgr.shape[:2] + fh, fw = frame.shape[:2] + x0 = max(0, fw - lw - logo_margin) + y0 = logo_margin + x1, y1 = x0 + lw, y0 + lh + if logo_alpha is not None: + roi = frame[y0:y1, x0:x1].astype(np.float32) + blended = ( + roi * (1.0 - logo_alpha) + + logo_bgr.astype(np.float32) * logo_alpha + ) + frame[y0:y1, x0:x1] = blended.astype(np.uint8) + else: + frame[y0:y1, x0:x1] = logo_bgr + + if minimap_bg is not None and minimap_extent is not None: + mm = minimap_bg.copy() + mm_w, mm_h = minimap_size + for tid, player in player_dict.items(): + rx, ry = self._ref_bottom_center(player["bbox"], H_inv) + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, rx, ry + ) + dot_color = color_for(tid) + radius = 6 if tid == focal_tid else 4 + cv2.circle(mm, (mx, my), radius, dot_color, cv2.FILLED) + cv2.circle(mm, (mx, my), radius, (0, 0, 0), 1) + for referee in referee_dict.values(): + rx, ry = self._ref_bottom_center(referee["bbox"], H_inv) + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, rx, ry + ) + cv2.circle(mm, (mx, my), 3, (0, 255, 255), cv2.FILLED) + cv2.circle(mm, (mx, my), 3, (0, 0, 0), 1) + ball = ball_dict.get(1) + if ball is not None: + bx, by = get_center_of_bbox(ball["bbox"]) + bref = cv2.perspectiveTransform( + np.array([[[bx, by]]], dtype=np.float32), H_inv + )[0][0] + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, float(bref[0]), float(bref[1]) + ) + cv2.circle(mm, (mx, my), 4, (0, 255, 0), cv2.FILLED) + cv2.circle(mm, (mx, my), 4, (0, 0, 0), 1) + fh, fw = frame.shape[:2] + x0 = max(0, fw - mm_w - minimap_margin) + y0 = max(0, fh - mm_h - minimap_margin) + frame[y0 : y0 + mm_h, x0 : x0 + mm_w] = mm + + output_video_frames.append(frame) + + return output_video_frames + + +class FootballAnalyzer(GstBase.BaseTransform): + """ + Buffers every incoming video frame, then on EOS runs the full batch + pipeline (YOLO detection, ByteTrack with whole-clip class voting, + SIFT/RANSAC camera motion, annotated drawing with trails / HUD / + logo / minimap) and pushes the annotated frames downstream before + forwarding EOS. + """ + + __gstmetadata__ = ( + "Football Analyzer", + "Filter/Effect/Video", + "Runs football_analysis (YOLO + ByteTrack + SIFT camera motion + " + "annotated drawing) on the full clip and emits annotated frames on EOS", + "Marcus Edel ", + ) + + if backend.BACKEND == "gst": + src_template = Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + VIDEO_CAPS.copy(), + ) + sink_template = Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + VIDEO_CAPS.copy(), + ) + __gsttemplates__ = (src_template, sink_template) + + model_path = GObject.Property( + type=str, + default="", + nick="Model Path", + blurb="Path to the YOLO weights (must be set before processing)", + flags=GObject.ParamFlags.READWRITE, + ) + + headshot_path = GObject.Property( + type=str, + default="", + nick="Headshot Path", + blurb="Optional headshot image for the focal-player HUD", + flags=GObject.ParamFlags.READWRITE, + ) + + logo_path = GObject.Property( + type=str, + default="", + nick="Logo Path", + blurb="Optional top-right logo overlay", + flags=GObject.ParamFlags.READWRITE, + ) + + tracks_stub_path = GObject.Property( + type=str, + default="", + nick="Tracks Stub Path", + blurb="Optional pickle path for cached object tracks (read & written)", + flags=GObject.ParamFlags.READWRITE, + ) + + camera_motion_stub_path = GObject.Property( + type=str, + default="", + nick="Camera Motion Stub Path", + blurb="Optional pickle path for cached camera-motion homographies (read & written)", + flags=GObject.ParamFlags.READWRITE, + ) + + show_minimap = GObject.Property( + type=bool, + default=True, + nick="Show Minimap", + blurb="Render the bottom-right minimap overlay", + flags=GObject.ParamFlags.READWRITE, + ) + + def __init__(self): + super().__init__() + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self._frames = [] + self._pts = [] + self._duration = [] + self._width = 0 + self._height = 0 + self._tracker = None + + def _ensure_tracker(self): + if self._tracker is not None: + return self._tracker + if not self.model_path or not os.path.exists(self.model_path): + raise FileNotFoundError(f"YOLO model not found: {self.model_path!r}") + self.logger.info(f"Loading FootballAnalyzer Tracker from {self.model_path}") + self._tracker = Tracker(self.model_path) + return self._tracker + + def do_set_caps(self, incaps, outcaps): + info = GstVideo.VideoInfo.new_from_caps(incaps) + self._width = info.width + self._height = info.height + return True + + def do_transform_ip(self, buf): + import numpy as np + + try: + ok, mapinfo = buf.map(Gst.MapFlags.READ) + if not ok: + self.logger.error("Failed to map incoming buffer for read") + return Gst.FlowReturn.ERROR + try: + frame = ( + np.frombuffer(mapinfo.data, dtype=np.uint8) + .reshape(self._height, self._width, 3) + .copy() + ) + finally: + buf.unmap(mapinfo) + + self._frames.append(frame) + self._pts.append(buf.pts) + self._duration.append(buf.duration) + return Gst.FlowReturn.OK + + except Exception as e: + self.logger.error(f"FootballAnalyzer chain error: {e}") + return Gst.FlowReturn.ERROR + + def do_sink_event(self, event): + if event.type == Gst.EventType.EOS: + try: + self._run_pipeline_and_push() + except Exception as e: + self.logger.error(f"FootballAnalyzer EOS processing failed: {e}") + # Forward EOS regardless so the pipeline shuts down cleanly. + return GstBase.BaseTransform.do_sink_event(self, event) + + def _run_pipeline_and_push(self): + import numpy as np + + if not self._frames: + self.logger.info("FootballAnalyzer: no frames buffered, skipping") + return + + tracker = self._ensure_tracker() + n = len(self._frames) + self.logger.info(f"FootballAnalyzer: running pipeline on {n} frames") + + tracks_stub = self.tracks_stub_path or None + cam_stub = self.camera_motion_stub_path or None + headshot = self.headshot_path or None + logo = self.logo_path or None + + tracks = tracker.get_object_tracks( + self._frames, + read_from_stub=tracks_stub is not None and os.path.exists(tracks_stub), + stub_path=tracks_stub, + ) + camera_motion = tracker.get_camera_motion( + self._frames, + tracks, + read_from_stub=cam_stub is not None and os.path.exists(cam_stub), + stub_path=cam_stub, + ) + annotated = tracker.draw_annotations( + self._frames, + tracks, + camera_motion=camera_motion, + headshot_path=headshot, + logo_path=logo, + show_minimap=self.show_minimap, + ) + + if len(annotated) != n: + self.logger.warning( + f"draw_annotations returned {len(annotated)} frames for {n} inputs; " + "padding/truncating to match" + ) + if len(annotated) < n: + annotated = list(annotated) + [annotated[-1]] * (n - len(annotated)) + else: + annotated = annotated[:n] + + srcpad = self.srcpad + for i, out in enumerate(annotated): + data = np.ascontiguousarray(out, dtype=np.uint8).tobytes() + outbuf = Gst.Buffer.new_allocate(None, len(data), None) + outbuf.fill(0, data) + outbuf.pts = self._pts[i] + outbuf.duration = self._duration[i] + ret = srcpad.push(outbuf) + if ret != Gst.FlowReturn.OK: + self.logger.error( + f"Pushing annotated frame {i} failed with {ret}; aborting" + ) + break + + self._frames.clear() + self._pts.clear() + self._duration.clear() + + +# GStreamer factory registration runs only under the gst backend. +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_football_analyzer", FootballAnalyzer + ) +elif not CAN_REGISTER_ELEMENT: + GlobalLogger().warning( + "The 'pyml_football_analyzer' element will not be registered because " + "required modules are missing." + ) diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py new file mode 100644 index 0000000..430d1c9 --- /dev/null +++ b/plugins/python/football_overlay.py @@ -0,0 +1,1162 @@ +# FootballOverlay +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os + +from log.global_logger import GlobalLogger +import backend + +CAN_REGISTER_ELEMENT = True +try: + import re + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + gi.require_version("GstVideo", "1.0") + gi.require_version("GstAnalytics", "1.0") + gi.require_version("GLib", "2.0") + from gi.repository import ( + Gst, + GstBase, + GstVideo, + GstAnalytics, + GLib, + ) # noqa: E402 + from backend import GObject # noqa: E402 + + from log.logger_factory import LoggerFactory # noqa: E402 + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + OVERLAY_CAPS = Gst.Caps.from_string( + "video/x-raw, format=(string){ RGBA, ARGB, BGRA, ABGR }" + ) + +except ImportError as e: + CAN_REGISTER_ELEMENT = False + GlobalLogger().warning( + f"The 'pyml_football_overlay' element will not be available. Error: {e}" + ) + + +_FORMAT_ORDER = { + "RGBA": (0, 1, 2, 3), + "ARGB": (3, 0, 1, 2), + "BGRA": (2, 1, 0, 3), + "ABGR": (3, 2, 1, 0), +} + +_PALETTE = [ + (239, 71, 111, 255), + (255, 209, 102, 255), + (6, 214, 160, 255), + (17, 138, 178, 255), + (255, 107, 107, 255), + (78, 205, 196, 255), + (199, 125, 255, 255), + (255, 159, 28, 255), + (46, 196, 182, 255), + (118, 200, 247, 255), +] + +_REFEREE_RGBA = (255, 215, 0, 255) +_BALL_RGBA = (0, 230, 0, 255) +_PLAYER_RGBA = (0, 200, 255, 255) +_RED_TEAM_RGBA = (255, 40, 40, 255) +_BLUE_TEAM_RGBA = (40, 90, 255, 255) +_DEFAULT_RGBA = (235, 235, 235, 255) +_BLACK_RGBA = (0, 0, 0, 255) +_HUD_BG_RGBA = (92, 41, 131, 255) +_HUD_TEXT_RGBA = (64, 186, 47, 255) +_HIGHLIGHT_RGBA = (255, 255, 255, 255) + + +def _is_ball(label): + return "ball" in label + + +def _is_referee(label): + return "referee" in label or label == "ref" + + +class FootballOverlay(GstBase.BaseTransform): + """ + Metadata-driven broadcast overlay (football_analysis style), streaming. + + Reads upstream GstAnalytics detection/tracking metadata and draws: an + ellipse + optional id badge per subject, a gold ellipse for referees, a + green triangle on the ball, fading motion trails, and a focal-player HUD + with a headshot, accumulated ball contacts, and distance travelled. + """ + + __gstmetadata__ = ( + "Football Overlay", + "Filter/Effect/Video", + "Broadcast-style detection/tracking overlay (ellipses, ball triangle, " + "trails, headshot HUD with ball contacts + distance) from GstAnalytics", + "Marcus Edel ", + ) + + if backend.BACKEND == "gst": + src_template = Gst.PadTemplate.new( + "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, OVERLAY_CAPS.copy() + ) + sink_template = Gst.PadTemplate.new( + "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, OVERLAY_CAPS.copy() + ) + __gsttemplates__ = (src_template, sink_template) + + show_labels = GObject.Property( + type=bool, + default=True, + nick="Show Labels", + blurb="Draw the class name above each object", + flags=GObject.ParamFlags.READWRITE, + ) + show_ids = GObject.Property( + type=bool, + default=True, + nick="Show Track IDs", + blurb="Draw the track-id badge under each tracked object", + flags=GObject.ParamFlags.READWRITE, + ) + trails = GObject.Property( + type=bool, + default=True, + nick="Show Trails", + blurb="Draw a fading motion trail behind each tracked object", + flags=GObject.ParamFlags.READWRITE, + ) + trail_length = GObject.Property( + type=int, + default=30, + minimum=2, + maximum=300, + nick="Trail Length", + blurb="Number of recent positions kept in each motion trail", + flags=GObject.ParamFlags.READWRITE, + ) + show_ball = GObject.Property( + type=bool, + default=False, + nick="Show Ball", + blurb="Draw the marker on the ball (the ball is still tracked for " + "contact counting either way)", + flags=GObject.ParamFlags.READWRITE, + ) + show_hud = GObject.Property( + type=bool, + default=True, + nick="Show HUD", + blurb="Draw the focal-player HUD (headshot, label, contacts, distance)", + flags=GObject.ParamFlags.READWRITE, + ) + headshot_path = GObject.Property( + type=str, + default="data/Chinedu-Obasi_2684938.jpg", + nick="Headshot Path", + blurb="Image shown in the HUD (empty to disable)", + flags=GObject.ParamFlags.READWRITE, + ) + headshot_size = GObject.Property( + type=int, + default=90, + minimum=16, + maximum=512, + nick="Headshot Size", + blurb="Headshot square size in pixels", + flags=GObject.ParamFlags.READWRITE, + ) + player_label = GObject.Property( + type=str, + default="Player #8", + nick="Player Label", + blurb="Static label drawn in the HUD", + flags=GObject.ParamFlags.READWRITE, + ) + contact_pad_ratio = GObject.Property( + type=float, + default=0.25, + minimum=0.0, + maximum=5.0, + nick="Contact Pad Ratio", + blurb="Ball counts as a contact within this fraction of the player box size", + flags=GObject.ParamFlags.READWRITE, + ) + contact_gap_frames = GObject.Property( + type=int, + default=5, + minimum=0, + maximum=1000, + nick="Contact Gap Frames", + blurb="Min frames between counted contacts for the same player", + flags=GObject.ParamFlags.READWRITE, + ) + player_height = GObject.Property( + type=float, + default=1.8, + minimum=0.1, + maximum=10.0, + nick="Player Height (m)", + blurb="Assumed real-world height used to convert pixels to metres", + flags=GObject.ParamFlags.READWRITE, + ) + min_confidence = GObject.Property( + type=float, + default=0.0, + minimum=0.0, + maximum=1.0, + nick="Min Confidence", + blurb="Skip detections whose confidence is below this threshold", + flags=GObject.ParamFlags.READWRITE, + ) + class_names = GObject.Property( + type=str, + default="", + nick="Class Names", + blurb="Comma-separated names to map numeric labels (label_N) from the " + "onnx/objectdetector path, e.g. 'ball,goalkeeper,player,referee'", + flags=GObject.ParamFlags.READWRITE, + ) + team_colors = GObject.Property( + type=bool, + default=True, + nick="Team Colors", + blurb="Colour players by jersey team (red/blue, per-track majority vote); " + "off draws all players one colour", + flags=GObject.ParamFlags.READWRITE, + ) + draw_from_detections = GObject.Property( + type=bool, + default=False, + nick="Draw From Detections", + blurb="Draw ellipses on the raw per-frame detection boxes instead of the " + "tracker's boxes -- no Kalman drift, coasted phantoms or track-split " + "doubles. Team colour is then classified per frame; the HUD still uses " + "tracker metadata if present", + flags=GObject.ParamFlags.READWRITE, + ) + merge_iou = GObject.Property( + type=float, + default=0.5, + minimum=0.0, + maximum=1.0, + nick="Merge IoU", + blurb="Collapse overlapping boxes (across classes) into one before " + "drawing, so one player isn't circled twice; a box is merged when its " + "IoU or containment with a kept box exceeds this (0 disables)", + flags=GObject.ParamFlags.READWRITE, + ) + position_smoothing = GObject.Property( + type=float, + default=0.5, + minimum=0.0, + maximum=0.95, + nick="Position Smoothing", + blurb="Temporal EMA on drawn box positions (0=off, higher=smoother but " + "more lag). Boxes are associated frame-to-frame by proximity, so this " + "damps detection jitter and the steps from a detection interval > 1", + flags=GObject.ParamFlags.READWRITE, + ) + highlight_focal = GObject.Property( + type=bool, + default=True, + nick="Highlight Focal Player", + blurb="Mark the focal player (the one shown in the HUD) on the pitch " + "with a chevron above their head and a bolder ellipse", + flags=GObject.ParamFlags.READWRITE, + ) + focal_track_id = GObject.Property( + type=int, + default=-1, + minimum=-1, + maximum=100000, + nick="Focal Track ID", + blurb="Pin the focal/highlighted player to this track id; -1 = auto " + "(the player tracked the most, with hysteresis so it stays stable)", + flags=GObject.ParamFlags.READWRITE, + ) + + def __init__(self): + super().__init__() + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self.set_in_place(True) + self.width = 0 + self.height = 0 + self._order = _FORMAT_ORDER["RGBA"] + # per-track state, accumulated across frames + self._trail = {} + self._last_pt = {} + self._distance_px = {} + self._heights = [] + self._widths = [] + self._ell_w = {} # track_id -> smoothed ellipse half-width (px) + self._contacts = {} + self._last_contact_frame = {} + self._frames_seen = {} + self._track_label = {} + self._class_votes = {} # track_id -> {label: count}, for stable class + self._team_votes = {} # track_id -> {"red": n, "blue": n}, jersey team + self._frame = 0 + self._focal = None # current focal track id (sticky, for hysteresis) + self._headshot = None + self._headshot_loaded = False + self._inv_order = [0, 1, 2, 3] # buffer-channel -> logical RGBA index + # Position-smoothing slots: {"box": np[x1,y1,x2,y2]} kept across frames + # and matched by proximity, so the drawn ellipse can be low-passed. + self._smooth_slots = [] + + def do_set_caps(self, incaps, outcaps): + info = GstVideo.VideoInfo.new_from_caps(incaps) + self.width = info.width + self.height = info.height + fmt = info.finfo.name if info.finfo else "RGBA" + self._order = _FORMAT_ORDER.get(fmt, _FORMAT_ORDER["RGBA"]) + # buffer channel j holds logical[self._order[j]]; invert so we can pull + # logical R,G,B out of the buffer for jersey colour classification. + self._inv_order = [self._order.index(c) for c in range(4)] + self._headshot_loaded = False # re-load in the new channel order + self.logger.info(f"FootballOverlay caps: {fmt} {self.width}x{self.height}") + return True + + def _map_label(self, label): + if self.class_names: + m = re.match(r"label_(\d+)$", label) + if m: + names = [s.strip() for s in self.class_names.split(",") if s.strip()] + i = int(m.group(1)) + if 0 <= i < len(names): + return names[i] + return label + + def _parse_label(self, full_label): + core = full_label + m = re.match(r"stream_\d+_(.*)$", full_label) + if m: + core = m.group(1) + m = re.match(r"(.+)_id_(\d+)$", core) + if m: + return self._map_label(m.group(1)), int(m.group(2)) + m = re.match(r"id_(\d+)$", core) + if m: + return "object", int(m.group(1)) + return self._map_label(core or "object"), None + + def _read_metadata(self, buf): + entries = [] + meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + if not meta: + return entries + for index in range(GstAnalytics.relation_get_length(meta)): + ret, od_mtd = meta.get_od_mtd(index) + if not ret or od_mtd is None: + continue + full_label = GLib.quark_to_string(od_mtd.get_obj_type()) + presence, x, y, w, h, score = od_mtd.get_location() + if not presence: + continue + label, track_id = self._parse_label(full_label) + entries.append( + { + "label": label.lower(), + "track_id": track_id, + "confidence": score, + "box": (x, y, x + w, y + h), + } + ) + return entries + + @staticmethod + def _point_to_bbox_distance(px, py, box): + x1, y1, x2, y2 = box + dx = max(x1 - px, 0.0, px - x2) + dy = max(y1 - py, 0.0, py - y2) + return (dx * dx + dy * dy) ** 0.5 + + def _ball_contact(self, players, ball_box): + """Closest player to the ball, if within contact_pad_ratio of its size.""" + bx = (ball_box[0] + ball_box[2]) / 2.0 + by = (ball_box[1] + ball_box[3]) / 2.0 + best_tid, best_d, best_box = None, float("inf"), None + for tid, box in players.items(): + d = self._point_to_bbox_distance(bx, by, box) + if d < best_d: + best_tid, best_d, best_box = tid, d, box + if best_box is None: + return None + w = best_box[2] - best_box[0] + h = best_box[3] - best_box[1] + if best_d > self.contact_pad_ratio * max(w, h): + return None + return best_tid + + def _update_tracks(self, entries, det_ball_box=None): + self._frame += 1 + active = set() + players = {} + ball_box = None + # Accumulate per-track class votes first so the stable label below + # already reflects this frame. + for e in entries: + tid = e["track_id"] + if tid is None: + continue + v = self._class_votes.setdefault(tid, {}) + v[e["label"]] = v.get(e["label"], 0) + 1 + for e in entries: + tid = e["track_id"] + if tid is None: + continue + label = self._stable_label(tid, e["label"]) + if _is_ball(label): + ball_box = e["box"] + continue + active.add(tid) + players[tid] = e["box"] + self._track_label[tid] = label + self._frames_seen[tid] = self._frames_seen.get(tid, 0) + 1 + x1, y1, x2, y2 = e["box"] + foot = (int((x1 + x2) / 2), int(y2)) + if y2 - y1 > 0: + self._heights.append(y2 - y1) + if len(self._heights) > 600: + self._heights = self._heights[-600:] + self._update_ellipse_width(tid, x2 - x1) + prev = self._last_pt.get(tid) + if prev is not None: + self._distance_px[tid] = ( + self._distance_px.get(tid, 0.0) + + ((foot[0] - prev[0]) ** 2 + (foot[1] - prev[1]) ** 2) ** 0.5 + ) + self._last_pt[tid] = foot + trail = self._trail.setdefault(tid, []) + trail.append(foot) + if len(trail) > self.trail_length: + del trail[: -self.trail_length] + + # Fall back to the detected ball if no tracked ball this frame. + if ball_box is None: + ball_box = det_ball_box + + # Ball contacts (debounced per player), like football_analyzer. + if ball_box is not None and players: + tid = self._ball_contact(players, ball_box) + if tid is not None: + last = self._last_contact_frame.get(tid) + if last is None or (self._frame - last) > self.contact_gap_frames: + self._contacts[tid] = self._contacts.get(tid, 0) + 1 + self._last_contact_frame[tid] = self._frame + + for tid in list(self._trail.keys()): + if tid not in active: + del self._trail[tid] + self._last_pt.pop(tid, None) + self._ell_w.pop(tid, None) + return active + + def _update_ellipse_width(self, track_id, raw_w): + # Smooth (and outlier-reject) the per-track ellipse width so a single + # oversized box -- two players merged, or a drifting keep-alive + # prediction -- can't balloon the circle for one frame. + if raw_w <= 0: + return + if self._widths: + self._widths.append(raw_w) + if len(self._widths) > 600: + self._widths = self._widths[-600:] + srt = sorted(self._widths) + med = srt[len(srt) // 2] + clamped = min(max(raw_w, 0.5 * med), 1.8 * med) + else: + self._widths.append(raw_w) + clamped = raw_w + prev = self._ell_w.get(track_id) + # EMA: slow enough to keep the circle size steady frame-to-frame, fast + # enough to still follow real perspective changes as players move. + self._ell_w[track_id] = ( + clamped if prev is None else 0.25 * clamped + 0.75 * prev + ) + + def _px_per_meter(self): + import numpy as np + + if not self._heights: + return None + return float(np.median(self._heights)) / max(0.1, self.player_height) + + def _focal_track(self): + # Pin to an explicit track id if requested. + if self.focal_track_id >= 0: + return ( + self.focal_track_id + if self.focal_track_id in self._frames_seen + else self._focal + ) + keys = set(self._frames_seen) + if not keys: + return None + + # Only consider *sustained* tracks. Otherwise a track that flickered for + # a few frames -- common when detection/tracking churns -- can win on a + # single ball contact and then show ~0 distance (it was barely tracked). + # The floor scales with elapsed frames, with a small absolute minimum. + floor = max(10, int(0.2 * self._frame)) + candidates = [t for t in keys if self._frames_seen.get(t, 0) >= floor] or list( + keys + ) + + # Rank by ball contacts (the player most involved with the ball), with + # frames-seen as a tiebreak / pre-contact fallback (before anyone has + # touched the ball, the most-tracked player is shown). + def score(t): + return (self._contacts.get(t, 0), self._frames_seen.get(t, 0)) + + best = max(candidates, key=score) + # Stability: keep the current focal unless a challenger has *strictly + # more* contacts, so the highlight/HUD don't flip on ties or noise. + cur = self._focal + if ( + cur is not None + and cur in candidates + and self._contacts.get(best, 0) <= self._contacts.get(cur, 0) + ): + best = cur + self._focal = best + return best + + def _stable_label(self, track_id, fallback=""): + # Majority-voted class over the track's history — smooths frame-to-frame + # misclassifications (e.g. a player briefly tagged 'referee'), so the + # gold referee marking doesn't flicker. + votes = self._class_votes.get(track_id) + if not votes: + return fallback + return max(votes, key=votes.get) + + def _c(self, rgba): + return tuple(rgba[i] for i in self._order) + + def _team_color(self, track_id): + # Confident kit colour from a track's accumulated jersey votes, else + # None. Red/blue -> team; "ref" (distinctive non-team kit) -> gold. + # Requires a minimum number of votes AND a clear majority, so a few + # noisy frames can't decide the colour. + if track_id is None: + return None + c = self._team_votes.get(track_id) + if not c: + return None + red, blue, ref = c.get("red", 0), c.get("blue", 0), c.get("ref", 0) + total = red + blue + ref + if total < 4: + return None + colors = {_RED_TEAM_RGBA: red, _BLUE_TEAM_RGBA: blue, _REFEREE_RGBA: ref} + color, n = max(colors.items(), key=lambda kv: kv[1]) + return color if n >= 0.6 * total else None + + def _is_referee_track(self, track_id, fallback_label): + # A track is a referee only if referee *clearly dominates* its class + # votes. Referees are rare, so a mostly-player track with a few stray + # 'referee' mislabels stays a player (won't get the gold circle). + votes = self._class_votes.get(track_id) if track_id is not None else None + if not votes: + return _is_referee(fallback_label) + total = sum(votes.values()) + ref = sum(c for lbl, c in votes.items() if _is_referee(lbl)) + return total > 0 and ref >= 3 and ref >= 0.6 * total + + def _color_for(self, label, track_id): + # Colour by the track's *accumulated* jersey team (robust to per-frame + # noise). Referee/player only decides the fallback when the team is + # undecided: a referee keeps gold (stays visible), a player isn't drawn. + if _is_ball(label): + return _BALL_RGBA + if self.team_colors: + team = self._team_color(track_id) + if team is not None: + return team + return _REFEREE_RGBA if self._is_referee_track(track_id, label) else None + return _REFEREE_RGBA if _is_referee(label) else _PLAYER_RGBA + + @staticmethod + def _overlap(a, b): + # max(IoU, intersection-over-smaller-area): catches both heavy overlap + # and a small duplicate box sitting inside a larger one. + ax1, ay1, ax2, ay2 = a + bx1, by1, bx2, by2 = b + iw = max(0.0, min(ax2, bx2) - max(ax1, bx1)) + ih = max(0.0, min(ay2, by2) - max(ay1, by1)) + inter = iw * ih + if inter <= 0.0: + return 0.0 + area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) + area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) + union = area_a + area_b - inter + iou = inter / union if union > 0.0 else 0.0 + smaller = min(area_a, area_b) + contain = inter / smaller if smaller > 0.0 else 0.0 + return max(iou, contain) + + @staticmethod + def _feet_close(a, b): + # True when two boxes' foot points (bottom-centre, where the ellipse is + # drawn) are within ~0.4 of the smaller box width. The ellipse is ~2x the + # box width, so near-coincident feet = one player circled twice even when + # the boxes' IoU is low. Genuinely adjacent players are ~a full width + # apart at the feet, so they're not merged. + fax, fay = (a[0] + a[2]) / 2.0, a[3] + fbx, fby = (b[0] + b[2]) / 2.0, b[3] + ref = max(1.0, min(a[2] - a[0], b[2] - b[0])) + return ((fax - fbx) ** 2 + (fay - fby) ** 2) ** 0.5 < 0.4 * ref + + def _merge_overlaps(self, entries): + # Class-agnostic greedy suppression: keep the most confident box, drop + # any later box that overlaps it past merge_iou OR sits at the same feet. + # Collapses a player circled twice (e.g. player+goalkeeper on one person, + # or two offset boxes) into one. The ball is never merged against players. + if self.merge_iou <= 0.0 or len(entries) < 2: + return entries + ordered = sorted(entries, key=lambda e: e["confidence"], reverse=True) + kept = [] + for e in ordered: + if _is_ball(e["label"]): + kept.append(e) + continue + if any( + not _is_ball(k["label"]) + and ( + self._overlap(e["box"], k["box"]) >= self.merge_iou + or self._feet_close(e["box"], k["box"]) + ) + for k in kept + ): + continue + kept.append(e) + return kept + + def _assign_track_ids(self, draw_entries, track_entries): + # Give each drawn box a stable track id: track-mode boxes already carry + # one; detection-mode boxes borrow the id of the best-overlapping track + # (greedy, each track used once) so detection circles can use the + # tracker's persistent id for the badge and the accumulated colour. + ids = [e["track_id"] for e in draw_entries] + if not track_entries: + return ids + pairs = [] + for di, e in enumerate(draw_entries): + if e["track_id"] is not None or _is_ball(e["label"]): + continue + for t in track_entries: + if _is_ball(t["label"]): + continue + ov = self._overlap(e["box"], t["box"]) + if ov >= 0.3: + pairs.append((ov, di, t["track_id"])) + pairs.sort(key=lambda p: p[0], reverse=True) + used_draw, used_track = set(), set() + for _ov, di, tid in pairs: + if di in used_draw or tid in used_track: + continue + ids[di] = tid + used_draw.add(di) + used_track.add(tid) + return ids + + def _smooth_boxes(self, np, entries): + # Temporal EMA on the boxes we're about to draw. Each box is matched to + # the nearest slot from last frame (by centre, within a size-relative + # gate) and pulled toward the new detection; slots not matched this + # frame are dropped (no phantoms). Damps jitter and interval steps. The + # ball is passed through unsmoothed so it never lags. + import numpy as np + + a = float(self.position_smoothing) + if a <= 0.0 or not entries: + return entries + used = set() + out = [] + for e in entries: + if _is_ball(e["label"]): + out.append(e) + continue + box = np.array(e["box"], dtype=np.float64) + cx, cy = (box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0 + # Generous gate so a coherent interval-step jump still associates + # (and glides) without grabbing a different nearby player. + gate = 1.5 * max(box[2] - box[0], box[3] - box[1], 1.0) + best, best_d = None, gate + for idx, slot in enumerate(self._smooth_slots): + if idx in used: + continue + sb = slot["box"] + d = ( + ((sb[0] + sb[2]) / 2.0 - cx) ** 2 + + ((sb[1] + sb[3]) / 2.0 - cy) ** 2 + ) ** 0.5 + if d < best_d: + best, best_d = idx, d + if best is None: + self._smooth_slots.append({"box": box.copy()}) + used.add(len(self._smooth_slots) - 1) + smoothed = box + else: + used.add(best) + slot = self._smooth_slots[best] + slot["box"] = a * slot["box"] + (1.0 - a) * box + smoothed = slot["box"] + ne = dict(e) + ne["box"] = ( + float(smoothed[0]), + float(smoothed[1]), + float(smoothed[2]), + float(smoothed[3]), + ) + out.append(ne) + self._smooth_slots = [s for i, s in enumerate(self._smooth_slots) if i in used] + return out + + def _detection_color(self, cv2, np, frame, label, box): + # Colour a raw detection box (no track id) by its jersey team, classified + # from this frame -- referees included. When the jersey isn't clearly a + # team colour, a referee falls back to gold (so real refs stay visible) + # and a player isn't drawn (matching the track-mode behaviour). + ref = _is_referee(label) + if not self.team_colors: + return _REFEREE_RGBA if ref else _PLAYER_RGBA + vote = self._classify_jersey(cv2, np, frame, box) + if vote == "red": + return _RED_TEAM_RGBA + if vote == "blue": + return _BLUE_TEAM_RGBA + if vote == "ref": + return _REFEREE_RGBA + return _REFEREE_RGBA if ref else None + + def _classify_jersey(self, cv2, np, frame, box): + # Dominant jersey colour in the torso patch -> "red"/"blue"/"ref"/None + # (HSV). "ref" is a distinctive non-team kit colour (yellow/orange or + # pink/magenta) -- chosen to avoid grass-green and the red/blue teams -- + # so the referee is identified by its kit colour, not the class label. + import cv2 + import numpy as np + + x1, y1, x2, y2 = (int(v) for v in box) + h_box, w_box = y2 - y1, x2 - x1 + if h_box <= 0 or w_box <= 0: + return None + jy1, jy2 = y1 + int(0.15 * h_box), y1 + int(0.55 * h_box) + jx1, jx2 = x1 + int(0.25 * w_box), x1 + int(0.75 * w_box) + H, W = frame.shape[:2] + jy1, jy2 = max(0, jy1), min(H, jy2) + jx1, jx2 = max(0, jx1), min(W, jx2) + if jy2 - jy1 < 3 or jx2 - jx1 < 3: + return None + # logical RGB from the buffer's channel order, then HSV + rgb = np.ascontiguousarray(frame[jy1:jy2, jx1:jx2][:, :, self._inv_order[:3]]) + hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) + s_v = (hsv[..., 1] > 80) & (hsv[..., 2] > 50) + h = hsv[..., 0] + red = int((((h <= 10) | (h >= 170)) & s_v).sum()) + blue = int(((h >= 100) & (h <= 130) & s_v).sum()) + # Referee kit: yellow/orange (~18-34) or pink/magenta (~145-165). These + # bands skip grass-green (~40-90) and the red/blue team bands. + ref = int(((((h >= 18) & (h <= 34)) | ((h >= 145) & (h <= 165))) & s_v).sum()) + min_pixels = max(20, int(0.02 * rgb.shape[0] * rgb.shape[1])) + counts = {"red": red, "blue": blue, "ref": ref} + best = max(counts, key=counts.get) + if counts[best] < min_pixels: + return None + return best + + def _load_headshot(self, cv2, np): + import cv2 + import numpy as np + + if self._headshot_loaded: + return self._headshot + self._headshot_loaded = True + self._headshot = None + path = self.headshot_path + if not path or not os.path.exists(path): + if path: + self.logger.warning(f"headshot not found: {path}") + return None + img = cv2.imread(path) # BGR + if img is None: + return None + sz = int(self.headshot_size) + img = cv2.resize(img, (sz, sz), interpolation=cv2.INTER_AREA) + rgb = img[:, :, ::-1] # BGR -> RGB + alpha = np.full((sz, sz, 1), 255, dtype=np.uint8) + rgba = np.concatenate([rgb, alpha], axis=2).astype(np.uint8) # logical RGBA + + self._headshot = np.ascontiguousarray(rgba[:, :, list(self._order)]) + return self._headshot + + def _draw_trail(self, cv2, np, frame, points, rgba): + import cv2 + import numpy as np + + if len(points) < 2: + return + pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(frame, [pts], False, self._c(rgba), 2, cv2.LINE_AA) + + def _draw_ellipse(self, cv2, frame, box, rgba, track_id): + import cv2 + + x1, y1, x2, y2 = box + y_bottom = int(y2) + x_center = int((x1 + x2) / 2) + # Prefer the per-track smoothed width so the ellipse stays stable even + # when a single detection box is momentarily oversized. + smoothed = self._ell_w.get(track_id) + width = max(1, int(smoothed if smoothed is not None else x2 - x1)) + color = self._c(rgba) + cv2.ellipse( + frame, + (x_center, y_bottom), + (width, max(1, int(0.35 * width))), + 0.0, + -45, + 235, + color, + 2, + cv2.LINE_AA, + ) + if self.show_ids and track_id is not None: + rect_w, rect_h = 40, 18 + x1r = x_center - rect_w // 2 + x2r = x_center + rect_w // 2 + y1r = y_bottom - rect_h // 2 + 15 + y2r = y_bottom + rect_h // 2 + 15 + cv2.rectangle(frame, (x1r, y1r), (x2r, y2r), color, cv2.FILLED) + tx = x1r + 12 - (10 if track_id > 99 else 0) + cv2.putText( + frame, + str(track_id), + (tx, y1r + 14), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + self._c(_BLACK_RGBA), + 2, + cv2.LINE_AA, + ) + + def _draw_triangle(self, cv2, np, frame, box, rgba): + import cv2 + import numpy as np + + x1, y1, x2, y2 = box + x = int((x1 + x2) / 2) + y = int(y1) + pts = np.array([[x, y], [x - 10, y - 20], [x + 10, y - 20]], dtype=np.int32) + cv2.drawContours(frame, [pts], 0, self._c(rgba), cv2.FILLED) + cv2.drawContours(frame, [pts], 0, self._c(_BLACK_RGBA), 2) + + def _draw_focal_marker(self, cv2, np, frame, box): + # Broadcast-style "selected player" chevron floating above the head, + # plus a bolder ellipse, to flag the focal (HUD) player on the pitch. + import cv2 + import numpy as np + + x1, y1, x2, y2 = box + cx = int((x1 + x2) / 2) + tip_y = int(y1) - 10 + s = 16 + pts = np.array( + [ + [cx, tip_y], + [cx - s, tip_y - int(s * 1.5)], + [cx + s, tip_y - int(s * 1.5)], + ], + dtype=np.int32, + ) + cv2.drawContours(frame, [pts], 0, self._c(_HIGHLIGHT_RGBA), cv2.FILLED) + cv2.drawContours(frame, [pts], 0, self._c(_BLACK_RGBA), 2) + # Bolder ring at the feet to reinforce the selection. + x_center = int((x1 + x2) / 2) + width = max(1, int(x2 - x1)) + cv2.ellipse( + frame, + (x_center, int(y2)), + (width, max(1, int(0.35 * width))), + 0.0, + -45, + 235, + self._c(_HIGHLIGHT_RGBA), + 4, + cv2.LINE_AA, + ) + + def _draw_label(self, cv2, frame, box, label, rgba): + import cv2 + + x1, y1, _, _ = box + cv2.putText( + frame, + label, + (int(x1), max(12, int(y1) - 6)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + self._c(rgba), + 1, + cv2.LINE_AA, + ) + + def _draw_hud(self, cv2, frame, contacts, distance_m, rgba, headshot): + import cv2 + + font = cv2.FONT_HERSHEY_SIMPLEX + x, y = 10, 10 + if headshot is not None: + hh, hw = headshot.shape[:2] + w, h = hw + 280, max(110, hh + 20) + text_x = x + hw + 20 + else: + w, h = 320, 100 + text_x = x + 12 + cv2.rectangle(frame, (x, y), (x + w, y + h), self._c(_HUD_BG_RGBA), cv2.FILLED) + cv2.rectangle(frame, (x, y), (x + w, y + h), self._c(rgba), 2) + if headshot is not None: + hy, hx = y + 10, x + 10 + fh, fw = frame.shape[:2] + hh = min(hh, fh - hy) + hw = min(hw, fw - hx) + if hh > 0 and hw > 0: + frame[hy : hy + hh, hx : hx + hw] = headshot[:hh, :hw] + cv2.rectangle(frame, (hx, hy), (hx + hw, hy + hh), self._c(rgba), 2) + tc = self._c(_HUD_TEXT_RGBA) + cv2.putText( + frame, self.player_label, (text_x, y + 28), font, 0.7, tc, 2, cv2.LINE_AA + ) + cv2.putText( + frame, + f"Ball contacts: {contacts}", + (text_x, y + 58), + font, + 0.6, + tc, + 1, + cv2.LINE_AA, + ) + cv2.putText( + frame, + f"Distance: {distance_m:.1f} m", + (text_x, y + 85), + font, + 0.6, + tc, + 1, + cv2.LINE_AA, + ) + + def do_transform_ip(self, buf): + import numpy as np + + try: + import numpy as np + + all_entries = self._read_metadata(buf) + # The buffer carries both the detector's boxes (track_id None) and + # the tracker's boxes (track_id set). Tracking state/HUD always use + # the tracked entries; what we *draw* depends on draw_from_detections. + track_entries = [e for e in all_entries if e["track_id"] is not None] + det_entries = [e for e in all_entries if e["track_id"] is None] + + # Ball position for contact counting: prefer a tracked ball, else + # fall back to the strongest ball *detection* (the ball is small and + # fast, so it often isn't tracked) -- so contacts still get counted. + det_ball_box = None + best_ball = -1.0 + for e in det_entries: + if _is_ball(e["label"]) and e["confidence"] > best_ball: + best_ball, det_ball_box = e["confidence"], e["box"] + + # Per-track state (votes, contacts, distance, focal) from the tracker. + active = self._update_tracks( + track_entries if track_entries else all_entries, det_ball_box + ) + + if self.draw_from_detections: + draw_entries = list(det_entries) + # Bridge missed detections: the detector occasionally drops a + # player for a frame, which would flicker the circle. The tracker + # is still coasting that player (Kalman keep-alive), so draw any + # confirmed track that has no detection this frame -- detections + # still drive everything they cover; tracks only fill the gaps. + if track_entries: + covered = set() + for d in det_entries: + if _is_ball(d["label"]): + continue + for t in track_entries: + if t["track_id"] in covered or _is_ball(t["label"]): + continue + if self._overlap(d["box"], t["box"]) >= 0.3: + covered.add(t["track_id"]) + draw_entries += [ + t + for t in track_entries + if not _is_ball(t["label"]) and t["track_id"] not in covered + ] + else: + draw_entries = track_entries if track_entries else det_entries + # min-confidence gates only what we *draw* (tracks carry conf 1.0, so + # they're unaffected); the contact math above used the raw detections. + if self.min_confidence > 0.0: + draw_entries = [ + e for e in draw_entries if e["confidence"] >= self.min_confidence + ] + # Collapse overlapping boxes so one player isn't circled twice, + # then low-pass the positions so the circle glides. + draw_entries = self._merge_overlaps(draw_entries) + draw_entries = self._smooth_boxes(np, draw_entries) + if not all_entries: + return Gst.FlowReturn.OK + + import cv2 + + ok, mapinfo = buf.map(Gst.MapFlags.WRITE) + if not ok: + self.logger.error("Failed to map buffer for writing") + return Gst.FlowReturn.ERROR + try: + frame = np.frombuffer( + mapinfo.data, dtype=np.uint8, count=self.height * self.width * 4 + ).reshape(self.height, self.width, 4) + + # Jersey team voting first, so trails/ellipses use this frame's + # vote (track mode; detection mode classifies per box at draw). + # Referees are voted on too -- their colour comes from the jersey + # (gold only as the fallback), not the class label. + if self.team_colors: + for e in track_entries: + tid = e["track_id"] + lab = self._stable_label(tid, e["label"]) + if _is_ball(lab): + continue + vote = self._classify_jersey(cv2, np, frame, e["box"]) + if vote: + tv = self._team_votes.setdefault( + tid, {"red": 0, "blue": 0, "ref": 0} + ) + tv[vote] = tv.get(vote, 0) + 1 + + if self.trails: + for tid in active: + rgba = self._color_for(self._track_label.get(tid, ""), tid) + if rgba is None: + continue + self._draw_trail(cv2, np, frame, self._trail.get(tid, []), rgba) + + # Which drawn box is the focal (HUD) player? Match the focal + # track's box to the nearest drawn box so we can highlight it + # even when drawing from detections (no track id on the box). + focal_idx = None + if self.highlight_focal: + focal_tid = self._focal_track() + focal_box = None + if focal_tid is not None: + for t in track_entries: + if t["track_id"] == focal_tid: + focal_box = t["box"] + break + if focal_box is not None: + best = 0.0 + for i, e in enumerate(draw_entries): + if _is_ball(e["label"]): + continue + ov = self._overlap(e["box"], focal_box) + if ov > best: + best, focal_idx = ov, i + + # Stable track id per drawn box (detection boxes borrow the id of + # the track they overlap) -- used for the id badge and to look up + # the track's accumulated colour. + draw_ids = self._assign_track_ids(draw_entries, track_entries) + + for i, e in enumerate(draw_entries): + box = e["box"] + badge_id = draw_ids[i] + # Use the track's stable identity (class + accumulated team + # votes) for colour whenever the box maps to a track -- in + # detection mode that's the box's matched track id. This + # makes colour robust to per-frame label/jersey noise. Only + # an unmatched detection falls back to this frame's guess. + color_tid = e["track_id"] if e["track_id"] is not None else badge_id + if color_tid is not None: + label = self._stable_label(color_tid, e["label"]) + else: + label = e["label"] + if _is_ball(label): + if self.show_ball: + self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) + continue + if color_tid is not None: + rgba = self._color_for(label, color_tid) + else: + rgba = self._detection_color(cv2, np, frame, label, box) + if rgba is None: + continue + self._draw_ellipse(cv2, frame, box, rgba, badge_id) + if i == focal_idx: + self._draw_focal_marker(cv2, np, frame, box) + if self.show_labels: + self._draw_label(cv2, frame, box, label, rgba) + + if self.show_hud: + focal = self._focal_track() + if focal is not None: + ppm = self._px_per_meter() + dist_m = ( + (self._distance_px.get(focal, 0.0) / ppm) if ppm else 0.0 + ) + hud_rgba = ( + self._color_for(self._track_label.get(focal, ""), focal) + or _DEFAULT_RGBA + ) + self._draw_hud( + cv2, + frame, + self._contacts.get(focal, 0), + dist_m, + hud_rgba, + self._load_headshot(cv2, np), + ) + finally: + buf.unmap(mapinfo) + + return Gst.FlowReturn.OK + + except Exception as e: + self.logger.error(f"FootballOverlay transform error: {e}") + return Gst.FlowReturn.ERROR + + +# GStreamer factory registration runs only under the gst backend. +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_football_overlay", FootballOverlay + ) +elif not CAN_REGISTER_ELEMENT: + GlobalLogger().warning( + "The 'pyml_football_overlay' element will not be registered because " + "required modules are missing." + ) diff --git a/plugins/python/inference.py b/plugins/python/inference.py index e112092..e7fe8d5 100644 --- a/plugins/python/inference.py +++ b/plugins/python/inference.py @@ -17,18 +17,11 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform - from utils.muxed_buffer_processor import MuxedBufferProcessor except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -45,7 +38,7 @@ class GenericInferenceTransform(VideoTransform): engine-name: pytorch (default), onnx, tensorflow, tflite, openvino Example: - gst-launch-1.0 filesrc location=data/people.mp4 ! decodebin name=d \ + python pyml-launch.py filesrc location=data/people.mp4 ! decodebin name=d \ d. ! queue ! videoconvert ! videoscale \ ! "video/x-raw,format=RGB,width=640,height=480" \ ! pyml_inference engine-name=onnx model-name=yolo11m.onnx device=cpu \ @@ -67,35 +60,23 @@ def do_start(self): ) return result - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, _ = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - frame = frames[0] if num_sources > 1 else frames + def process_frames(self, frames, num_sources, fmt, target): + """Run the engine on the frame and log the result. The frame is unchanged.""" + frame = frames[0] if num_sources > 1 else frames - if not self.engine: - return Gst.FlowReturn.OK + if not self.engine: + return - result = self.engine.do_forward(frame) - if result is not None: - self.logger.info(f"inference result: {result}") + result = self.engine.do_forward(frame) + if result is not None: + self.logger.info(f"inference result: {result}") - return Gst.FlowReturn.OK - except Exception as e: - self.logger.error(f"inference error: {e}") - return Gst.FlowReturn.ERROR - - -if CAN_REGISTER_ELEMENT: - GObject.type_register(GenericInferenceTransform) - __gstelementfactory__ = ("pyml_inference", Gst.Rank.NONE, GenericInferenceTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_inference", GenericInferenceTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_inference' element will not be registered because required modules are missing." ) diff --git a/plugins/python/kafkasink.py b/plugins/python/kafkasink.py index 349589f..121d74d 100644 --- a/plugins/python/kafkasink.py +++ b/plugins/python/kafkasink.py @@ -17,22 +17,21 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: import json import os - from confluent_kafka import Producer import gi gi.require_version("Gst", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GLib, GstAnalytics # noqa: E402 + from gi.repository import Gst # noqa: E402 from log.logger_factory import LoggerFactory # noqa: E402 from utils.runtime_utils import runtime_check_gstreamer_version # noqa: E402 + from backend import analytics, GObject # noqa: E402 except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning( @@ -191,6 +190,10 @@ def do_get_property(self, prop: GObject.GParamSpec): def initialize_producer(self): if self.broker and self.topic: try: + # Imported lazily so a missing confluent_kafka does not break the + # element scan; it is only needed once a producer is created. + from confluent_kafka import Producer + config = { "bootstrap.servers": self.broker, "linger.ms": int(self.linger_ms), @@ -237,40 +240,21 @@ def extract_metadata(self, buffer): """Extract object detection metadata from GstBuffer using GstAnalyticsRelationMeta.""" metadata = [] - meta = GstAnalytics.buffer_get_analytics_relation_meta(buffer) + meta = analytics.get_relation_meta(buffer) if not meta: - self.logger.warning("No GstAnalytics metadata found on buffer.") + self.logger.warning("No analytics metadata found on buffer.") return metadata try: - count = GstAnalytics.relation_get_length(meta) - for index in range(count): - ret, od_mtd = meta.get_od_mtd(index) - if not ret or od_mtd is None: - break - - label_quark = od_mtd.get_obj_type() - label = GLib.quark_to_string(label_quark) - location = od_mtd.get_location() - - presence, x, y, w, h, loc_conf_lvl = location - if presence: - x1 = x - y1 = y - x2 = x + w - y2 = y + h - - metadata.append( - { - "label": label, - "confidence": loc_conf_lvl, - "box": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, - } - ) - else: - self.logger.warning( - "Presence flag in location is False. Skipping this entry." - ) + for obj in analytics.read_objects(meta): + x, y, w, h = obj["x"], obj["y"], obj["w"], obj["h"] + metadata.append( + { + "label": obj["label"], + "confidence": obj["score"], + "box": {"x1": x, "y1": y, "x2": x + w, "y2": y + h}, + } + ) except Exception as e: self.logger.error(f"Error while extracting metadata: {e}") @@ -332,10 +316,11 @@ def do_finalize(self): self.producer.flush() -if CAN_REGISTER_ELEMENT: - GObject.type_register(KafkaSink) - __gstelementfactory__ = (KafkaSink.GST_PLUGIN_NAME, Gst.Rank.NONE, KafkaSink) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + KafkaSink.GST_PLUGIN_NAME, KafkaSink + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( - "The 'pyml_kafkasink' element will not be registered because confluent_kafka module is missing." + "The 'pyml_kafkasink' element will not be registered because required modules are missing." ) diff --git a/plugins/python/llm.py b/plugins/python/llm.py index ced1265..ccce00d 100644 --- a/plugins/python/llm.py +++ b/plugins/python/llm.py @@ -17,13 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - from gi.repository import Gst, GObject # noqa: E402 from base_llm import BaseLlm except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -39,10 +36,9 @@ class LLM(BaseLlm): ) -if CAN_REGISTER_ELEMENT: - GObject.type_register(LLM) - __gstelementfactory__ = ("pyml_llm", Gst.Rank.NONE, LLM) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_llm", LLM) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_llm' element will not be registered because required modules are missing." ) diff --git a/plugins/python/llm_remote.py b/plugins/python/llm_remote.py index 62a65ff..4187adc 100644 --- a/plugins/python/llm_remote.py +++ b/plugins/python/llm_remote.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -25,7 +26,8 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstBase + from gi.repository import Gst, GstBase + from backend import GObject from log.logger_factory import LoggerFactory except ImportError as e: @@ -48,7 +50,7 @@ class LlmRemote(GstBase.Aggregator): timeout: HTTP request timeout in seconds (default: 120) Example (Ollama): - gst-launch-1.0 filesrc location=prompt.txt ! "text/x-raw,format=utf8" \ + python pyml-launch.py filesrc location=prompt.txt ! "text/x-raw,format=utf8" \ ! pyml_llm_remote url=http://localhost:11434/api/generate model-name=llama3 \ ! fakesink """ @@ -60,20 +62,22 @@ class LlmRemote(GstBase.Aggregator): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - Gst.Caps.from_string("text/x-raw,format=utf8"), - ), - ) + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string("text/x-raw,format=utf8"), + ), + Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string("text/x-raw,format=utf8"), + ), + ) def __init__(self): super().__init__() @@ -267,10 +271,9 @@ def push_generated_text(self, inbuf, generated_text): return Gst.FlowReturn.ERROR -if CAN_REGISTER_ELEMENT: - GObject.type_register(LlmRemote) - __gstelementfactory__ = ("pyml_llm_remote", Gst.Rank.NONE, LlmRemote) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_llm_remote", LlmRemote) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_llm_remote' element will not be registered because required modules are missing." ) diff --git a/plugins/python/llm_stream_filter.py b/plugins/python/llm_stream_filter.py index 4a2b062..aad478a 100644 --- a/plugins/python/llm_stream_filter.py +++ b/plugins/python/llm_stream_filter.py @@ -18,6 +18,7 @@ from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -26,11 +27,9 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GstVideo", "1.0") - gi.require_version("GLib", "2.0") - gi.require_version("GstAnalytics", "1.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib + from gi.repository import Gst - from utils.muxed_buffer_processor import MuxedBufferProcessor + from backend import analytics, frameio, GObject from video_transform import VideoTransform from engine.engine_manager import EngineManager from utils.caption_utils import load_captions @@ -55,20 +54,22 @@ class LLMStreamFilter(VideoTransform): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new( - "video_src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string("video/x-raw"), - ), - Gst.PadTemplate.new( - "text_src", - Gst.PadDirection.SRC, - Gst.PadPresence.REQUEST, - Gst.Caps.from_string("text/x-raw, format=utf8"), - ), - ) + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new( + "video_src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string("video/x-raw"), + ), + Gst.PadTemplate.new( + "text_src", + Gst.PadDirection.SRC, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string("text/x-raw, format=utf8"), + ), + ) num_streams = GObject.Property( type=int, @@ -277,15 +278,8 @@ def do_transform_ip(self, buf): import torch try: - muxed_processor = MuxedBufferProcessor( - self.logger, - self.width, - self.height, - framerate_num=30, - framerate_denom=1, - ) - frames, id_str, num_sources, format = muxed_processor.extract_frames( - buf, self.sinkpad + frames, num_sources, _ = frameio.read_frames( + buf, self.sinkpad, self.width, self.height ) if frames is None: self.logger.error("Failed to extract frames") @@ -337,11 +331,12 @@ def do_transform_ip(self, buf): # Add metadata and push text buffers for selected streams for idx, caption in enumerate(captions): if idx in self.selected_streams: - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if meta: - qk = GLib.quark_from_string(f"stream_{idx}_{caption}") - ret, mtd = meta.add_one_cls_mtd(idx, qk) - if ret: + mtd = analytics.add_classification( + meta, idx, f"stream_{idx}_{caption}" + ) + if mtd is not None: self.logger.info(f"Stream {idx}: Added caption {caption}") else: self.logger.error(f"Stream {idx}: Failed to add metadata") @@ -368,10 +363,11 @@ def do_transform_ip(self, buf): return Gst.FlowReturn.ERROR -if CAN_REGISTER_ELEMENT: - GObject.type_register(LLMStreamFilter) - __gstelementfactory__ = ("pyml_llmstreamfilter", Gst.Rank.NONE, LLMStreamFilter) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_llmstreamfilter", LLMStreamFilter + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_llmstreamfilter' element will not be registered because required modules are missing." ) diff --git a/plugins/python/mariantranslate.py b/plugins/python/mariantranslate.py index cfd4b6f..9af52d1 100644 --- a/plugins/python/mariantranslate.py +++ b/plugins/python/mariantranslate.py @@ -17,13 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - from gi.repository import Gst, GObject # noqa: E402 from base_translate import BaseTranslate except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -74,10 +71,11 @@ def do_translate_text(self, text): return "" -if CAN_REGISTER_ELEMENT: - GObject.type_register(MarianTranslate) - __gstelementfactory__ = ("pyml_mariantranslate", Gst.Rank.NONE, MarianTranslate) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_mariantranslate", MarianTranslate + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_mariantranslate' element will not be registered because required modules are missing." ) diff --git a/plugins/python/maskrcnn.py b/plugins/python/maskrcnn.py index d04ef10..cd60df1 100644 --- a/plugins/python/maskrcnn.py +++ b/plugins/python/maskrcnn.py @@ -17,19 +17,12 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib # noqa: E402 - from base_objectdetector import BaseObjectDetector + from tasks.maskrcnn import MaskRCNNTask except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning( @@ -37,7 +30,7 @@ ) -class MaskRCNN(BaseObjectDetector): +class MaskRCNN(BaseObjectDetector, MaskRCNNTask): """ GStreamer element for Mask R-CNN model inference on video frames. """ @@ -49,65 +42,10 @@ class MaskRCNN(BaseObjectDetector): "Aaron Boxer ", ) - def do_decode(self, buf, output, stream_idx=0): - """ - Processes the Mask R-CNN model's output detections and adds metadata to the GStreamer buffer, - tagged with the stream index. - """ - boxes = output["boxes"] - labels = output["labels"] - scores = output["scores"] - masks = output["masks"] # Additional mask outputs for Mask R-CNN - - self.logger.info( - f"Processing buffer at address: {hex(id(buf))} for stream {stream_idx}" - ) - self.logger.info(f"Stream {stream_idx} - Processing {len(boxes)} detections") - - # Add analytics metadata to the buffer - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if not meta: - self.logger.error(f"Stream {stream_idx} - Failed to add analytics metadata") - return - - for i, (box, label, score, mask) in enumerate( - zip(boxes, labels, scores, masks) - ): - x1, y1, x2, y2 = box - self.logger.info( - f"Stream {stream_idx} - Detection {i}: Box coordinates (x1={x1}, y1={y1}, x2={x2}, y2={y2}), " - f"Label={label}, Score={score:.2f}" - ) - - # Use stream_idx in the quark string to differentiate streams - qk_string = f"stream_{stream_idx}_label_{label}" - qk = GLib.quark_from_string(qk_string) - ret, mtd = meta.add_od_mtd(qk, x1, y1, x2 - x1, y2 - y1, score) - if ret: - self.logger.info( - f"Stream {stream_idx} - Successfully added object detection metadata with quark {qk_string} and mtd {mtd}" - ) - else: - self.logger.error( - f"Stream {stream_idx} - Failed to add object detection metadata" - ) - - attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) - if attached_meta: - count = GstAnalytics.relation_get_length(attached_meta) - self.logger.info( - f"Stream {stream_idx} - Metadata successfully attached to buffer at address: {hex(id(buf))} with {count} relations" - ) - else: - self.logger.warning( - f"Stream {stream_idx} - Failed to retrieve attached metadata immediately after addition for buffer: {hex(id(buf))}" - ) - -if CAN_REGISTER_ELEMENT: - GObject.type_register(MaskRCNN) - __gstelementfactory__ = ("pyml_maskrcnn", Gst.Rank.NONE, MaskRCNN) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_maskrcnn", MaskRCNN) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_maskrcnn' element will not be registered because required modules are missing." ) diff --git a/plugins/python/objectdetector.py b/plugins/python/objectdetector.py index a429eb5..5f6e750 100644 --- a/plugins/python/objectdetector.py +++ b/plugins/python/objectdetector.py @@ -16,22 +16,9 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -from log.global_logger import GlobalLogger - -CAN_REGISTER_ELEMENT = True -try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject # noqa: E402 - from base_objectdetector import BaseObjectDetector -except ImportError as e: - CAN_REGISTER_ELEMENT = False - GlobalLogger().warning( - f"The 'objectdetector_pylm' element will not be available. Error: {e}" - ) +from base_objectdetector import BaseObjectDetector +from backend import GObject +import backend class ObjectDetector(BaseObjectDetector): @@ -46,17 +33,45 @@ class ObjectDetector(BaseObjectDetector): "Aaron Boxer ", ) + confidence = GObject.Property( + type=float, + default=0.25, + minimum=0.0, + maximum=1.0, + nick="Confidence Threshold", + blurb="Minimum detection confidence for the decoder post-process " + "(anchor_free); lower = more (and weaker) detections", + flags=GObject.ParamFlags.READWRITE, + ) + nms_iou = GObject.Property( + type=float, + default=0.45, + minimum=0.0, + maximum=1.0, + nick="NMS IoU", + blurb="NMS IoU threshold for the decoder post-process; higher keeps " + "more overlapping boxes", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.logger.info( "ObjectDetector created without a model. Please set the 'model-name' property." ) + def do_forward(self, frames): + # Push decoder thresholds to the engine before it post-processes. + if self.engine: + self.engine.conf = self.confidence + self.engine.iou = self.nms_iou + return super().do_forward(frames) + -if CAN_REGISTER_ELEMENT: - GObject.type_register(ObjectDetector) - __gstelementfactory__ = ("pyml_objectdetector", Gst.Rank.NONE, ObjectDetector) -else: - GlobalLogger().warning( - "The 'pyml_objectdetector' element will not be registered because base_objectdetector module is missing." +# The class is backend-agnostic: under g2g the host imports this module and +# instantiates ObjectDetector directly, so no GObject registration applies. +# GStreamer factory registration runs only under the gst backend. +if backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_objectdetector", ObjectDetector ) diff --git a/plugins/python/ocr.py b/plugins/python/ocr.py index 16e2fc4..c0ba4e8 100644 --- a/plugins/python/ocr.py +++ b/plugins/python/ocr.py @@ -17,24 +17,16 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - import json - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.ocr_engine import OcrEngine from engine.engine_factory import EngineFactory + from backend import GObject + from tasks.ocr import OcrTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -44,81 +36,7 @@ OCR_META_HEADER = b"GST-OCR:" -class OcrEngine(PyTorchEngine): - """ - PyTorch engine for TrOCR text recognition. - - Supports HuggingFace model IDs: - microsoft/trocr-base-printed - microsoft/trocr-large-printed - microsoft/trocr-base-handwritten - """ - - def do_load_model(self, model_name, **kwargs): - try: - from transformers import TrOCRProcessor, VisionEncoderDecoderModel - - self.processor = TrOCRProcessor.from_pretrained(model_name) - self.model = VisionEncoderDecoderModel.from_pretrained(model_name) - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info(f"TrOCR model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load TrOCR model '{model_name}': {e}") - - def do_forward(self, frames): - import numpy as np - import torch - from PIL import Image - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - results = [] - for frame in frames: - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - H, W = frame.shape[:2] - - # Split frame into horizontal strips for text region detection - strip_height = max(H // 4, 32) - texts = [] - regions = [] - for y_start in range(0, H, strip_height): - y_end = min(y_start + strip_height, H) - strip = pil_img.crop((0, y_start, W, y_end)) - pixel_values = self.processor( - images=strip, return_tensors="pt" - ).pixel_values.to(self.device) - - with torch.no_grad(): - generated_ids = self.model.generate(pixel_values) - - text = self.processor.batch_decode( - generated_ids, skip_special_tokens=True - )[0].strip() - if text: - texts.append(text) - regions.append( - { - "x": 0, - "y": y_start, - "w": W, - "h": y_end - y_start, - "text": text, - } - ) - - results.append({"texts": texts, "regions": regions}) - except Exception as e: - self.logger.error(f"OCR inference error on frame: {e}") - results.append({"texts": [], "regions": []}) - - return results[0] if not is_batch else results - - -class OCRTransform(VideoTransform): +class OCRTransform(VideoTransform, OcrTask): """ GStreamer element for optical character recognition on video frames. @@ -130,6 +48,8 @@ class OCRTransform(VideoTransform): (JSON with recognized text and regions). """ + META_HEADER = OCR_META_HEADER + __gstmetadata__ = ( "OCR", "Transform", @@ -168,112 +88,10 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_ocr") - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - result = self._do_forward(frames) - if result is None: - return Gst.FlowReturn.ERROR - - if num_sources == 1: - self._apply_ocr(buf, result, fmt, frames) - else: - if isinstance(result, list) and len(result) > 0: - self._apply_ocr( - buf, result[0], fmt, frames[0] if frames.ndim == 4 else frames - ) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"OCR transform error: {e}") - return Gst.FlowReturn.ERROR - - def _do_forward(self, frames): - if self.engine: - return self.engine.do_forward(frames) - return None - - def _apply_ocr(self, buf, result, fmt, frame): - """Draw recognized text on frame and append OCR metadata.""" - import cv2 - import numpy as np - - regions = result.get("regions", []) - - # Draw text overlays before appending read-only metadata memory - if self.draw_text and regions: - overlay = frame.copy() - for region in regions: - x, y, w, h = region["x"], region["y"], region["w"], region["h"] - text = region["text"] - cv2.rectangle(overlay, (x, y), (x + w, y + h), (0, 255, 0), 2) - font_scale = max(0.4, min(w / 300.0, 1.0)) - cv2.putText( - overlay, - text, - (x + 4, y + h - 8), - cv2.FONT_HERSHEY_SIMPLEX, - font_scale, - (0, 255, 0), - 1, - cv2.LINE_AA, - ) - - output = self._convert_rgb_to_format(overlay, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - - # Append OCR results as a custom buffer memory chunk - if regions: - meta_bytes = OCR_META_HEADER + json.dumps(regions).encode("utf-8") - tmp = Gst.Buffer.new_allocate(None, len(meta_bytes), None) - tmp.fill(0, meta_bytes) - buf.append_memory(tmp.get_memory(0)) - - @staticmethod - def _convert_rgb_to_format(rgb, fmt): - """Convert an RGB numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return rgb - elif fmt == "BGR": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) - elif fmt == "RGBA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - return np.roll(rgba, 1, axis=-1) - elif fmt == "ABGR": - bgra = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - return np.roll(bgra, 1, axis=-1) - else: - return rgb - -if CAN_REGISTER_ELEMENT: - GObject.type_register(OCRTransform) - __gstelementfactory__ = ("pyml_ocr", Gst.Rank.NONE, OCRTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_ocr", OCRTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_ocr' element will not be registered because required modules are missing." ) diff --git a/plugins/python/optical_flow.py b/plugins/python/optical_flow.py index 2c27d5a..1b44974 100644 --- a/plugins/python/optical_flow.py +++ b/plugins/python/optical_flow.py @@ -17,23 +17,16 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.optical_flow_engine import OpticalFlowEngine from engine.engine_factory import EngineFactory + from backend import frameio, GObject + from tasks.optical_flow import OpticalFlowTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -50,74 +43,7 @@ } -class OpticalFlowEngine(PyTorchEngine): - """ - PyTorch engine for dense optical flow estimation using RAFT. - - Supports torchvision RAFT model variants: - raft_large (most accurate) - raft_small (fastest) - """ - - def do_load_model(self, model_name, **kwargs): - try: - from torchvision.models.optical_flow import ( - raft_large, - raft_small, - Raft_Large_Weights, - Raft_Small_Weights, - ) - - if model_name == "raft_small": - weights = Raft_Small_Weights.DEFAULT - self.model = raft_small(weights=weights) - else: - weights = Raft_Large_Weights.DEFAULT - self.model = raft_large(weights=weights) - - self.transforms = weights.transforms() - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info(f"RAFT model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load RAFT model '{model_name}': {e}") - - def do_forward(self, prev_frame, curr_frame): - import torch - - try: - H, W = curr_frame.shape[:2] - - # Convert HWC uint8 -> CHW float tensor - prev_t = torch.from_numpy(prev_frame).permute(2, 0, 1).float() - curr_t = torch.from_numpy(curr_frame).permute(2, 0, 1).float() - - # RAFT requires dimensions divisible by 8 - pad_h = (8 - H % 8) % 8 - pad_w = (8 - W % 8) % 8 - if pad_h > 0 or pad_w > 0: - prev_t = torch.nn.functional.pad(prev_t, (0, pad_w, 0, pad_h)) - curr_t = torch.nn.functional.pad(curr_t, (0, pad_w, 0, pad_h)) - - prev_t, curr_t = self.transforms(prev_t, curr_t) - prev_batch = prev_t.unsqueeze(0).to(self.device) - curr_batch = curr_t.unsqueeze(0).to(self.device) - - with torch.no_grad(): - flow_predictions = self.model(prev_batch, curr_batch) - - # RAFT returns a list of flow predictions; take the last (finest) - flow = flow_predictions[-1].squeeze(0).cpu().numpy() - # flow shape: (2, H', W') -> transpose to (H, W, 2) and crop - flow = flow.transpose(1, 2, 0)[:H, :W] - return flow - - except Exception as e: - self.logger.error(f"Optical flow inference error: {e}") - return None - - -class OpticalFlowTransform(VideoTransform): +class OpticalFlowTransform(VideoTransform, OpticalFlowTask): """ GStreamer element for dense optical flow estimation using RAFT. @@ -167,107 +93,32 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_optical_flow") - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - frame = frames[0] if frames.ndim == 4 else frames + def process_frames(self, frames, num_sources, fmt, target): + """Pair this frame with the previous one and draw the flow overlay.""" + frame = frames[0] if frames.ndim == 4 else frames - if self._prev_frame is None: - self._prev_frame = frame.copy() - return Gst.FlowReturn.OK - - flow = self._do_forward(self._prev_frame, frame) + # Temporal pairing stays in the shell: hold the previous frame. + if self._prev_frame is None: self._prev_frame = frame.copy() + return - if flow is None: - return Gst.FlowReturn.OK - - if self.visualize: - self._apply_flow_vis(buf, flow, frame, fmt) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"Optical flow transform error: {e}") - return Gst.FlowReturn.ERROR + flow = self.forward(self._prev_frame, frame) + self._prev_frame = frame.copy() - def _do_forward(self, prev_frame, curr_frame): - if self.engine: - return self.engine.do_forward(prev_frame, curr_frame) - return None + if flow is None: + return - def _apply_flow_vis(self, buf, flow, frame, fmt): - """Render flow as a color overlay and write back to buffer.""" - import cv2 - import numpy as np + if self.visualize: + # Portable task: render the flow overlay frame. + output, blob = self.decode(flow, frame, fmt) + frameio.write_result(target, output, blob) - flow_vis = self._flow_to_color(flow) - blended = cv2.addWeighted(frame, 0.5, flow_vis, 0.5, 0) - output = self._convert_rgb_to_format(blended, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - @staticmethod - def _flow_to_color(flow): - """Convert optical flow (H, W, 2) to an RGB color image using HSV encoding.""" - import cv2 - import numpy as np - - fx, fy = flow[..., 0], flow[..., 1] - mag = np.sqrt(fx**2 + fy**2) - ang = np.arctan2(fy, fx) - - hsv = np.zeros((*flow.shape[:2], 3), dtype=np.uint8) - hsv[..., 0] = ((ang + np.pi) / (2 * np.pi) * 179).astype(np.uint8) - hsv[..., 1] = 255 - mag_norm = mag / (mag.max() + 1e-8) - hsv[..., 2] = (mag_norm * 255).astype(np.uint8) - - return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) - - @staticmethod - def _convert_rgb_to_format(rgb, fmt): - """Convert an RGB numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return rgb - elif fmt == "BGR": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) - elif fmt == "RGBA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - return np.roll(rgba, 1, axis=-1) - elif fmt == "ABGR": - bgra = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - return np.roll(bgra, 1, axis=-1) - else: - return rgb - - -if CAN_REGISTER_ELEMENT: - GObject.type_register(OpticalFlowTransform) - __gstelementfactory__ = ("pyml_optical_flow", Gst.Rank.NONE, OpticalFlowTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_optical_flow", OpticalFlowTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_optical_flow' element will not be registered because required modules are missing." ) diff --git a/plugins/python/overlay.py b/plugins/python/overlay.py index e409074..5417f04 100644 --- a/plugins/python/overlay.py +++ b/plugins/python/overlay.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend from utils.analytics_utils import ANALYTICS_UTILS_AVAILABLE @@ -46,8 +47,8 @@ GstVideo, GstGL, GstVulkan, - GObject, ) # noqa: E402 + from backend import GObject from log.logger_factory import LoggerFactory except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -57,7 +58,9 @@ # Support CPU, OpenGL, and Vulkan buffers VIDEO_FORMATS = "video/x-raw, format=(string){ RGBA, ARGB, BGRA, ABGR }; video/x-raw(memory:GLMemory), format=(string){ RGBA, ARGB, BGRA, ABGR }; video/x-raw(memory:VulkanMemory), format=(string){ RGBA, ARGB, BGRA, ABGR }" -OVERLAY_CAPS = Gst.Caps.from_string(VIDEO_FORMATS) +# Building a Gst object needs Gst.init, which only the gst backend calls. +if backend.BACKEND == "gst": + OVERLAY_CAPS = Gst.Caps.from_string(VIDEO_FORMATS) class Overlay(GstBase.BaseTransform): @@ -68,20 +71,21 @@ class Overlay(GstBase.BaseTransform): "Aaron Boxer ", ) - src_template = Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OVERLAY_CAPS.copy(), - ) + if backend.BACKEND == "gst": + src_template = Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + OVERLAY_CAPS.copy(), + ) - sink_template = Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.ALWAYS, - OVERLAY_CAPS.copy(), - ) - __gsttemplates__ = (src_template, sink_template) + sink_template = Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + OVERLAY_CAPS.copy(), + ) + __gsttemplates__ = (src_template, sink_template) meta_path = GObject.Property( type=str, @@ -445,10 +449,9 @@ def do_post_process(self, frame_metadata): self.tracking_display.fade_history() -if CAN_REGISTER_ELEMENT: - GObject.type_register(Overlay) - __gstelementfactory__ = ("pyml_overlay", Gst.Rank.NONE, Overlay) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_overlay", Overlay) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_overlay' element will not be registered because a module is missing." ) diff --git a/plugins/python/overlay_counter.py b/plugins/python/overlay_counter.py index b49933e..6ce89f0 100644 --- a/plugins/python/overlay_counter.py +++ b/plugins/python/overlay_counter.py @@ -17,11 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - from gi.repository import Gst, GObject - from overlay import Overlay from overlay_helper.overlay_utils_interface import Color except ImportError as e: @@ -32,7 +31,6 @@ class OverlayCounter(Overlay): - __gstmetadata__ = ( "OverlayCounter", "Filter/Effect/Video", @@ -68,8 +66,9 @@ def do_post_process(self, frame_metadata): self.overlay_graphics.draw_text(text, 0, 50, Color(1, 0, 0, 1), 20) -if CAN_REGISTER_ELEMENT: - GObject.type_register(OverlayCounter) - __gstelementfactory__ = ("pyml_overlay_counter", Gst.Rank.NONE, OverlayCounter) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_overlay_counter", OverlayCounter + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning("Failed to register the 'pyml_overlay_counter' element.") diff --git a/plugins/python/overlay_helper/overlay_skia.py b/plugins/python/overlay_helper/overlay_skia.py index 5454210..e1a53e1 100644 --- a/plugins/python/overlay_helper/overlay_skia.py +++ b/plugins/python/overlay_helper/overlay_skia.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -38,8 +39,8 @@ GstVideo, GstAnalytics, GLib, - GObject, ) # noqa: E402 + from backend import GObject from log.logger_factory import LoggerFactory except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -347,10 +348,11 @@ def draw_text_with_cairo(self, cr, label, x, y): cr.stroke() -if CAN_REGISTER_ELEMENT: - GObject.type_register(OverlaySkia) - __gstelementfactory__ = ("pyml_overlay_skia", Gst.Rank.NONE, OverlaySkia) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_overlay_skia", OverlaySkia + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_overlay_skia' element will not be registered because a required module is missing." ) diff --git a/plugins/python/pose.py b/plugins/python/pose.py index cb4a8be..83e5148 100644 --- a/plugins/python/pose.py +++ b/plugins/python/pose.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -28,13 +29,12 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib + from gi.repository import Gst + from backend import analytics, GObject from base_objectdetector import BaseObjectDetector from utils.format_converter import FormatConverter - from engine.pytorch_engine import PyTorchEngine + from engine.yolo_pose_engine import YoloPoseEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -86,46 +86,6 @@ ] -class YoloPoseEngine(PyTorchEngine): - """PyTorch engine for YOLO pose estimation models.""" - - def do_load_model(self, model_name, **kwargs): - try: - from ultralytics import YOLO - - self.model = YOLO(f"{model_name}.pt") - self.execute_with_stream(lambda: self.model.to(self.device)) - self.logger.info(f"YOLO pose model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load YOLO pose model '{model_name}': {e}") - - def do_forward(self, frames): - import numpy as np - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - writable = np.array(frames, copy=True) - batch_size = writable.shape[0] if is_batch else 1 - - model = self.get_model() - if model is None: - self.logger.error("Pose model not loaded") - return None if not is_batch else [None] * batch_size - - try: - img_list = ( - [writable[i] for i in range(batch_size)] if is_batch else [writable] - ) - results = self.execute_with_stream( - lambda: model(img_list, imgsz=640, conf=0.25, verbose=False) - ) - if not results: - return None if not is_batch else [None] * batch_size - return results[0] if not is_batch else results - except Exception as e: - self.logger.error(f"Pose inference error: {e}") - return None if not is_batch else [None] * batch_size - - class YOLOPoseTransform(BaseObjectDetector): """ GStreamer element for human pose estimation using YOLO on video frames. @@ -179,7 +139,7 @@ def do_decode(self, buf, result, stream_idx=0): return # Attach person bounding boxes via GstAnalytics (compatible with pyml_overlay) - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if not meta: self.logger.error("Failed to add analytics relation metadata") return @@ -189,16 +149,16 @@ def do_decode(self, buf, result, stream_idx=0): x1, y1, x2, y2 = boxes.xyxy[i] score = boxes.conf[i].item() - qk = GLib.quark_from_string(f"stream_{stream_idx}_person") - ret, _ = meta.add_od_mtd( - qk, + mtd = analytics.add_object( + meta, + f"stream_{stream_idx}_person", x1.item(), y1.item(), x2.item() - x1.item(), y2.item() - y1.item(), score, ) - if not ret: + if mtd is None: self.logger.error(f"Failed to add od_mtd for person {i}") continue @@ -291,10 +251,11 @@ def _convert_bgr_to_format(bgr, fmt): return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) -if CAN_REGISTER_ELEMENT: - GObject.type_register(YOLOPoseTransform) - __gstelementfactory__ = ("pyml_yolo_pose", Gst.Rank.NONE, YOLOPoseTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_yolo_pose", YOLOPoseTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_yolo_pose' element will not be registered because required modules are missing." ) diff --git a/plugins/python/pyml_launch.py b/plugins/python/pyml_launch.py new file mode 100644 index 0000000..c5cb298 --- /dev/null +++ b/plugins/python/pyml_launch.py @@ -0,0 +1,532 @@ +# pyml-launch +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Run one pipeline description on whichever backend `PYML_BACKEND` selects. + +`gst` hands the pipeline to `gst-launch-1.0` unchanged. `g2g` rewrites it for +`g2g-launch-py`, which spells five things differently: + + * a `pyml_*` element is the generic `pyelement` host plus the module and class + to load; + * `pyml_overlay` is g2g's own `analyticsoverlay`, a native element with its own + properties rather than a hosted one; + * `pyelement` and `analyticsoverlay` both work on RGBA, so a raw-video caps + filter that leaves the format open has to pin it; + * a hosted element carries no pad templates into g2g, so an element that + declares `INPUT_CAPS` / `OUTPUT_CAPS` hands them to its host as properties, + which is the only way it can take audio in and give text out; + * a sink takes no clock properties and the overlay no `wait-text`, because + g2g never does what those switch off. + +Everything else is named the same on both: `filesrc`, `decodebin`, +`videoconvert`, `videoscale`, `autovideosink`. Write the pipeline the way the +README does, in `gst-launch` spelling, and this translates it. Either way the +line the backend runs is printed, so it can be pasted back and extended. +""" + +import ast +import os +import shlex +import shutil +import site +import subprocess +import sys +from pathlib import Path +from typing import NamedTuple + +#: The checkout's `plugins` tree, when this module was loaded from one. An +#: installed copy sits in site-packages, which carries no element modules. +_PLUGINS = Path(__file__).resolve().parent.parent +CHECKOUT_PLUGINS = _PLUGINS if _PLUGINS.name == "plugins" else None + +USAGE = "usage: pyml-launch [key=value ...] ! ! ..." + +#: The g2g launcher, and the checkout it is built in, looked for beside this one. +G2G_LAUNCH_NAME = "g2g-launch-py" +G2G_CHECKOUT_NAME = "glass2glass" + +#: The g2g elements that host a gst-python-ml one: a transform, and the N-in +#: batching host an aggregator needs. +PY_ELEMENT = "pyelement" +PY_AGGREGATOR = "pyaggregator" + +#: gst-python-ml elements that g2g implements natively instead of hosting. +NATIVE_EQUIVALENTS = {"pyml_overlay": "analyticsoverlay"} + +#: What each native element calls the properties it shares with the one it +#: replaces. A property missing here has no counterpart at all. +NATIVE_PROPERTIES = {"pyml_overlay": {"tracking": "show-track"}} + +#: What separates two elements, on both spellings. +SEPARATOR = "!" + +#: `(element name suffix, property)` a g2g pipeline does not spell, mapped to +#: what to do when the behaviour it switches off is actually wanted. +DEFAULTED_PROPERTIES = { + ("sink", "sync"): "a g2g sink does not wait on the clock, put a `clocksync` " + "element ahead of it instead", + ("sink", "async"): "a g2g sink does not wait on the clock, put a `clocksync` " + "element ahead of it instead", + ("textoverlay", "wait-text"): "g2g's textoverlay never holds video back for " + "the text pad", +} + +FALSE_VALUES = ("false", "0", "no", "off") + +#: `pyelement` hosts an RGBA frame by default and `analyticsoverlay` draws on +#: RGBA8 only, so a caps filter that names no format would fail to negotiate. +G2G_RAW_VIDEO_FORMAT = "RGBA" + +#: The class constant an element states each of its pad caps in, and the host +#: property it becomes. Each value is one `gst-launch` caps description, which +#: g2g refuses unless it names exactly one concrete caps. +G2G_CAPS_PROPERTIES = { + "INPUT_CAPS": "input-caps", + "OUTPUT_CAPS": "output-caps", +} + + +class ElementShell(NamedTuple): + """What hosting one gst-python-ml element on g2g takes: the module and class + to load, and the `(property, caps)` pairs it negotiates with, if it says.""" + + module: str + cls: str + caps: tuple = () + + +def plugin_dir(): + """The directory holding the element modules: the checkout this module came + from, or the `python` subdirectory of a `GST_PLUGIN_PATH` entry.""" + if CHECKOUT_PLUGINS: + return CHECKOUT_PLUGINS / "python" + for entry in os.environ.get("GST_PLUGIN_PATH", "").split(os.pathsep): + candidate = Path(entry) / "python" + if candidate.is_dir(): + return candidate + raise SystemExit( + "pyml-launch: no gst-python-ml element modules found; point " + "GST_PLUGIN_PATH at the checkout's plugins directory" + ) + + +def element_shells(directory=None): + """Map each element name to the `ElementShell` hosting it takes. + + Read out of the sources with `ast` rather than by importing: a plugin pulls + in torch and friends, and every one whose dependencies are missing would + drop out of the map. + """ + shells = {} + registered = [] + declarations = {} + for path in sorted((directory or plugin_dir()).glob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + constants = string_constants(tree) + declarations.update(caps_declarations(tree)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + called = node.func + name = ( + called.attr + if isinstance(called, ast.Attribute) + else getattr(called, "id", None) + ) + if name != "register_gst_element" or len(node.args) < 2: + continue + element, cls = node.args[0], node.args[1] + element = element_name(element, constants) + if element and isinstance(cls, ast.Name): + registered.append((element, path.stem, cls.id)) + for element, module, cls in registered: + shells[element] = ElementShell(module, cls, declared_caps(cls, declarations)) + return shells + + +def caps_declarations(tree): + """Each class in the module: the classes it derives from, and the g2g caps it + states itself.""" + declarations = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + constants = string_constants(node) + caps = { + property: constants[f"{node.name}.{constant}"] + for constant, property in G2G_CAPS_PROPERTIES.items() + if f"{node.name}.{constant}" in constants + } + bases = [base.id for base in node.bases if isinstance(base, ast.Name)] + declarations[node.name] = (bases, caps) + return declarations + + +def declared_caps(cls, declarations): + """The `(property, caps)` pairs a class negotiates with, the ones it states + itself ahead of the ones it inherits. + + A leaf that changes one pad restates only that pad, so a class taking text in + and giving audio out gets its input from the base and its output from itself. + """ + caps = {} + pending, seen = [cls], set() + while pending: + name = pending.pop(0) + if name in seen or name not in declarations: + continue + seen.add(name) + bases, stated = declarations[name] + for property, value in stated.items(): + caps.setdefault(property, value) + pending.extend(bases) + return tuple( + (property, caps[property]) + for property in G2G_CAPS_PROPERTIES.values() + if property in caps + ) + + +def string_constants(tree): + """Every `NAME = "..."` under the node, class attributes keyed `Class.NAME`. + + Some elements register under a class constant rather than a literal, so the + name has to be resolved before it can go in the map. + """ + constants = {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + prefix = f"{node.name}." + body = node.body + elif isinstance(node, ast.Module): + prefix = "" + body = node.body + else: + continue + for statement in body: + if not isinstance(statement, ast.Assign): + continue + if not isinstance(statement.value, ast.Constant): + continue + if not isinstance(statement.value.value, str): + continue + for target in statement.targets: + if isinstance(target, ast.Name): + constants[f"{prefix}{target.id}"] = statement.value.value + return constants + + +def element_name(node, constants): + """The element name a `register_gst_element` first argument spells, whether + it is a literal or a constant defined in the same module.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + return constants.get(f"{node.value.id}.{node.attr}") + if isinstance(node, ast.Name): + return constants.get(node.id) + return None + + +def rewrite_for_g2g(argv, shells): + """Translate `gst-launch` arguments into the ones `g2g-launch-py` takes. + + Works on the argument list rather than one joined string so a property value + the shell already unquoted (`labels="person, bicycle"`) keeps its boundary, + and gets requoted on the way out. + """ + rewritten = [] + raw_format = None + parts = list(segments(argv)) + fan_in = fan_in_names(parts) + for segment in parts: + # Separators and references are carried through where they were + # written: which chains link to which is the caller's, not ours. + if segment == [SEPARATOR] or is_reference(segment[0]): + rewritten.extend(segment) + continue + host = PY_AGGREGATOR if declared_name(segment) in fan_in else PY_ELEMENT + translated = rewrite_segment(segment, shells, raw_format, host) + rewritten.extend(translated) + raw_format = raw_video_format(translated[0]) or raw_format + return rewritten + + +def declared_name(segment): + """The handle a segment gives itself with `name=`, if any.""" + for token in segment[1:]: + key, separator, value = token.partition("=") + if separator and key == "name": + return value + return None + + +def fan_in_names(parts): + """The element handles several chains feed into. + + A chain *ending* in a bare `mux.` reference links into that element, so an + element named by more than one is taking more than its own chain's input. + That is the one shape g2g hosts on `pyaggregator` (its N-in batching host) + rather than the one-in `pyelement`. A reference that *starts* a chain + (`cap.text_src ! ...`) reads the other way, out of a second source pad, and + leaves the element a one-in transform. + """ + feeding = [ + segment[0].partition(".")[0] + for previous, segment in zip([None, *parts], parts) + if previous == [SEPARATOR] and len(segment) == 1 and is_reference(segment[0]) + ] + return {name for name in feeding if feeding.count(name) > 1} + + +def raw_video_format(caps): + """The format a raw-video caps filter pins, or `None` for anything else.""" + if not caps.startswith("video/x-raw"): + return None + for field in caps.split(","): + key, separator, value = field.partition("=") + if separator and key == "format": + return value + return None + + +def segments(argv): + """The argument list split into one token list per element, `!`, or + reference. + + A `!` separates two elements, and a bare `name.` reference is a chain + boundary of its own: `mux. ! sink` starts a chain at that element and + `... ! mux.` ends one there. Both come back as their own single-token + segment, so the links the caller wrote survive the rewrite unchanged. + """ + segment = [] + for token in argv: + if token == SEPARATOR or is_reference(token): + if segment: + yield segment + yield [token] + segment = [] + else: + segment.append(token) + if segment: + yield segment + + +def is_reference(token): + """Whether the token is a `name.` / `name.pad` reference to an element.""" + name, separator, pad = token.partition(".") + return bool( + separator + and name[:1].isalpha() + and name.replace("_", "").replace("-", "").isalnum() + and (not pad or pad.replace("_", "").isalnum()) + ) + + +def rewrite_segment(segment, shells, raw_format=None, host=None): + if isinstance(segment, str): + segment = segment.split() + if not segment: + return [] + head, properties = segment[0], segment[1:] + + # A caps filter is the one segment whose first token is a media type; no + # element name contains a slash. Caps carry no spaces, so a caps written + # with one (`video/x-raw, width=320`) arrives as several tokens to rejoin. + if "/" in head: + return [with_pinned_format("".join(segment))] + + native = NATIVE_EQUIVALENTS.get(head) + if native: + return [native, *[quoted(p) for p in renamed_properties(head, properties)]] + + shell = shells.get(head) + if shell: + # Every property of a hosted element goes to the Python class, so the + # defaulted-property table must not touch one whose name happens to end + # like a g2g element's (`pyml_kafkasink` and its `sync`). + properties = [quoted(p) for p in properties] + hosted = [host or PY_ELEMENT, f"module={shell.module}", f"class={shell.cls}"] + # `pyelement` takes RGBA unless told otherwise, so an upstream caps + # filter naming another format has to reach the hosted element too. + # `pyaggregator` negotiates from its inputs and takes no format. + pin = raw_format not in (None, G2G_RAW_VIDEO_FORMAT) + if hosted[0] == PY_ELEMENT and pin: + if not any(p.startswith("format=") for p in properties): + hosted.append(f"format={raw_format}") + hosted.extend(quoted(f"{property}={value}") for property, value in shell.caps) + return [*hosted, *properties] + + if head.startswith("pyml_"): + raise SystemExit(f"pyml-launch: no gst-python-ml element named {head!r}") + + return [head, *[quoted(p) for p in drop_defaulted_properties(head, properties)]] + + +def renamed_properties(head, properties): + """The properties under the names g2g's own element spells them. + + A native equivalent is a different element, not a rename, so only the knobs + that mean the same thing carry over. One that does not is an error rather + than a guess, because dropping it would run a pipeline that quietly does + something else. + """ + renames = NATIVE_PROPERTIES.get(head, {}) + renamed = [] + for property in properties: + key, _, value = property.partition("=") + if key not in renames: + raise SystemExit( + f"pyml-launch: g2g's {NATIVE_EQUIVALENTS[head]} has no counterpart " + f"for {head} {key}; of its properties only " + f"{', '.join(sorted(renames))} carries over" + ) + renamed.append(f"{renames[key]}={value}") + return renamed + + +def drop_defaulted_properties(head, properties): + """The properties minus the ones naming behaviour g2g already has. + + Each of these switches off something g2g never does, so turning it off is a + no-op there. Asking for it back is refused rather than dropped quietly, + because that needs a pipeline change this cannot make on its own. + """ + kept = [] + for property in properties: + key, _, value = property.partition("=") + instead = next( + ( + instead + for (suffix, name), instead in DEFAULTED_PROPERTIES.items() + if name == key and head.endswith(suffix) + ), + None, + ) + if instead is None: + kept.append(property) + elif value.lower() not in FALSE_VALUES: + raise SystemExit( + f"pyml-launch: {head} {property} has no equivalent on g2g; {instead}" + ) + return kept + + +def quoted(token): + """A token spelled so g2g's launch parser reads the value back unchanged. + + Its grammar treats a quote, a `\\`, a `!` or a `#` as syntax wherever it + appears, and the argument list is joined with spaces before parsing, so a + value carrying any of those has to say so. + """ + key, separator, value = token.partition("=") + if not separator: + return token + for character in ("\\", '"', "'", "!", "#"): + value = value.replace(character, f"\\{character}") + if any(character.isspace() for character in value): + value = f'"{value}"' + return f"{key}={value}" + + +def with_pinned_format(caps): + if not caps.startswith("video/x-raw") or "format=" in caps: + return caps + return f"{caps},format={G2G_RAW_VIDEO_FORMAT}" + + +def _prepend_path(existing, *entries): + paths = [str(entry) for entry in entries] + if existing: + paths.append(existing) + return os.pathsep.join(paths) + + +def launch_environment(): + """The environment both launchers need. + + Neither runs under this interpreter: GStreamer's Python loader embeds the + system one and g2g embeds its own, so torch and the rest are only reachable + if the venv's site directories are on `PYTHONPATH`. + """ + env = os.environ.copy() + env["PYTHONPATH"] = _prepend_path( + env.get("PYTHONPATH"), plugin_dir(), *site.getsitepackages() + ) + return env + + +def gst_command(argv): + env = launch_environment() + if CHECKOUT_PLUGINS: + env["GST_PLUGIN_PATH"] = _prepend_path( + env.get("GST_PLUGIN_PATH"), CHECKOUT_PLUGINS + ) + return ["gst-launch-1.0", *argv], env + + +def g2g_binary(): + """The `g2g-launch-py` to run: an explicit `G2G_LAUNCH`, else the release + build in the glass2glass checkout, else whatever is on `PATH`.""" + explicit = os.environ.get("G2G_LAUNCH") + if explicit: + return explicit + checkout = os.environ.get("G2G_DIR") + if not checkout and CHECKOUT_PLUGINS: + checkout = CHECKOUT_PLUGINS.parent.parent / G2G_CHECKOUT_NAME + if checkout: + release = Path(checkout) / "target" / "release" / G2G_LAUNCH_NAME + if release.is_file(): + return str(release) + return shutil.which(G2G_LAUNCH_NAME) + + +def g2g_command(pipeline): + binary = g2g_binary() + if not binary: + raise SystemExit( + "pyml-launch: no g2g-launch-py release build found; point G2G_LAUNCH " + "at one, or G2G_DIR at a glass2glass checkout, and build it with: " + "PYO3_PYTHON=$(which python) cargo build --release -p g2g-python " + "--features ml --bin g2g-launch-py" + ) + return [binary, *pipeline], launch_environment() + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else list(argv) + if not argv: + raise SystemExit(USAGE) + + backend = os.environ.get("PYML_BACKEND", "gst").lower() + if backend == "gst": + command, env = gst_command(argv) + elif backend == "g2g": + command, env = g2g_command(rewrite_for_g2g(argv, element_shells())) + else: + raise SystemExit( + f"pyml-launch: unknown PYML_BACKEND={backend!r}; use 'gst' or 'g2g'" + ) + + # The line the backend actually runs, so it can be pasted back and extended. + print(f"pyml-launch: {shlex.join(command)}", file=sys.stderr) + return subprocess.call(command, env=env) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/python/sam.py b/plugins/python/sam.py index 81be32b..1d74bc6 100644 --- a/plugins/python/sam.py +++ b/plugins/python/sam.py @@ -17,24 +17,16 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - import json - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.sam_engine import SamEngine from engine.engine_factory import EngineFactory + from backend import GObject + from tasks.sam import SamTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -43,109 +35,8 @@ # Header prefix for segmentation mask buffer metadata SAM_META_HEADER = b"GST-SAM:" -# Mask overlay colors (BGR) cycled across detected objects -MASK_COLORS = [ - (255, 0, 0), - (0, 255, 0), - (0, 0, 255), - (255, 255, 0), - (0, 255, 255), - (255, 0, 255), - (128, 0, 255), - (255, 128, 0), - (0, 128, 255), - (128, 255, 0), -] - - -class SamEngine(PyTorchEngine): - """ - PyTorch engine for Segment Anything Model 2 (SAM2). - - Supports HuggingFace model IDs: - facebook/sam2-hiera-large - facebook/sam2-hiera-base-plus - facebook/sam2-hiera-small - facebook/sam2-hiera-tiny - """ - - def do_load_model(self, model_name, **kwargs): - try: - from transformers import Sam2Model, Sam2Processor - - self.processor = Sam2Processor.from_pretrained(model_name) - self.model = Sam2Model.from_pretrained(model_name) - self.execute_with_stream(lambda: self.model.to(self.device)) - self.model.eval() - self.logger.info(f"SAM2 model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load SAM2 model '{model_name}': {e}") - - def do_forward(self, frames, max_masks=10): - import numpy as np - import torch - from PIL import Image - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - results = [] - for frame in frames: - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - H, W = frame.shape[:2] - - # Automatic mask generation: grid of input points - grid_size = int(np.ceil(np.sqrt(max_masks))) - xs = np.linspace(0, W - 1, grid_size).astype(int) - ys = np.linspace(0, H - 1, grid_size).astype(int) - points = [[int(x), int(y)] for y in ys for x in xs][:max_masks] - input_points = [points] - - inputs = self.processor( - images=pil_img, - input_points=input_points, - return_tensors="pt", - ) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = self.model(**inputs) - - masks = self.processor.post_process_masks( - outputs.pred_masks, - inputs["original_sizes"], - inputs["reshaped_input_sizes"], - ) - scores = outputs.iou_scores - mask_list = [] - if len(masks) > 0: - frame_masks = masks[0].cpu().numpy() - frame_scores = scores[0].cpu().numpy() - for j in range(min(frame_masks.shape[0], max_masks)): - best_idx = frame_scores[j].argmax() - mask = frame_masks[j, best_idx] - score = float(frame_scores[j, best_idx]) - mask_list.append( - {"mask_idx": j, "score": score, "shape": list(mask.shape)} - ) - - results.append( - { - "masks": mask_list, - "raw_masks": masks[0].cpu().numpy() if len(masks) > 0 else None, - } - ) - except Exception as e: - self.logger.error(f"SAM inference error on frame: {e}") - results.append({"masks": [], "raw_masks": None}) - - return results[0] if not is_batch else results - - -class SamTransform(VideoTransform): +class SamTransform(VideoTransform, SamTask): """ GStreamer element for image segmentation using Segment Anything Model 2. @@ -157,6 +48,8 @@ class SamTransform(VideoTransform): GST-SAM: memory chunk (JSON with mask scores and shapes). """ + META_HEADER = SAM_META_HEADER + __gstmetadata__ = ( "SAM Segmentation", "Transform", @@ -205,110 +98,10 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_sam") - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - result = self._do_forward(frames) - if result is None: - return Gst.FlowReturn.ERROR - - if num_sources == 1: - self._apply_masks(buf, result, fmt, frames) - else: - if isinstance(result, list) and len(result) > 0: - self._apply_masks( - buf, result[0], fmt, frames[0] if frames.ndim == 4 else frames - ) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"SAM transform error: {e}") - return Gst.FlowReturn.ERROR - - def _do_forward(self, frames): - if self.engine: - return self.engine.do_forward(frames, max_masks=self.max_masks) - return None - - def _apply_masks(self, buf, result, fmt, frame): - """Overlay masks on frame and append mask metadata.""" - import cv2 - import numpy as np - - raw_masks = result.get("raw_masks") - mask_info = result.get("masks", []) - - # Draw mask overlays before appending read-only metadata memory - if self.visualize and raw_masks is not None: - overlay = frame.copy() - for j in range(min(raw_masks.shape[0], self.max_masks)): - best_idx = 0 - if raw_masks.ndim == 4 and raw_masks.shape[1] > 1: - best_idx = raw_masks[j].sum(axis=(1, 2)).argmax() - mask = raw_masks[j, best_idx] - color = MASK_COLORS[j % len(MASK_COLORS)] - colored = np.zeros_like(overlay) - colored[:] = color - mask_bool = mask > 0.5 - overlay[mask_bool] = cv2.addWeighted( - overlay[mask_bool], 0.5, colored[mask_bool], 0.5, 0 - ) - - output = self._convert_rgb_to_format(overlay, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - - # Append mask metadata as a custom buffer memory chunk - if mask_info: - meta_bytes = SAM_META_HEADER + json.dumps(mask_info).encode("utf-8") - tmp = Gst.Buffer.new_allocate(None, len(meta_bytes), None) - tmp.fill(0, meta_bytes) - buf.append_memory(tmp.get_memory(0)) - - @staticmethod - def _convert_rgb_to_format(rgb, fmt): - """Convert an RGB numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return rgb - elif fmt == "BGR": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) - elif fmt == "RGBA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - return np.roll(rgba, 1, axis=-1) - elif fmt == "ABGR": - bgra = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - return np.roll(bgra, 1, axis=-1) - else: - return rgb - -if CAN_REGISTER_ELEMENT: - GObject.type_register(SamTransform) - __gstelementfactory__ = ("pyml_sam", Gst.Rank.NONE, SamTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_sam", SamTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_sam' element will not be registered because required modules are missing." ) diff --git a/plugins/python/sepformer.py b/plugins/python/sepformer.py index c63b57b..ccb20a6 100644 --- a/plugins/python/sepformer.py +++ b/plugins/python/sepformer.py @@ -17,19 +17,14 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GObject", "2.0") - from gi.repository import Gst, GObject, GstBase # noqa: E402 + from backend import GObject from base_separate import BaseSeparate - import os - from engine.pytorch_engine import PyTorchEngine + from engine.sepformer_engine import SepformerEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -38,85 +33,12 @@ f"The 'pyml_sepformer' element will not be available. Error: {e}" ) +if backend.BACKEND == "gst": + import gi -class SepformerEngine(PyTorchEngine): - def __init__(self): - super().__init__() - self.sample_rate = 0 - - def do_load_model(self, model_name, **kwargs): - from speechbrain.pretrained import SepformerSeparation - from huggingface_hub import snapshot_download - - if not model_name: - return - self.logger.info(f"Loading Sepformer-WhamR model on device: {self.device}") - savedir = "pretrained_models/sepformer-whamr" - repo_id = "speechbrain/sepformer-whamr" - try: - # Download the model files manually to avoid deprecated argument issues - if not os.path.exists(savedir): - snapshot_download(repo_id=repo_id, local_dir=savedir) - # Load from local directory - self.model = SepformerSeparation.from_hparams( - source=savedir, savedir=savedir, run_opts={"device": self.device} - ) - self.sample_rate = 8000 # Hz, as per SpeechBrain Sepformer models - self.sources = ["source0", "source1"] # 2 sources for separation - except Exception as e: - self.logger.error(f"Failed to load Sepformer-WhamR model: {e}") - - def separate_sources( - self, - mix, - segment=10.0, - overlap=0.1, - ): - import torch - from torchaudio.transforms import Fade - - device = mix.device - batch, length = mix.shape # For SpeechBrain, input is (batch, time) - chunk_len = int(self.sample_rate * segment * (1 + overlap)) - start = 0 - end = chunk_len - overlap_frames = int(overlap * self.sample_rate) - fade = Fade(fade_in_len=0, fade_out_len=overlap_frames, fade_shape="linear") - - final = torch.zeros(batch, len(self.sources), length, device=device) - - min_chunk_samples = int(self.sample_rate * 0.5) # Avoid tiny chunks - - while start < length - overlap_frames: - actual_end = min(end, length) - chunk_length = actual_end - start - if chunk_length < min_chunk_samples: - break - - chunk = mix[:, start:actual_end] - if chunk_length < chunk_len: - pad = chunk_len - chunk_length - chunk = torch.nn.functional.pad(chunk, (0, pad)) - - # Add small epsilon noise to avoid zero std - chunk += 1e-8 * torch.randn_like(chunk) - - with torch.no_grad(): - out = self.model.separate_batch(chunk) # (batch, time, sources) - out = out.permute(0, 2, 1) # (batch, sources, time) - - out = out[:, :, :chunk_length] - out = fade(out) - final[:, :, start:actual_end] += out - if start == 0: - fade.fade_in_len = overlap_frames - start += int(chunk_len - overlap_frames) - else: - start += chunk_len - end += chunk_len - if end >= length: - fade.fade_out_len = 0 - return final + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 class Sepformer(BaseSeparate): @@ -129,32 +51,27 @@ class Sepformer(BaseSeparate): SAMPLE_RATE = 8000 - CAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=SAMPLE_RATE, - channels=1, + INPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=8000,channels=1" + OUTPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=8000,channels=1" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + Gst.Caps.from_string(INPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), ) - ) - - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - CAPS, - GstBase.AggregatorPad.__gtype__, - ), - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - CAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) def __init__(self): super().__init__() @@ -208,8 +125,7 @@ def do_separate(self, audio_data): return selected.cpu().numpy() -if CAN_REGISTER_ELEMENT: - GObject.type_register(Sepformer) - __gstelementfactory__ = ("pyml_sepformer", Gst.Rank.NONE, Sepformer) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_sepformer", Sepformer) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning("pyml_sepformer not registered") diff --git a/plugins/python/stablediffusion.py b/plugins/python/stablediffusion.py index afc2f3a..9c1017b 100644 --- a/plugins/python/stablediffusion.py +++ b/plugins/python/stablediffusion.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -28,14 +29,16 @@ gi.require_version("GObject", "2.0") from gi.repository import Gst, GstBase # noqa: E402 from base_aggregator import BaseAggregator + from diffusers import StableDiffusionPipeline # noqa: F401 except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning( f"The 'pyml_stablediffusion' element will not be available. Error: {e}" ) -# Set output caps to image format (e.g., PNG) -ICAPS = Gst.Caps(Gst.Structure("text/plain", format="utf8")) +# Building a Gst object needs Gst.init, which only the gst backend calls. +if backend.BACKEND == "gst": + ICAPS = Gst.Caps(Gst.Structure("text/plain", format="utf8")) class StableDiffusion(BaseAggregator): @@ -46,23 +49,24 @@ class StableDiffusion(BaseAggregator): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.REQUEST, - ICAPS, - GstBase.AggregatorPad.__gtype__, - ), - Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - Gst.Caps.from_string( - "video/x-raw, width=512, height=512, format=RGBA, framerate=0/1" + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.REQUEST, + ICAPS, + GstBase.AggregatorPad.__gtype__, ), - ), - ) + Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string( + "video/x-raw, width=512, height=512, format=RGBA, framerate=0/1" + ), + ), + ) def do_load_model(self): """ @@ -153,14 +157,11 @@ def push_image_to_pipeline(self, image_data): self.logger.error(f"Error pushing image to pipeline: {e}") -# if CAN_REGISTER_ELEMENT: -# GObject.type_register(StableDiffusion) -# __gstelementfactory__ = ( -# "pyml_stablediffusion", -# Gst.Rank.NONE, -# StableDiffusion, -# ) -# else: -# GlobalLogger().warning( -# "The 'pyml_stablediffusion' element will not be registered because required modules are missing." -# ) +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_stablediffusion", StableDiffusion + ) +elif not CAN_REGISTER_ELEMENT: + GlobalLogger().warning( + "The 'pyml_stablediffusion' element will not be registered because required modules are missing." + ) diff --git a/plugins/python/streamdemux.py b/plugins/python/streamdemux.py index e781e07..ca6f76d 100644 --- a/plugins/python/streamdemux.py +++ b/plugins/python/streamdemux.py @@ -16,13 +16,13 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import backend import gi gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") -gi.require_version("GstAnalytics", "1.0") -gi.require_version("GLib", "2.0") -from gi.repository import Gst, GObject, GstAnalytics, GLib # noqa: E402 +from gi.repository import Gst, GObject # noqa: E402 +from backend import analytics # noqa: E402 from log.logger_factory import LoggerFactory # noqa: E402 from utils.metadata import Metadata # noqa: E402 from collections import defaultdict # noqa: E402 @@ -121,25 +121,27 @@ def process_src_pad(self, buffer, memory_chunk, stream_idx): out_buffer.duration = buffer.duration out_buffer.dts = buffer.dts out_buffer.offset = buffer.offset - meta = GstAnalytics.buffer_get_analytics_relation_meta(buffer) + meta = analytics.get_relation_meta(buffer) if meta: - out_meta = GstAnalytics.buffer_add_analytics_relation_meta(out_buffer) - count = GstAnalytics.relation_get_length(meta) + out_meta = analytics.add_relation_meta(out_buffer) + objects = analytics.read_objects(meta) self.logger.info( - f"Processing {count} analytics relations for stream_{stream_idx}" + f"Processing {len(objects)} analytics relations for stream_{stream_idx}" ) - for i in range(count): - ret, od_mtd = meta.get_od_mtd(i) - if ret and od_mtd: - label_quark = od_mtd.get_obj_type() - label = GLib.quark_to_string(label_quark) - if f"stream_{stream_idx}_" in label: - presence, x, y, w, h, conf = od_mtd.get_location() - if presence: - qk = GLib.quark_from_string(label) - ret, new_od_mtd = out_meta.add_od_mtd(qk, x, y, w, h, conf) - if not ret: - self.logger.error(f"Failed to attach metadata: {label}") + for obj in objects: + label = obj["label"] + if f"stream_{stream_idx}_" in label: + new_od_mtd = analytics.add_object( + out_meta, + label, + obj["x"], + obj["y"], + obj["w"], + obj["h"], + obj["score"], + ) + if new_od_mtd is None: + self.logger.error(f"Failed to attach metadata: {label}") return out_buffer def push_buffer(self, pad, buffer, pad_name): @@ -229,5 +231,7 @@ def event(self, pad, parent, event): return Gst.PadProbeReturn.OK -GObject.type_register(StreamDemux) -__gstelementfactory__ = ("pyml_streamdemux", Gst.Rank.NONE, StreamDemux) +if backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_streamdemux", StreamDemux + ) diff --git a/plugins/python/streammux.py b/plugins/python/streammux.py index 5547285..29dea89 100644 --- a/plugins/python/streammux.py +++ b/plugins/python/streammux.py @@ -16,6 +16,7 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import backend import gi gi.require_version("Gst", "1.0") @@ -210,5 +211,5 @@ def do_get_property(self, prop): raise AttributeError(f"Unknown property: {prop.name}") -GObject.type_register(StreamMux) -__gstelementfactory__ = ("pyml_streammux", Gst.Rank.NONE, StreamMux) +if backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_streammux", StreamMux) diff --git a/plugins/python/superres.py b/plugins/python/superres.py index 7ffed5d..341a3d9 100644 --- a/plugins/python/superres.py +++ b/plugins/python/superres.py @@ -17,103 +17,23 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import ctypes - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform from utils.format_converter import FormatConverter - from utils.muxed_buffer_processor import MuxedBufferProcessor - from engine.pytorch_engine import PyTorchEngine + from engine.super_res_engine import SuperResEngine from engine.engine_factory import EngineFactory + from backend import frameio, GObject + from tasks.superres import SuperResTask except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning(f"The 'superres' element will not be available. Error {e}") -class SuperResEngine(PyTorchEngine): - """ - PyTorch engine for image super-resolution using Real-ESRGAN. - - Supports model variants: - real-esrgan-x4 (4x upscale, general purpose) - real-esrgan-x2 (2x upscale) - """ - - def do_load_model(self, model_name, **kwargs): - try: - from basicsr.archs.rrdbnet_arch import RRDBNet - from realesrgan import RealESRGANer - - scale = 4 - if "x2" in model_name: - scale = 2 - - model = RRDBNet( - num_in_ch=3, - num_out_ch=3, - num_feat=64, - num_block=23, - num_grow_ch=32, - scale=scale, - ) - - model_url = ( - "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth" - if scale == 4 - else "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth" - ) - - gpu_id = 0 if str(self.device) != "cpu" else None - self.upsampler = RealESRGANer( - scale=scale, - model_path=model_url, - model=model, - tile=0, - tile_pad=10, - pre_pad=0, - half=False, - gpu_id=gpu_id, - ) - self._scale = scale - self.logger.info(f"Real-ESRGAN model '{model_name}' (scale={scale}) loaded") - except Exception as e: - raise ValueError(f"Failed to load Real-ESRGAN model '{model_name}': {e}") - - def do_forward(self, frames): - import cv2 - import numpy as np - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - if not is_batch: - frames = frames[np.newaxis] - - results = [] - for frame in frames: - try: - # Real-ESRGAN expects BGR input - bgr = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_RGB2BGR) - output, _ = self.upsampler.enhance(bgr, outscale=self._scale) - # Convert back to RGB - rgb_out = cv2.cvtColor(output, cv2.COLOR_BGR2RGB) - results.append(rgb_out) - except Exception as e: - self.logger.error(f"Super-resolution inference error: {e}") - results.append(None) - - return results[0] if not is_batch else results - - -class SuperResTransform(VideoTransform): +class SuperResTransform(VideoTransform, SuperResTask): """ GStreamer element for image super-resolution using Real-ESRGAN. @@ -156,82 +76,22 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_superres") - def do_transform_ip(self, buf): - try: - processor = MuxedBufferProcessor( - self.logger, self.width, self.height, 30, 1 - ) - frames, _, num_sources, fmt = processor.extract_frames(buf, self.sinkpad) - if frames is None: - return Gst.FlowReturn.ERROR - - frame = frames[0] if frames.ndim == 4 else frames - upscaled = self._do_forward(frame) - if upscaled is None: - return Gst.FlowReturn.OK - - self._apply_superres(buf, upscaled, fmt) - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"Super-resolution transform error: {e}") - return Gst.FlowReturn.ERROR - - def _do_forward(self, frame): - if self.engine: - return self.engine.do_forward(frame) - return None - - def _apply_superres(self, buf, upscaled, fmt): - """Resize upscaled frame back to original dimensions and write to buffer.""" - import cv2 - import numpy as np - - # Resize back to original buffer dimensions for in-place compatibility - resized = cv2.resize( - upscaled, (self.width, self.height), interpolation=cv2.INTER_LANCZOS4 - ) - output = self._convert_rgb_to_format(resized, fmt) - if output is not None: - success, map_info = buf.map(Gst.MapFlags.WRITE) - if success: - try: - frame_bytes = np.ascontiguousarray(output).tobytes() - dst = (ctypes.c_char * map_info.size).from_buffer(map_info.data) - ctypes.memmove( - dst, frame_bytes, min(len(frame_bytes), map_info.size) - ) - finally: - buf.unmap(map_info) - - @staticmethod - def _convert_rgb_to_format(rgb, fmt): - """Convert an RGB numpy array to the target GStreamer video format.""" - import cv2 - import numpy as np - - if fmt == "RGB": - return rgb - elif fmt == "BGR": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) - elif fmt == "RGBA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - elif fmt == "BGRA": - return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - elif fmt == "ARGB": - rgba = cv2.cvtColor(rgb, cv2.COLOR_RGB2RGBA) - return np.roll(rgba, 1, axis=-1) - elif fmt == "ABGR": - bgra = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGRA) - return np.roll(bgra, 1, axis=-1) - else: - return rgb - - -if CAN_REGISTER_ELEMENT: - GObject.type_register(SuperResTransform) - __gstelementfactory__ = ("pyml_superres", Gst.Rank.NONE, SuperResTransform) -else: + def process_frames(self, frames, num_sources, fmt, target): + """Upscale the primary frame and write it back at the original size.""" + frame = frames[0] if frames.ndim == 4 else frames + upscaled = self.forward(frame) + if upscaled is None: + return + + output, _blob = self.decode(upscaled, fmt) + frameio.write_result(target, output) + + +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_superres", SuperResTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_superres' element will not be registered because required modules are missing." ) diff --git a/plugins/python/tasks/__init__.py b/plugins/python/tasks/__init__.py new file mode 100644 index 0000000..ddac92b --- /dev/null +++ b/plugins/python/tasks/__init__.py @@ -0,0 +1,16 @@ +# Portable ML task logic +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. + +"""Backend-agnostic ML task logic. + +Each module here holds the inference and result-handling steps for one kind of +element (object detection, ...), expressed only in terms of the ML engine, +numpy results, and the analytics facade (`backend.analytics`). A backend +combines a task mixin with a framework element shell, which supplies the +per-buffer glue (do_transform_ip / process, pad templates, buffer I/O). +""" diff --git a/plugins/python/tasks/action.py b/plugins/python/tasks/action.py new file mode 100644 index 0000000..ed1a196 --- /dev/null +++ b/plugins/python/tasks/action.py @@ -0,0 +1,79 @@ +# ActionTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic action-recognition task. + +`ActionTask` runs classification over a window of frames and turns the result +into an optional output frame (a label overlay) plus a metadata dict, using +only numpy/cv2 and the engine. It never touches the buffer and holds no +temporal state; the backend element shell accumulates the frame window, does +the frame read, the frame write, and the metadata attach through the frameio +facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.draw_label - bool overlay flag (a backend-declared property) +""" + +from tasks.frame_format import to_format + + +class ActionTask: + """Inference + frame/metadata production, independent of any framework.""" + + def forward(self, frame_buffer): + if self.engine: + return self.engine.do_forward(frame_buffer) + return None + + def decode(self, frame, result, fmt): + """Turn a classification result into ``(output_frame_or_None, blob_bytes)``. + + ``output_frame`` is the label-overlaid frame in pixel format ``fmt`` + (or None when no overlay is drawn). ``blob_bytes`` is the serialized + action metadata, always returned, to be appended by the shell. + """ + import json + + import cv2 + + label = result.get("label", "") + score = result.get("score", 0.0) + output = None + + # Draw label before appending read-only metadata memory + if self.draw_label and label: + overlay = frame.copy() + text = f"{label} ({score:.2f})" + (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 1.0, 2) + cv2.rectangle(overlay, (8, 8), (16 + tw, 16 + th + 8), (0, 0, 0), -1) + cv2.putText( + overlay, + text, + (12, 12 + th), + cv2.FONT_HERSHEY_SIMPLEX, + 1.0, + (0, 255, 0), + 2, + cv2.LINE_AA, + ) + + output = to_format(overlay, fmt) + + # Serialize action metadata for the shell to append. + return output, json.dumps(result).encode("utf-8") diff --git a/plugins/python/tasks/anomaly.py b/plugins/python/tasks/anomaly.py new file mode 100644 index 0000000..8b619eb --- /dev/null +++ b/plugins/python/tasks/anomaly.py @@ -0,0 +1,89 @@ +# AnomalyTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic anomaly-detection task. + +`AnomalyTask` runs inference and turns the result into an optional output frame +(a heatmap overlay) plus a metadata dict, using only numpy/cv2 and the engine. +It never touches the buffer; the backend element shell does the frame read, +the frame write, and the metadata attach through the frameio facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.threshold - anomaly threshold (a backend-declared property) + self.draw_heatmap - bool overlay flag (a backend-declared property) +""" + +from tasks.frame_format import to_format + + +class AnomalyTask: + """Inference + frame/metadata production, independent of any framework.""" + + def forward(self, frame): + if self.engine: + return self.engine.do_forward(frame, threshold=self.threshold) + return None + + def decode(self, frame, result, fmt): + """Turn an inference result into ``(output_frame_or_None, blob_bytes)``. + + ``output_frame`` is the heatmap-overlaid frame in pixel format ``fmt`` + (or None when no overlay is drawn). ``blob_bytes`` is the serialized + anomaly metadata, always returned, to be appended by the shell. + """ + import json + + import cv2 + import numpy as np + + is_anomaly = result.get("is_anomaly", False) + heatmap = result.get("heatmap") + output = None + + # Draw heatmap overlay when the frame is flagged. + if self.draw_heatmap and is_anomaly and heatmap is not None: + H, W = frame.shape[:2] + heatmap_resized = cv2.resize(heatmap, (W, H)) + heatmap_uint8 = (heatmap_resized * 255).astype(np.uint8) + heatmap_color = cv2.applyColorMap(heatmap_uint8, cv2.COLORMAP_JET) + heatmap_rgb = cv2.cvtColor(heatmap_color, cv2.COLOR_BGR2RGB) + + overlay = cv2.addWeighted(frame, 0.6, heatmap_rgb, 0.4, 0) + + # Draw anomaly score text + score = result.get("score", 0.0) + text = f"ANOMALY: {score:.3f}" + cv2.putText( + overlay, + text, + (12, 36), + cv2.FONT_HERSHEY_SIMPLEX, + 1.0, + (255, 0, 0), + 2, + cv2.LINE_AA, + ) + + output = to_format(overlay, fmt) + + meta = { + "score": result.get("score", 0.0), + "is_anomaly": is_anomaly, + } + return output, json.dumps(meta).encode("utf-8") diff --git a/plugins/python/tasks/clip.py b/plugins/python/tasks/clip.py new file mode 100644 index 0000000..7551794 --- /dev/null +++ b/plugins/python/tasks/clip.py @@ -0,0 +1,68 @@ +# ClipTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic CLIP classification task. + +`ClipTask` turns zero-shot classification results into GstAnalytics-style +metadata via the analytics facade. It never touches the buffer; the backend +element shell does the frame read, runs inference on a background thread, and +passes the opaque metadata target to `decode`. + +Contract expected from the host element: + self.logger - logger (provided by MLEngineMixin) + self.top_k - number of top labels to attach (a backend-declared property) + self.threshold - minimum probability to include (a backend-declared property) + self.width - frame width (host geometry) + self.height - frame height (host geometry) + +`decode`'s `target` argument is the opaque metadata target (a Gst buffer on the +gst backend); it is only passed through to the analytics facade. +""" + +from backend import analytics + + +class ClipTask: + """Result-to-metadata step, independent of any framework.""" + + def decode(self, target, results): + """Attach top-k classification results above threshold as GstAnalytics metadata.""" + meta = analytics.add_relation_meta(target) + if not meta: + self.logger.error("Failed to add analytics relation metadata") + return + + attached = 0 + for label, prob in results: + if attached >= self.top_k: + break + if prob < self.threshold: + break + + mtd = analytics.add_object( + meta, + f"clip_{label.replace(' ', '_')}", + 0, + 0, + self.width, + self.height, + prob, + ) + if mtd is not None: + attached += 1 + self.logger.info(f"CLIP: {label} = {prob:.3f}") diff --git a/plugins/python/tasks/depth.py b/plugins/python/tasks/depth.py new file mode 100644 index 0000000..b2627f6 --- /dev/null +++ b/plugins/python/tasks/depth.py @@ -0,0 +1,103 @@ +# DepthTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic monocular-depth task. + +`DepthTask` runs inference and turns a depth map into an optional output frame +(a colorized depth visualization) plus a serialized metadata blob (the uint8 +normalized depth map), using only numpy/cv2 and the engine. It never touches +the buffer; the backend element shell does the frame read, the frame write, and +the metadata attach through the frameio facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.logger - logger + self.visualize - bool, replace frame with colorized depth (a property) + self.colormap - str colormap name (a property) +""" + +# cv2 colormap IDs for depth visualization +COLORMAP_IDS = { + "inferno": 9, + "jet": 2, + "viridis": 16, + "plasma": 18, + "magma": 13, +} + + +class DepthTask: + """Inference + frame/metadata production, independent of any framework.""" + + def forward(self, frames): + if self.engine: + return self.engine.do_forward(frames) + return None + + def decode(self, frame, depth_map, fmt): + """Normalize depth, optionally visualize, then build metadata. + + Returns ``(output_frame_or_None, blob_bytes)``. ``output_frame`` is the + colorized depth visualization in pixel format ``fmt`` (or None when not + visualizing). ``blob_bytes`` is the uint8 normalized depth map, always + returned, to be appended by the shell. + """ + import cv2 + import numpy as np + + d_min, d_max = depth_map.min(), depth_map.max() + if d_max > d_min: + depth_norm = ((depth_map - d_min) / (d_max - d_min) * 255).astype(np.uint8) + else: + depth_norm = np.zeros_like(depth_map, dtype=np.uint8) + + output = None + + # Visualize first, before appending any read-only metadata memory. + # (A READONLY chunk on the buffer would prevent buf.map(WRITE) from succeeding.) + if self.visualize: + cmap_id = COLORMAP_IDS.get(self.colormap, COLORMAP_IDS["inferno"]) + depth_bgr = cv2.applyColorMap(depth_norm, cmap_id) + output = self._convert_bgr_to_format(depth_bgr, fmt) + + # Build uint8 depth map metadata bytes (payload only; header added by shell). + depth_bytes = depth_norm.tobytes() + return output, depth_bytes + + @staticmethod + def _convert_bgr_to_format(bgr, fmt): + """Convert a BGR numpy array to the target GStreamer video format.""" + import cv2 + import numpy as np + + if fmt == "RGB": + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + elif fmt == "BGR": + return bgr + elif fmt == "RGBA": + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGBA) + elif fmt == "BGRA": + return cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) + elif fmt == "ARGB": + rgba = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGBA) + return np.roll(rgba, 1, axis=-1) # RGBA -> ARGB + elif fmt == "ABGR": + bgra = cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) + return np.roll(bgra, 1, axis=-1) # BGRA -> ABGR + else: + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) diff --git a/plugins/python/tasks/embedding.py b/plugins/python/tasks/embedding.py new file mode 100644 index 0000000..ac81e95 --- /dev/null +++ b/plugins/python/tasks/embedding.py @@ -0,0 +1,67 @@ +# EmbeddingTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic embedding task. + +`EmbeddingTask` runs the embedding forward pass and serializes the result into +the length-prefixed payload that follows the GST-EMBEDDING: header. It uses only +numpy/json/struct and the engine; it never touches the buffer. The backend +element shell does the frame read and appends the payload via the frameio +facade, prepending the header. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.normalize - L2-normalize flag (a backend-declared property) + self.text - optional text query (a backend-declared property) + self._text_embedding - cached text embedding (host-managed state) +""" + +import json +import struct + + +class EmbeddingTask: + """Inference + payload serialization, independent of any framework.""" + + def forward(self, frame): + return self.engine.do_forward(frame, normalize=self.normalize) + + def decode(self, emb): + """Serialize an embedding into ``(None, payload_bytes)``. + + ``payload_bytes`` is everything AFTER the GST-EMBEDDING: header: the + 4-byte JSON header length, the JSON header, and the raw float32 bytes. + The shell prepends the header when appending the blob. + """ + import numpy as np + + # Build JSON header with dimension info and optional similarity score + header = {"dim": int(emb.shape[0]), "dtype": "float32"} + if self._text_embedding is not None: + similarity = float(np.dot(emb, self._text_embedding)) + header["text"] = self.text + header["similarity"] = round(similarity, 6) + + header_bytes = json.dumps(header).encode("utf-8") + header_len = struct.pack("= 6 + ): # [x1, y1, x2, y2, score, label] + self.logger.info(f"Stream {stream_idx} - Processing list of detections") + boxes = [[det[0], det[1], det[2], det[3]] for det in output] + scores = [det[4] for det in output] + labels = [int(det[5]) for det in output] + else: + self.logger.error( + f"Stream {stream_idx} - Unrecognized format: {output} (type: {type(output)})" + ) + return + + meta = analytics.add_relation_meta(buf) + if not meta: + self.logger.error( + f"Stream {stream_idx} - Failed to add analytics relation metadata" + ) + return + + self.logger.info(f"Stream {stream_idx} - Adding {len(boxes)} detections") + for i, (box, label, score) in enumerate(zip(boxes, labels, scores)): + x1, y1, x2, y2 = box + qk_string = f"stream_{stream_idx}_label_{label}" + od_mtd = analytics.add_object( + meta, qk_string, x1, y1, x2 - x1, y2 - y1, score + ) + if od_mtd is None: + self.logger.error( + f"Stream {stream_idx} - Failed to add od_mtd for detection {i}" + ) + continue + self.logger.info( + f"Stream {stream_idx} - Added detection {i}: label={qk_string}, x1={x1}, y1={y1}, w={x2-x1}, h={y2-y1}, score={score}" + ) + + attached_meta = analytics.get_relation_meta(buf) + if attached_meta: + count = analytics.relation_length(attached_meta) + self.logger.info( + f"Stream {stream_idx} - Metadata relations after adding: {count}" + ) diff --git a/plugins/python/tasks/ocr.py b/plugins/python/tasks/ocr.py new file mode 100644 index 0000000..b44707a --- /dev/null +++ b/plugins/python/tasks/ocr.py @@ -0,0 +1,84 @@ +# OcrTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic OCR task. + +`OcrTask` runs text recognition and turns the result into an optional output +frame (recognized text drawn on the frame) plus a serialized metadata blob +(JSON with recognized text and regions), using only numpy/cv2 and the engine. +It never touches the buffer; the backend element shell does the frame read, the +frame write, and the metadata attach through the frameio facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.logger - logger + self.draw_text - bool, draw recognized text on the frame (a property) +""" + +from tasks.frame_format import to_format + + +class OcrTask: + """Inference + frame/metadata production, independent of any framework.""" + + def forward(self, frames): + if self.engine: + return self.engine.do_forward(frames) + return None + + def decode(self, frame, result, fmt): + """Draw recognized text on frame and build OCR metadata. + + Returns ``(output_frame_or_None, blob_bytes_or_None)``. ``output_frame`` + is the text-overlaid frame in pixel format ``fmt`` (or None when not + drawing). ``blob_bytes`` is the serialized OCR metadata (or None when + there are no regions), to be appended by the shell. + """ + import json + + import cv2 + + regions = result.get("regions", []) + output = None + + # Draw text overlays before appending read-only metadata memory + if self.draw_text and regions: + overlay = frame.copy() + for region in regions: + x, y, w, h = region["x"], region["y"], region["w"], region["h"] + text = region["text"] + cv2.rectangle(overlay, (x, y), (x + w, y + h), (0, 255, 0), 2) + font_scale = max(0.4, min(w / 300.0, 1.0)) + cv2.putText( + overlay, + text, + (x + 4, y + h - 8), + cv2.FONT_HERSHEY_SIMPLEX, + font_scale, + (0, 255, 0), + 1, + cv2.LINE_AA, + ) + + output = to_format(overlay, fmt) + + # Build OCR results bytes (payload only; header added by shell). + blob = None + if regions: + blob = json.dumps(regions).encode("utf-8") + return output, blob diff --git a/plugins/python/tasks/optical_flow.py b/plugins/python/tasks/optical_flow.py new file mode 100644 index 0000000..9cfc119 --- /dev/null +++ b/plugins/python/tasks/optical_flow.py @@ -0,0 +1,70 @@ +# OpticalFlowTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic optical-flow task. + +`OpticalFlowTask` runs inference on a pair of frames and renders the resulting +flow field as a color overlay, using only numpy/cv2 and the engine. It never +touches the buffer; the backend element shell does the frame read, the frame +write, and the temporal frame-pairing (the previous-frame state) itself. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) +""" + +from tasks.frame_format import to_format + + +class OpticalFlowTask: + """Inference + flow visualization, independent of any framework.""" + + def forward(self, prev_frame, curr_frame): + if self.engine: + return self.engine.do_forward(prev_frame, curr_frame) + return None + + def decode(self, flow, frame, fmt): + """Render flow as a color overlay blended onto ``frame``. + + Returns ``(output_frame_or_None, None)``: the blended frame in pixel + format ``fmt`` for the shell to write, and no metadata blob. + """ + import cv2 + + flow_vis = self._flow_to_color(flow) + blended = cv2.addWeighted(frame, 0.5, flow_vis, 0.5, 0) + output = to_format(blended, fmt) + return output, None + + @staticmethod + def _flow_to_color(flow): + """Convert optical flow (H, W, 2) to an RGB color image using HSV encoding.""" + import cv2 + import numpy as np + + fx, fy = flow[..., 0], flow[..., 1] + mag = np.sqrt(fx**2 + fy**2) + ang = np.arctan2(fy, fx) + + hsv = np.zeros((*flow.shape[:2], 3), dtype=np.uint8) + hsv[..., 0] = ((ang + np.pi) / (2 * np.pi) * 179).astype(np.uint8) + hsv[..., 1] = 255 + mag_norm = mag / (mag.max() + 1e-8) + hsv[..., 2] = (mag_norm * 255).astype(np.uint8) + + return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) diff --git a/plugins/python/tasks/sam.py b/plugins/python/tasks/sam.py new file mode 100644 index 0000000..db77fb7 --- /dev/null +++ b/plugins/python/tasks/sam.py @@ -0,0 +1,98 @@ +# SamTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic segmentation task. + +`SamTask` runs Segment Anything inference and turns the result into an optional +output frame (colored mask overlays) plus a serialized metadata blob (JSON with +mask scores and shapes), using only numpy/cv2 and the engine. It never touches +the buffer; the backend element shell does the frame read, the frame write, and +the metadata attach through the frameio facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.logger - logger + self.visualize - bool, overlay colored masks on the frame (a property) + self.max_masks - int, maximum number of masks (a property) +""" + +from tasks.frame_format import to_format + +# Mask overlay colors (BGR) cycled across detected objects +MASK_COLORS = [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (0, 255, 255), + (255, 0, 255), + (128, 0, 255), + (255, 128, 0), + (0, 128, 255), + (128, 255, 0), +] + + +class SamTask: + """Inference + frame/metadata production, independent of any framework.""" + + def forward(self, frames): + if self.engine: + return self.engine.do_forward(frames, max_masks=self.max_masks) + return None + + def decode(self, frame, result, fmt): + """Overlay masks on frame and build mask metadata. + + Returns ``(output_frame_or_None, blob_bytes_or_None)``. ``output_frame`` + is the mask-overlaid frame in pixel format ``fmt`` (or None when not + visualizing). ``blob_bytes`` is the serialized mask metadata (or None + when there is none), to be appended by the shell. + """ + import json + + import cv2 + import numpy as np + + raw_masks = result.get("raw_masks") + mask_info = result.get("masks", []) + output = None + + # Draw mask overlays before appending read-only metadata memory + if self.visualize and raw_masks is not None: + overlay = frame.copy() + for j in range(min(raw_masks.shape[0], self.max_masks)): + best_idx = 0 + if raw_masks.ndim == 4 and raw_masks.shape[1] > 1: + best_idx = raw_masks[j].sum(axis=(1, 2)).argmax() + mask = raw_masks[j, best_idx] + color = MASK_COLORS[j % len(MASK_COLORS)] + colored = np.zeros_like(overlay) + colored[:] = color + mask_bool = mask > 0.5 + overlay[mask_bool] = cv2.addWeighted( + overlay[mask_bool], 0.5, colored[mask_bool], 0.5, 0 + ) + + output = to_format(overlay, fmt) + + # Build mask metadata bytes (payload only; header added by shell). + blob = None + if mask_info: + blob = json.dumps(mask_info).encode("utf-8") + return output, blob diff --git a/plugins/python/tasks/superres.py b/plugins/python/tasks/superres.py new file mode 100644 index 0000000..a15fe9a --- /dev/null +++ b/plugins/python/tasks/superres.py @@ -0,0 +1,56 @@ +# SuperResTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic super-resolution task. + +`SuperResTask` runs inference and resizes the upscaled output back to the +original frame dimensions, using only numpy/cv2 and the engine. It never +touches the buffer; the backend element shell does the frame read and the +frame write through the frameio facade. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.width - original buffer width (a backend-declared property) + self.height - original buffer height (a backend-declared property) +""" + +from tasks.frame_format import to_format + + +class SuperResTask: + """Inference + frame production, independent of any framework.""" + + def forward(self, frame): + if self.engine: + return self.engine.do_forward(frame) + return None + + def decode(self, upscaled, fmt): + """Resize the upscaled frame back to original dimensions. + + Returns ``(output_frame_or_None, None)``: the resized frame in pixel + format ``fmt`` for the shell to write, and no metadata blob. + """ + import cv2 + + # Resize back to original buffer dimensions for in-place compatibility + resized = cv2.resize( + upscaled, (self.width, self.height), interpolation=cv2.INTER_LANCZOS4 + ) + output = to_format(resized, fmt) + return output, None diff --git a/plugins/python/tasks/vlm.py b/plugins/python/tasks/vlm.py new file mode 100644 index 0000000..e0f087f --- /dev/null +++ b/plugins/python/tasks/vlm.py @@ -0,0 +1,57 @@ +# VlmTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic Vision-Language Model task. + +`VlmTask` runs the VLM forward pass and serializes the generated text into the +payload that follows the GST-VLM: header. It uses only json and the engine; it +never touches the buffer. The backend element shell does the frame read and +appends the payload via the frameio facade, prepending the header. + +Contract expected from the host element: + self.engine - the ML engine (provided by MLEngineMixin) + self.prompt - user prompt (a backend-declared property) + self.system_prompt - optional system prompt (a backend-declared property) + self.max_tokens - max tokens to generate (a backend-declared property) + self.temperature - sampling temperature (a backend-declared property) +""" + +import json + + +class VlmTask: + """Inference + payload serialization, independent of any framework.""" + + def forward(self, frame): + return self.engine.do_forward( + frame, + prompt=self.prompt, + system_prompt=self.system_prompt, + max_tokens=self.max_tokens, + temperature=self.temperature, + ) + + def decode(self, text): + """Serialize a generated caption into ``(None, payload_bytes)``. + + ``payload_bytes`` is the JSON result that follows the GST-VLM: header; + the shell prepends the header when appending the blob. + """ + result = {"text": text} + payload = json.dumps(result).encode("utf-8") + return None, payload diff --git a/plugins/python/tasks/yolo.py b/plugins/python/tasks/yolo.py new file mode 100644 index 0000000..76d6fc8 --- /dev/null +++ b/plugins/python/tasks/yolo.py @@ -0,0 +1,220 @@ +# YoloTask +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Backend-agnostic YOLO task. + +`YoloTask` overrides `do_decode` with the YOLO-specific result handling +(per-instance class names, tracking ids, segmentation) expressed only through +the analytics facade and the engine result. It inherits `do_forward` from +`ObjectDetectorTask`. A backend element shell combines this mixin with its +framework element base (the gst backend uses `BaseObjectDetector`); the shell +supplies the engine wiring, the read-only `engine_name` property, and element +registration. + +See `ObjectDetectorTask` for the host-element contract (self.engine, self.logger, +self.track). `do_decode`'s `buf` is the opaque metadata target. +""" + +from backend import analytics +from tasks.object_detector import ObjectDetectorTask + +COCO_CLASSES = { + 0: "person", + 1: "bicycle", + 2: "car", + 3: "motorcycle", + 4: "airplane", + 5: "bus", + 6: "train", + 7: "truck", + 8: "boat", + 9: "traffic light", + 10: "fire hydrant", + 11: "stop sign", + 12: "parking meter", + 13: "bench", + 14: "bird", + 15: "cat", + 16: "dog", + 17: "horse", + 18: "sheep", + 19: "cow", + 20: "elephant", + 21: "bear", + 22: "zebra", + 23: "giraffe", + 24: "backpack", + 25: "umbrella", + 26: "handbag", + 27: "tie", + 28: "suitcase", + 29: "frisbee", + 30: "skis", + 31: "snowboard", + 32: "sports ball", + 33: "kite", + 34: "baseball bat", + 35: "baseball glove", + 36: "skateboard", + 37: "surfboard", + 38: "tennis racket", + 39: "bottle", + 40: "wine glass", + 41: "cup", + 42: "fork", + 43: "knife", + 44: "spoon", + 45: "bowl", + 46: "banana", + 47: "apple", + 48: "sandwich", + 49: "orange", + 50: "broccoli", + 51: "carrot", + 52: "hot dog", + 53: "pizza", + 54: "donut", + 55: "cake", + 56: "chair", + 57: "couch", + 58: "potted plant", + 59: "bed", + 60: "dining table", + 61: "toilet", + 62: "TV", + 63: "laptop", + 64: "mouse", + 65: "remote", + 66: "keyboard", + 67: "cell phone", + 68: "microwave", + 69: "oven", + 70: "toaster", + 71: "sink", + 72: "refrigerator", + 73: "book", + 74: "clock", + 75: "vase", + 76: "scissors", + 77: "teddy bear", + 78: "hair drier", + 79: "toothbrush", +} + + +class YoloTask(ObjectDetectorTask): + """YOLO detection + tracking + segmentation, independent of any framework.""" + + def do_decode(self, buf, result, stream_idx=0): + self.logger.debug( + f"Decoding YOLO result for buffer {hex(id(buf))}, stream {stream_idx}: {result}" + ) + boxes = result.boxes + masks = None + if not self.engine.track: + masks = result.masks + + if boxes is None or len(boxes) == 0: + self.logger.info("No detections found.") + return + + meta = analytics.add_relation_meta(buf) + if not meta: + self.logger.error( + f"Stream {stream_idx} - Failed to add analytics relation metadata" + ) + return + + self.logger.debug( + f"Stream {stream_idx} - Attaching metadata for {len(boxes)} detections" + ) + for i in range(len(boxes)): + x1, y1, x2, y2 = boxes.xyxy[i] + score = boxes.conf[i] + label = boxes.cls[i] + label_num = label.item() + # Prefer the model's own class names; fall back to COCO for plain yolo. + names = getattr(result, "names", None) or COCO_CLASSES + class_name = names.get(label_num, f"unknown_{label_num}") + + # Use class name for detection, track_id for tracking + if self.engine.track and hasattr(boxes, "id") and boxes.id is not None: + track_id = boxes.id[i] + track_id_int = int(track_id.item()) + qk_string = f"stream_{stream_idx}_id_{track_id_int}" + else: + qk_string = ( + f"stream_{stream_idx}_{class_name}" # No index, just class name + ) + + od_mtd = analytics.add_object( + meta, + qk_string, + x1.item(), + y1.item(), + x2.item() - x1.item(), + y2.item() - y1.item(), + score.item(), + ) + if od_mtd is None: + self.logger.error( + f"Stream {stream_idx} - Failed to add object detection metadata" + ) + continue + self.logger.debug( + f"Stream {stream_idx} - Added od_mtd: label={qk_string}, x1={x1.item()}, y1={y1.item()}, w={x2.item()-x1.item()}, h={y2.item()-y1.item()}, score={score.item()}" + ) + + # Tracking metadata only when track=True + if self.engine.track and hasattr(boxes, "id") and boxes.id is not None: + tracking_mtd = analytics.add_tracking(meta, track_id_int) + if tracking_mtd is None: + self.logger.error( + f"Stream {stream_idx} - Failed to add tracking metadata" + ) + continue + ret = analytics.relate(meta, od_mtd, tracking_mtd) + if not ret: + self.logger.error( + f"Stream {stream_idx} - Failed to relate object detection and tracking metadata" + ) + else: + self.logger.debug( + f"Stream {stream_idx} - Linked od_mtd {od_mtd} to tracking_mtd {tracking_mtd}" + ) + + if masks is not None: + self.add_segmentation_metadata(buf, masks[i], x1, y1, x2, y2) + + attached_meta = analytics.get_relation_meta(buf) + if attached_meta: + count = analytics.relation_length(attached_meta) + self.logger.info( + f"Stream {stream_idx} - Metadata attached to buffer {hex(id(buf))}: {count} relations" + ) + else: + self.logger.error( + f"Stream {stream_idx} - Metadata not attached to buffer after adding" + ) + + def add_segmentation_metadata(self, buf, mask, x1, y1, x2, y2): + """ + Adds segmentation mask metadata to the buffer. + """ + self.logger.info("Adding segmentation mask metadata") + pass diff --git a/plugins/python/tracker.py b/plugins/python/tracker.py index ad0db4d..3dba4df 100644 --- a/plugins/python/tracker.py +++ b/plugins/python/tracker.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -25,14 +26,15 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GstBase, GstAnalytics, GObject, GLib # noqa: E402 + from gi.repository import Gst, GstBase # noqa: E402 from log.logger_factory import LoggerFactory # noqa: E402 + from backend import analytics, GObject # noqa: E402 - VIDEO_SRC_CAPS = Gst.Caps.from_string("video/x-raw") - VIDEO_SINK_CAPS = Gst.Caps.from_string("video/x-raw") + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + VIDEO_SRC_CAPS = Gst.Caps.from_string("video/x-raw") + VIDEO_SINK_CAPS = Gst.Caps.from_string("video/x-raw") except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -130,12 +132,137 @@ def iou_batch(bb_det, bb_trk): class SortTracker: """SORT/ByteTrack multi-object tracker using IoU + Kalman filtering.""" - def __init__(self, max_age=30, min_hits=3, iou_threshold=0.3): + def __init__( + self, + max_age=30, + min_hits=3, + iou_threshold=0.3, + keep_alive=2, + new_track_conf=0.25, + camera_motion=True, + dup_iou=0.8, + ): self.max_age = max_age self.min_hits = min_hits self.iou_threshold = iou_threshold + # ByteTrack-style activation gate: a brand-new track is only started + # from a confident detection. Weak/ghost boxes can still *continue* an + # existing track (matched above) but won't spawn phantom circles. + self.new_track_conf = new_track_conf + # Keep emitting a confirmed track (with its Kalman-predicted box) for up + # to keep_alive frames after a missed detection — bridges flicker so the + # overlay doesn't blink when the detector drops a box for a frame or two. + self.keep_alive = keep_alive + # Camera-motion compensation: estimate the global image shift from the + # tracks that matched, then re-try matching the leftovers with their + # predictions shifted by it. Re-attaches players during a pan instead of + # leaving the old track behind and spawning a duplicate. + self.camera_motion = camera_motion + # Two confirmed tracks overlapping more than this IoU are duplicates; + # the weaker one is dropped (ByteTrack's remove_duplicate_stracks). + self.dup_iou = dup_iou self.trackers = [] + @staticmethod + def _center(bbox): + return (bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0) + + def _estimate_motion(self, matches, predicted, det_bboxes): + """Fit a global 2D similarity transform (translation + uniform scale + + rotation) mapping each matched track's predicted centre to its observed + centre. Returns a callable box->warped-box, or None if it can't be + estimated. Uses RANSAC (via OpenCV) so players moving against the camera + consensus are rejected as outliers; falls back to a robust median + translation if OpenCV is unavailable or the fit is degenerate.""" + import numpy as np + + if len(matches) < 3: + return None + src = np.array( + [self._center(predicted[ti]) for _, ti in matches], dtype=np.float32 + ) + dst = np.array( + [self._center(det_bboxes[di]) for di, _ in matches], dtype=np.float32 + ) + + M = None + try: + import cv2 + + M, _ = cv2.estimateAffinePartial2D( + src, dst, method=cv2.RANSAC, ransacReprojThreshold=5.0 + ) + except Exception: + M = None + + if M is not None: + scale = float(np.hypot(M[0, 0], M[0, 1])) + # Reject implausible fits (e.g. from too few/noisy correspondences). + if 0.5 <= scale <= 2.0: + + def warp(box): + cx, cy = self._center(box) + ncx = M[0, 0] * cx + M[0, 1] * cy + M[0, 2] + ncy = M[1, 0] * cx + M[1, 1] * cy + M[1, 2] + nw, nh = box[2] * scale, box[3] * scale + return np.array([ncx - nw / 2.0, ncy - nh / 2.0, nw, nh]) + + return warp + + # Fallback: robust median translation (pan/tilt only). + delta = dst - src + tx, ty = float(np.median(delta[:, 0])), float(np.median(delta[:, 1])) + if abs(tx) < 1.0 and abs(ty) < 1.0: + return None + return lambda box: np.array([box[0] + tx, box[1] + ty, box[2], box[3]]) + + def _associate(self, det_bboxes, det_idxs, trk_idxs, trk_boxes, detections): + """Hungarian-match a subset of detections to a subset of trackers, + applying updates to matched trackers. Returns list of (det_i, trk_i).""" + from scipy.optimize import linear_sum_assignment + + if not det_idxs or not trk_idxs: + return [] + dets = [det_bboxes[d] for d in det_idxs] + trks = [trk_boxes[t] for t in trk_idxs] + iou_matrix = iou_batch(dets, trks) + if iou_matrix.size == 0: + return [] + cost = 1.0 - iou_matrix + row_ind, col_ind = linear_sum_assignment(cost) + matches = [] + for r, c in zip(row_ind, col_ind): + if iou_matrix[r, c] >= self.iou_threshold: + di, ti = det_idxs[r], trk_idxs[c] + self.trackers[ti].update(detections[di][:4]) + self.trackers[ti].label_quark = detections[di][5] + matches.append((di, ti)) + return matches + + def _suppress_duplicates(self): + """Drop the weaker of any two confirmed tracks sitting on the same box.""" + n = len(self.trackers) + if n < 2: + return + boxes = [t.get_bbox() for t in self.trackers] + iou_matrix = iou_batch(boxes, boxes) + remove = set() + for i in range(n): + if i in remove: + continue + for j in range(i + 1, n): + if j in remove: + continue + if iou_matrix[i, j] > self.dup_iou: + ti, tj = self.trackers[i], self.trackers[j] + # Keep the better track: matched more recently, then more + # hits; drop the other (usually the freshly-spawned dup). + ki = (ti.time_since_update, -ti.hits) + kj = (tj.time_since_update, -tj.hits) + remove.add(j if ki <= kj else i) + if remove: + self.trackers = [t for k, t in enumerate(self.trackers) if k not in remove] + def update(self, detections): """ Update tracks with new detections. @@ -147,54 +274,66 @@ def update(self, detections): list of (track_id, bbox, label_quark) for confirmed tracks """ import numpy as np - from scipy.optimize import linear_sum_assignment # Predict new locations for existing tracks - predicted = [] to_remove = [] for i, trk in enumerate(self.trackers): - pred = trk.predict() - if np.any(np.isnan(pred)): + if np.any(np.isnan(trk.predict())): to_remove.append(i) - else: - predicted.append(pred) for i in reversed(to_remove): self.trackers.pop(i) - # Build cost matrix using IoU det_bboxes = [d[:4] for d in detections] if len(detections) > 0 else [] - iou_matrix = iou_batch(det_bboxes, predicted) - cost_matrix = 1.0 - iou_matrix - - # Hungarian assignment - matched_det = set() - matched_trk = set() - if cost_matrix.size > 0: - row_ind, col_ind = linear_sum_assignment(cost_matrix) - for r, c in zip(row_ind, col_ind): - if iou_matrix[r, c] >= self.iou_threshold: - matched_det.add(r) - matched_trk.add(c) - self.trackers[c].update(detections[r][:4]) - # Store latest label quark on tracker - self.trackers[c].label_quark = detections[r][5] - - # Create new tracks for unmatched detections - for d_idx in range(len(detections)): + n_det = len(det_bboxes) + n_trk = len(self.trackers) + # Predicted box per tracker, captured before any update this frame. + predicted = [self.trackers[i].get_bbox() for i in range(n_trk)] + + # 1) First association on the raw predictions. + matches = self._associate( + det_bboxes, list(range(n_det)), list(range(n_trk)), predicted, detections + ) + matched_det = {di for di, _ in matches} + matched_trk = {ti for _, ti in matches} + + # 2) Camera-motion compensation: fit a global image transform (pan, zoom + # and rotation) from the tracks that matched, apply it to the leftover + # predictions, and re-match. This recovers tracks during camera moves + # instead of leaving them behind and spawning duplicates. + if self.camera_motion: + warp = self._estimate_motion(matches, predicted, det_bboxes) + if warp is not None: + rem_trk = [i for i in range(n_trk) if i not in matched_trk] + rem_det = [d for d in range(n_det) if d not in matched_det] + if rem_trk and rem_det: + shifted = {i: warp(predicted[i]) for i in rem_trk} + m2 = self._associate( + det_bboxes, rem_det, rem_trk, shifted, detections + ) + matched_det.update(di for di, _ in m2) + + # Create new tracks for unmatched detections, but only from confident + # ones (ByteTrack activation gate) so weak/ghost boxes don't start a + # phantom track that gets drawn as a stray circle. + for d_idx in range(n_det): if d_idx not in matched_det: + if detections[d_idx][4] < self.new_track_conf: + continue trk = KalmanBoxTracker(detections[d_idx][:4]) trk.label_quark = detections[d_idx][5] self.trackers.append(trk) - # Remove dead tracks + # Remove dead tracks, then drop duplicate tracks sitting on one object. self.trackers = [ t for t in self.trackers if t.time_since_update <= self.max_age ] + self._suppress_duplicates() - # Return confirmed tracks + # Return confirmed tracks, including ones that missed a detection this + # frame (predicted box) for up to keep_alive frames — prevents flicker. results = [] for trk in self.trackers: - if trk.hits >= self.min_hits and trk.time_since_update == 0: + if trk.hits >= self.min_hits and trk.time_since_update <= self.keep_alive: results.append((trk.id, trk.get_bbox(), trk.label_quark)) return results @@ -215,20 +354,21 @@ class TrackerTransform(GstBase.BaseTransform): "Aaron Boxer ", ) - src_template = Gst.PadTemplate.new( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - VIDEO_SRC_CAPS.copy(), - ) - - sink_template = Gst.PadTemplate.new( - "sink", - Gst.PadDirection.SINK, - Gst.PadPresence.ALWAYS, - VIDEO_SINK_CAPS.copy(), - ) - __gsttemplates__ = (src_template, sink_template) + if backend.BACKEND == "gst": + src_template = Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + VIDEO_SRC_CAPS.copy(), + ) + + sink_template = Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + VIDEO_SINK_CAPS.copy(), + ) + __gsttemplates__ = (src_template, sink_template) tracker_type = GObject.Property( type=str, @@ -268,6 +408,50 @@ class TrackerTransform(GstBase.BaseTransform): flags=GObject.ParamFlags.READWRITE, ) + keep_alive = GObject.Property( + type=int, + default=2, + minimum=0, + maximum=1000, + nick="Keep Alive", + blurb="Frames to keep emitting a confirmed track (Kalman-predicted box) " + "after a missed detection; bridges flicker (0 = only matched frames)", + flags=GObject.ParamFlags.READWRITE, + ) + + new_track_confidence = GObject.Property( + type=float, + default=0.25, + minimum=0.0, + maximum=1.0, + nick="New Track Confidence", + blurb="Minimum detection confidence to START a new track (ByteTrack " + "activation gate); weak boxes still continue existing tracks but " + "won't spawn phantom/duplicate circles", + flags=GObject.ParamFlags.READWRITE, + ) + + camera_motion = GObject.Property( + type=bool, + default=True, + nick="Camera Motion Compensation", + blurb="Estimate the global image shift from matched tracks and re-match " + "leftovers shifted by it, so a panning camera re-attaches players " + "instead of leaving the old track behind and spawning a duplicate", + flags=GObject.ParamFlags.READWRITE, + ) + + duplicate_iou = GObject.Property( + type=float, + default=0.8, + minimum=0.0, + maximum=1.0, + nick="Duplicate IoU", + blurb="Two confirmed tracks overlapping more than this are treated as " + "duplicates and the weaker one is dropped", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) @@ -281,25 +465,24 @@ def _ensure_tracker(self): max_age=self.max_age, min_hits=self.min_hits, iou_threshold=self.iou_threshold, + keep_alive=self.keep_alive, + new_track_conf=self.new_track_confidence, + camera_motion=self.camera_motion, + dup_iou=self.duplicate_iou, ) return self._tracker def _read_detections(self, buf): - """Extract detections from upstream GstAnalytics od_mtd.""" + """Extract detections from upstream object-detection metadata.""" detections = [] - meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + meta = analytics.get_relation_meta(buf) if not meta: return detections - count = GstAnalytics.relation_get_length(meta) - for index in range(count): - ret, od_mtd = meta.get_od_mtd(index) - if not ret or od_mtd is None: - continue - label_quark = od_mtd.get_obj_type() - presence, x, y, w, h, score = od_mtd.get_location() - if presence: - detections.append([x, y, w, h, score, label_quark]) + for obj in analytics.read_objects(meta): + detections.append( + [obj["x"], obj["y"], obj["w"], obj["h"], obj["score"], obj["label"]] + ) return detections def do_transform_ip(self, buf): @@ -315,20 +498,20 @@ def do_transform_ip(self, buf): tracked = tracker.update(detections) # Attach tracking results as new analytics metadata - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) + meta = analytics.add_relation_meta(buf) if not meta: self.logger.error( "Failed to add analytics relation metadata for tracking" ) return Gst.FlowReturn.ERROR - for track_id, bbox, label_quark in tracked: - label_str = GLib.quark_to_string(label_quark) + for track_id, bbox, label_str in tracked: track_label = f"{label_str}_id_{track_id}" - qk = GLib.quark_from_string(track_label) x, y, w, h = bbox - ret, od_mtd = meta.add_od_mtd(qk, int(x), int(y), int(w), int(h), 1.0) - if not ret: + od_mtd = analytics.add_object( + meta, track_label, int(x), int(y), int(w), int(h), 1.0 + ) + if od_mtd is None: self.logger.error( f"Failed to add tracking od_mtd for track {track_id}" ) @@ -351,6 +534,14 @@ def do_get_property(self, prop): return self.min_hits elif prop.name == "iou-threshold": return self.iou_threshold + elif prop.name == "keep-alive": + return self.keep_alive + elif prop.name == "new-track-confidence": + return self.new_track_confidence + elif prop.name == "camera-motion": + return self.camera_motion + elif prop.name == "duplicate-iou": + return self.duplicate_iou else: raise AttributeError(f"Unknown property {prop.name}") @@ -367,14 +558,27 @@ def do_set_property(self, prop, value): elif prop.name == "iou-threshold": self.iou_threshold = value self._tracker = None + elif prop.name == "keep-alive": + self.keep_alive = value + self._tracker = None + elif prop.name == "new-track-confidence": + self.new_track_confidence = value + self._tracker = None + elif prop.name == "camera-motion": + self.camera_motion = value + self._tracker = None + elif prop.name == "duplicate-iou": + self.duplicate_iou = value + self._tracker = None else: raise AttributeError(f"Unknown property {prop.name}") -if CAN_REGISTER_ELEMENT: - GObject.type_register(TrackerTransform) - __gstelementfactory__ = ("pyml_tracker", Gst.Rank.NONE, TrackerTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_tracker", TrackerTransform + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_tracker' element will not be registered because required modules are missing." ) diff --git a/plugins/python/vad.py b/plugins/python/vad.py index 1d9757c..f78ea97 100644 --- a/plugins/python/vad.py +++ b/plugins/python/vad.py @@ -17,6 +17,7 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: @@ -27,7 +28,8 @@ gi.require_version("Gst", "1.0") gi.require_version("GstBase", "1.0") - from gi.repository import Gst, GObject, GstBase + from gi.repository import Gst, GstBase + from backend import GObject from log.logger_factory import LoggerFactory @@ -72,17 +74,19 @@ class VoiceActivityDetector(GstBase.BaseTransform): "Aaron Boxer ", ) - AUDIO_CAPS = Gst.Caps.from_string( - "audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1" - ) + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + AUDIO_CAPS = Gst.Caps.from_string( + "audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1" + ) - sink_template = Gst.PadTemplate.new( - "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, AUDIO_CAPS - ) - src_template = Gst.PadTemplate.new( - "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, AUDIO_CAPS - ) - __gsttemplates__ = (sink_template, src_template) + sink_template = Gst.PadTemplate.new( + "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, AUDIO_CAPS + ) + src_template = Gst.PadTemplate.new( + "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, AUDIO_CAPS + ) + __gsttemplates__ = (sink_template, src_template) threshold = GObject.Property( type=float, @@ -187,10 +191,11 @@ def do_transform_ip(self, buf): return Gst.FlowReturn.OK -if CAN_REGISTER_ELEMENT: - GObject.type_register(VoiceActivityDetector) - __gstelementfactory__ = ("pyml_vad", Gst.Rank.NONE, VoiceActivityDetector) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_vad", VoiceActivityDetector + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_vad' element will not be registered because required modules are missing." ) diff --git a/plugins/python/video_transform.py b/plugins/python/video_transform.py index 0159a01..2c24ec7 100644 --- a/plugins/python/video_transform.py +++ b/plugins/python/video_transform.py @@ -16,38 +16,11 @@ # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. -import gi +"""Compatibility shim. `VideoTransform` now lives behind the pluggable backend +in `backend/` (see `backend/__init__.py`). Leaf plugins keep importing it from +here; the active backend is chosen by the PYML_BACKEND environment variable. +""" -gi.require_version("Gst", "1.0") -gi.require_version("GstBase", "1.0") -gi.require_version("GstVideo", "1.0") -from gi.repository import Gst # noqa: E402 +from backend import VideoTransform -from base_transform import BaseTransform # noqa: E402 - - -class VideoTransform(BaseTransform): - """ - GStreamer element for video transformation using a PyTorch model. - """ - - # Define VIDEO_CAPS to support multiple formats - VIDEO_CAPS = Gst.Caps.from_string( - "video/x-raw,format=(string){ RGB, RGBA, ARGB, BGRA, ABGR }," - "width=(int)[1,2147483647],height=(int)[1,2147483647]" - ) - __gsttemplates__ = ( - Gst.PadTemplate.new( - "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, VIDEO_CAPS - ), - Gst.PadTemplate.new( - "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, VIDEO_CAPS - ), - ) - - def do_set_caps(self, incaps, outcaps): - struct = incaps.get_structure(0) - self.width = struct.get_int("width").value - self.height = struct.get_int("height").value - - return True +__all__ = ["VideoTransform"] diff --git a/plugins/python/vlm.py b/plugins/python/vlm.py index 1cac59b..bf71976 100644 --- a/plugins/python/vlm.py +++ b/plugins/python/vlm.py @@ -17,22 +17,15 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import json - - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - from gi.repository import Gst, GObject - from video_transform import VideoTransform - from utils.format_converter import FormatConverter - from engine.pytorch_engine import PyTorchEngine + from engine.vlm_engine import VlmEngine from engine.engine_factory import EngineFactory + from backend import frameio, GObject + from tasks.vlm import VlmTask except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -42,126 +35,7 @@ VLM_META_HEADER = b"GST-VLM:" -class VlmEngine(PyTorchEngine): - """ - PyTorch engine for Vision-Language Models. - - Supports HuggingFace VLM model IDs via AutoProcessor + AutoModelForVision2Seq: - llava-hf/llava-1.5-7b-hf - Qwen/Qwen2-VL-7B-Instruct - OpenGVLab/InternVL2-8B - """ - - def __init__(self): - super().__init__() - self.processor = None - - def do_load_model(self, model_name, **kwargs): - try: - import torch - from transformers import AutoProcessor, AutoModelForVision2Seq - - self.processor = AutoProcessor.from_pretrained(model_name) - self.model = AutoModelForVision2Seq.from_pretrained( - model_name, - torch_dtype=torch.float16, - device_map=self.device, - ) - self.model.eval() - self.logger.info(f"VLM model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load VLM model '{model_name}': {e}") - - def do_forward( - self, - frame, - prompt="Describe this image in detail.", - system_prompt=None, - max_tokens=256, - temperature=0.7, - ): - """ - Run VLM inference on a single video frame. - - Args: - frame: numpy RGB array (H, W, 3). - prompt: user prompt text. - system_prompt: optional system prompt. - max_tokens: maximum tokens to generate. - temperature: sampling temperature. - - Returns: - Generated text string, or None on failure. - """ - import numpy as np - from PIL import Image - - try: - pil_img = Image.fromarray(frame.astype(np.uint8)) - text = self.do_generate( - pil_img, prompt, system_prompt, max_tokens, temperature - ) - return text - except Exception as e: - self.logger.error(f"VLM inference error: {e}") - return None - - def do_generate(self, image, prompt, system_prompt, max_tokens, temperature): - """ - Apply chat template, process image + text, and generate a response. - - Args: - image: PIL Image. - prompt: user prompt text. - system_prompt: optional system prompt. - max_tokens: maximum new tokens. - temperature: sampling temperature. - - Returns: - Generated text string. - """ - import torch - - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append( - { - "role": "user", - "content": [ - {"type": "image"}, - {"type": "text", "text": prompt}, - ], - } - ) - - # Apply chat template if the processor supports it - if hasattr(self.processor, "apply_chat_template"): - text_input = self.processor.apply_chat_template( - messages, add_generation_prompt=True - ) - else: - text_input = prompt - - inputs = self.processor(text=text_input, images=image, return_tensors="pt") - inputs = {k: v.to(self.model.device) for k, v in inputs.items()} - - with torch.no_grad(): - output_ids = self.model.generate( - **inputs, - max_new_tokens=max_tokens, - temperature=temperature, - do_sample=temperature > 0, - ) - - # Decode only the newly generated tokens - input_len = inputs.get("input_ids", torch.tensor([])).shape[-1] - generated = output_ids[0][input_len:] - text = self.processor.decode(generated, skip_special_tokens=True) - return text.strip() - - -class VlmTransform(VideoTransform): +class VlmTransform(VideoTransform, VlmTask): """ GStreamer element for Vision-Language Model inference on video frames. @@ -227,7 +101,6 @@ def __init__(self): super().__init__() self.mgr.engine_name = "pyml_vlm_engine" EngineFactory.register(self.mgr.engine_name, VlmEngine) - self.format_converter = FormatConverter() self._frame_count = 0 @GObject.Property(type=str) @@ -239,68 +112,37 @@ def engine_name(self): def engine_name(self, value): raise ValueError("'engine_name' is read-only for pyml_vlm") - def do_transform_ip(self, buf): - try: - self._frame_count += 1 - if self.frame_stride > 1 and (self._frame_count % self.frame_stride) != 1: - return Gst.FlowReturn.OK - - if self.engine is None: - return Gst.FlowReturn.OK - - success, map_info = buf.map(Gst.MapFlags.READ) - if not success: - self.logger.error("Failed to map video buffer for reading") - return Gst.FlowReturn.ERROR + def process_frames(self, frames, num_sources, fmt, target): + """Caption the frame and append the payload, skipping strided-out frames.""" + self._frame_count += 1 + if self.frame_stride > 1 and (self._frame_count % self.frame_stride) != 1: + return - try: - frame = self.format_converter.to_rgb( - map_info.data, self.width, self.height, buf, self.sinkpad - ) - finally: - buf.unmap(map_info) + if self.engine is None: + return - if frame is None: - return Gst.FlowReturn.ERROR + frame = frames[0] if num_sources > 1 else frames - text = self.engine.do_forward( - frame, - prompt=self.prompt, - system_prompt=self.system_prompt, - max_tokens=self.max_tokens, - temperature=self.temperature, - ) + text = self.forward(frame) - if text is None: - return Gst.FlowReturn.OK + if text is None: + return - result = {"text": text} - # Append VLM response as a JSON memory chunk. - # Use new_allocate+fill: PyGI hides the maxsize arg in new_wrapped - # (it derives it from data length), so passing it explicitly shifts - # all subsequent args and causes a GI assertion crash. - meta_bytes = VLM_META_HEADER + json.dumps(result).encode("utf-8") - tmp = Gst.Buffer.new_allocate(None, len(meta_bytes), None) - tmp.fill(0, meta_bytes) - buf.append_memory(tmp.get_memory(0)) + # Portable task: serialize the VLM response payload. + _, payload = self.decode(text) + # Append VLM response as a JSON memory chunk. + frameio.write_result(target, None, payload, VLM_META_HEADER) - self.logger.debug( - f"VLM response ({len(text)} chars): {text[:80]}..." - if len(text) > 80 - else f"VLM response: {text}" - ) - - return Gst.FlowReturn.OK - - except Exception as e: - self.logger.error(f"VLM transform error: {e}") - return Gst.FlowReturn.ERROR + self.logger.debug( + f"VLM response ({len(text)} chars): {text[:80]}..." + if len(text) > 80 + else f"VLM response: {text}" + ) -if CAN_REGISTER_ELEMENT: - GObject.type_register(VlmTransform) - __gstelementfactory__ = ("pyml_vlm", Gst.Rank.NONE, VlmTransform) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_vlm", VlmTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_vlm' element will not be registered because required modules are missing." ) diff --git a/plugins/python/whisperlive.py b/plugins/python/whisperlive.py index 9f55a16..c5f12a9 100644 --- a/plugins/python/whisperlive.py +++ b/plugins/python/whisperlive.py @@ -17,15 +17,11 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GObject", "2.0") - from gi.repository import Gst, GObject, GstBase # noqa: E402 + from backend import GObject from base_transcribe import BaseTranscribe except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -33,19 +29,14 @@ f"The 'pyml_whisperlive' element will not be available. Error: {e}" ) -TTS_SAMPLE_RATE = 24000 -model_ref = "collabora/whisperspeech:s2a-q4-base-en+pl.model" +if backend.BACKEND == "gst": + import gi + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 -OCAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=TTS_SAMPLE_RATE, - channels=1, - ) -) +model_ref = "collabora/whisperspeech:s2a-q4-base-en+pl.model" class WhisperLive(BaseTranscribe): @@ -56,13 +47,17 @@ class WhisperLive(BaseTranscribe): "Aaron Boxer ", ) - __gsttemplates__ = Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OCAPS, - GstBase.AggregatorPad.__gtype__, - ) + OUTPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=24000,channels=1" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ) llm_model_name = GObject.Property( type=str, @@ -206,19 +201,14 @@ def do_process_text(self, transcript): else: audio_np = audio_np.T # Transpose the numpy array if it's not 1D - duration = len(audio_np) / TTS_SAMPLE_RATE * Gst.SECOND - buffer = Gst.Buffer.new_wrapped(audio_np.tobytes()) - - buffer.pts = Gst.CLOCK_TIME_NONE - buffer.duration = duration + return audio_np.tobytes() - return buffer - -if CAN_REGISTER_ELEMENT: - GObject.type_register(WhisperLive) - __gstelementfactory__ = ("pyml_whisperlive", Gst.Rank.NONE, WhisperLive) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_whisperlive", WhisperLive + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_whisperlive' element will not be registered because required modules were missing." ) diff --git a/plugins/python/whisperspeechtts.py b/plugins/python/whisperspeechtts.py index ca9c929..3ba5004 100644 --- a/plugins/python/whisperspeechtts.py +++ b/plugins/python/whisperspeechtts.py @@ -17,14 +17,10 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - from gi.repository import Gst, GObject, GstBase # noqa: E402 from base_tts import BaseTts except ImportError as e: CAN_REGISTER_ELEMENT = False @@ -32,20 +28,17 @@ f"The 'pyml_whisperspeechtts' element will not be available. Error: {e}" ) +if backend.BACKEND == "gst": + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + from gi.repository import Gst, GstBase # noqa: E402 + TTS_SAMPLE_RATE = 24000 model_ref = "collabora/whisperspeech:s2a-q4-base-en+pl.model" -OCAPS = Gst.Caps( - Gst.Structure( - "audio/x-raw", - format="S16LE", - layout="interleaved", - rate=TTS_SAMPLE_RATE, - channels=1, - ) -) - class WhisperSpeechTTS(BaseTts): __gstmetadata__ = ( @@ -55,15 +48,20 @@ class WhisperSpeechTTS(BaseTts): "Aaron Boxer ", ) - __gsttemplates__ = ( - Gst.PadTemplate.new_with_gtype( - "src", - Gst.PadDirection.SRC, - Gst.PadPresence.ALWAYS, - OCAPS, - GstBase.AggregatorPad.__gtype__, - ), - ) + # the rate has to match TTS_SAMPLE_RATE, which the element reports downstream + OUTPUT_CAPS = "audio/x-raw,format=S16LE,layout=interleaved,rate=24000,channels=1" + + # Building a Gst object needs Gst.init, which only the gst backend calls. + if backend.BACKEND == "gst": + __gsttemplates__ = ( + Gst.PadTemplate.new_with_gtype( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + Gst.Caps.from_string(OUTPUT_CAPS), + GstBase.AggregatorPad.__gtype__, + ), + ) def do_load_model(self): from whisperspeech.pipeline import Pipeline @@ -103,10 +101,11 @@ def do_get_sample_rate(self): return TTS_SAMPLE_RATE -if CAN_REGISTER_ELEMENT: - GObject.type_register(WhisperSpeechTTS) - __gstelementfactory__ = ("pyml_whisperspeechtts", Gst.Rank.NONE, WhisperSpeechTTS) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_whisperspeechtts", WhisperSpeechTTS + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_whisperspeechtts' element will not be registered because required modules were missing." ) diff --git a/plugins/python/whispertranscribe.py b/plugins/python/whispertranscribe.py index c70a94c..2d6f551 100644 --- a/plugins/python/whispertranscribe.py +++ b/plugins/python/whispertranscribe.py @@ -17,17 +17,13 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GObject", "2.0") - from gi.repository import Gst, GObject # noqa: E402 + from backend import GObject from base_transcribe import BaseTranscribe - from engine.pytorch_engine import PyTorchEngine + from engine.whisper_engine import WhisperEngine from engine.engine_factory import EngineFactory except ImportError as e: @@ -37,21 +33,6 @@ ) -class WhisperEngine(PyTorchEngine): - def do_load_model(self, model_name, **kwargs): - from faster_whisper import WhisperModel - - if not model_name: - return - compute_type = "float16" if self.device.startswith("cuda") else "int8" - self.logger.info( - f"Loading Whisper model on device: {self.device} with compute_type: {compute_type}" - ) - self.model = WhisperModel( - model_name, device=self.device, compute_type=compute_type - ) - - class WhisperTranscribe(BaseTranscribe): __gstmetadata__ = ( "WhisperTranscribe", @@ -89,10 +70,11 @@ def do_transcribe(self, audio_data, task): return result -if CAN_REGISTER_ELEMENT: - GObject.type_register(WhisperTranscribe) - __gstelementfactory__ = ("pyml_whispertranscribe", Gst.Rank.NONE, WhisperTranscribe) -else: +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element( + "pyml_whispertranscribe", WhisperTranscribe + ) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_whispertranscribe' element will not be registered because base_transcribe module is missing." ) diff --git a/plugins/python/yolo.py b/plugins/python/yolo.py index a34deb6..517b158 100644 --- a/plugins/python/yolo.py +++ b/plugins/python/yolo.py @@ -17,185 +17,28 @@ # Boston, MA 02110-1301, USA. from log.global_logger import GlobalLogger +from backend import GObject +import backend CAN_REGISTER_ELEMENT = True try: - import gi - - gi.require_version("Gst", "1.0") - gi.require_version("GstBase", "1.0") - gi.require_version("GstVideo", "1.0") - gi.require_version("GstAnalytics", "1.0") - gi.require_version("GLib", "2.0") - from gi.repository import Gst, GObject, GstAnalytics, GLib # noqa: E402 from base_objectdetector import BaseObjectDetector + from tasks.yolo import YoloTask - import time - from engine.pytorch_engine import PyTorchEngine + from engine.yolo_engine import YoloEngine from engine.engine_factory import EngineFactory except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning(f"The 'yolo' element will not be available. Error {e}") -COCO_CLASSES = { - 0: "person", - 1: "bicycle", - 2: "car", - 3: "motorcycle", - 4: "airplane", - 5: "bus", - 6: "train", - 7: "truck", - 8: "boat", - 9: "traffic light", - 10: "fire hydrant", - 11: "stop sign", - 12: "parking meter", - 13: "bench", - 14: "bird", - 15: "cat", - 16: "dog", - 17: "horse", - 18: "sheep", - 19: "cow", - 20: "elephant", - 21: "bear", - 22: "zebra", - 23: "giraffe", - 24: "backpack", - 25: "umbrella", - 26: "handbag", - 27: "tie", - 28: "suitcase", - 29: "frisbee", - 30: "skis", - 31: "snowboard", - 32: "sports ball", - 33: "kite", - 34: "baseball bat", - 35: "baseball glove", - 36: "skateboard", - 37: "surfboard", - 38: "tennis racket", - 39: "bottle", - 40: "wine glass", - 41: "cup", - 42: "fork", - 43: "knife", - 44: "spoon", - 45: "bowl", - 46: "banana", - 47: "apple", - 48: "sandwich", - 49: "orange", - 50: "broccoli", - 51: "carrot", - 52: "hot dog", - 53: "pizza", - 54: "donut", - 55: "cake", - 56: "chair", - 57: "couch", - 58: "potted plant", - 59: "bed", - 60: "dining table", - 61: "toilet", - 62: "TV", - 63: "laptop", - 64: "mouse", - 65: "remote", - 66: "keyboard", - 67: "cell phone", - 68: "microwave", - 69: "oven", - 70: "toaster", - 71: "sink", - 72: "refrigerator", - 73: "book", - 74: "clock", - 75: "vase", - 76: "scissors", - 77: "teddy bear", - 78: "hair drier", - 79: "toothbrush", -} - - -class YoloEngine(PyTorchEngine): - def do_load_model(self, model_name, **kwargs): - try: - from ultralytics import YOLO - - self.model = YOLO(f"{model_name}.pt") - self.execute_with_stream(lambda: self.model.to(self.device)) - self.logger.info(f"YOLO model '{model_name}' loaded on {self.device}") - except Exception as e: - raise ValueError(f"Failed to load YOLO model '{model_name}'. Error: {e}") - - def do_forward(self, frames): - import numpy as np - - is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 - writable_frames = np.array(frames, copy=True) - batch_size = writable_frames.shape[0] if is_batch else 1 - - model = self.get_model() - if model is None: - self.logger.error("Model is not loaded.") - return None if not is_batch else [None] * batch_size - try: - start_pre = time.time() - img_list = ( - [ - writable_frames[i] if is_batch else writable_frames - for i in range(batch_size) - ] - if is_batch - else [writable_frames] - ) - self.logger.debug( - f"Input shape: {writable_frames.shape}, min={writable_frames.min()}, max={writable_frames.max()}" - ) - end_pre = time.time() - - if self.track: - # Ensure tracker persists across batches - results = self.execute_with_stream( - lambda: model.track( - source=img_list, - persist=True, - imgsz=640, - conf=0.1, - verbose=True, - tracker="botsort.yaml", - ) - ) - else: - results = self.execute_with_stream( - lambda: model(img_list, imgsz=640, conf=0.1, verbose=True) - ) - end_inf = time.time() - - if results is None or (isinstance(results, list) and not results): - self.logger.warning("Inference returned None or empty list.") - return None if not is_batch else [None] * batch_size - - self.logger.info( - f"Preprocessing: {(end_pre - start_pre)*1000:.2f} ms, Inference: {(end_inf - end_pre)*1000:.2f} ms for {batch_size} frames" - ) - return results[0] if not is_batch else results - - except Exception as e: - self.logger.error(f"Error during inference: {e}") - return None if not is_batch else [None] * batch_size - - -class YOLOTransform(BaseObjectDetector): +class YOLOTransform(BaseObjectDetector, YoloTask): """ - GStreamer element for YOLO model inference on video frames - (detection, segmentation, and tracking). + GStreamer element shell for YOLO model inference on video frames + (detection, segmentation, and tracking). The result handling (do_decode) + is inherited from the backend-agnostic YoloTask; this class supplies the + engine wiring, the read-only engine_name property, and registration. """ __gstmetadata__ = ( @@ -205,6 +48,36 @@ class YOLOTransform(BaseObjectDetector): "Aaron Boxer ", ) + confidence = GObject.Property( + type=float, + default=0.1, + minimum=0.0, + maximum=1.0, + nick="Confidence Threshold", + blurb="Minimum detection confidence (matches football_analyzer); kept " + "low on purpose so the tracker can use weak boxes to continue tracks " + "-- the tracker's new-track-confidence gates phantom tracks", + flags=GObject.ParamFlags.READWRITE, + ) + nms_iou = GObject.Property( + type=float, + default=0.7, + minimum=0.0, + maximum=1.0, + nick="NMS IoU", + blurb="NMS IoU threshold (matches football_analyzer's default); lower " + "suppresses more overlap but can also drop genuinely close players", + flags=GObject.ParamFlags.READWRITE, + ) + agnostic_nms = GObject.Property( + type=bool, + default=False, + nick="Class-Agnostic NMS", + blurb="Suppress overlapping boxes across classes too; off by default " + "(like football_analyzer) so two close players aren't merged", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.mgr.engine_name = "pyml_yolo_engine" @@ -222,112 +95,21 @@ def engine_name(self, value): "The 'engine_name' property cannot be set in this derived class." ) - def do_decode(self, buf, result, stream_idx=0): - self.logger.debug( - f"Decoding YOLO result for buffer {hex(id(buf))}, stream {stream_idx}: {result}" - ) - boxes = result.boxes - masks = None - if not self.engine.track: - masks = result.masks - - if boxes is None or len(boxes) == 0: - self.logger.info("No detections found.") - return - - meta = GstAnalytics.buffer_add_analytics_relation_meta(buf) - if not meta: - self.logger.error( - f"Stream {stream_idx} - Failed to add analytics relation metadata" - ) - return - - self.logger.debug( - f"Stream {stream_idx} - Attaching metadata for {len(boxes)} detections" - ) - for i in range(len(boxes)): - x1, y1, x2, y2 = boxes.xyxy[i] - score = boxes.conf[i] - label = boxes.cls[i] - label_num = label.item() - class_name = COCO_CLASSES.get(label_num, f"unknown_{label_num}") - - # Use class name for detection, track_id for tracking - if self.engine.track and hasattr(boxes, "id") and boxes.id is not None: - track_id = boxes.id[i] - track_id_int = int(track_id.item()) - qk_string = f"stream_{stream_idx}_id_{track_id_int}" - else: - qk_string = ( - f"stream_{stream_idx}_{class_name}" # No index, just class name - ) - - qk = GLib.quark_from_string(qk_string) - ret, od_mtd = meta.add_od_mtd( - qk, - x1.item(), - y1.item(), - x2.item() - x1.item(), - y2.item() - y1.item(), - score.item(), - ) - if not ret: - self.logger.error( - f"Stream {stream_idx} - Failed to add object detection metadata" - ) - continue - self.logger.debug( - f"Stream {stream_idx} - Added od_mtd: label={qk_string}, x1={x1.item()}, y1={y1.item()}, w={x2.item()-x1.item()}, h={y2.item()-y1.item()}, score={score.item()}" - ) - - # Tracking metadata only when track=True - if self.engine.track and hasattr(boxes, "id") and boxes.id is not None: - ret, tracking_mtd = meta.add_tracking_mtd( - track_id_int, Gst.util_get_timestamp() - ) - if not ret: - self.logger.error( - f"Stream {stream_idx} - Failed to add tracking metadata" - ) - continue - ret = GstAnalytics.RelationMeta.set_relation( - meta, GstAnalytics.RelTypes.RELATE_TO, od_mtd.id, tracking_mtd.id - ) - if not ret: - self.logger.error( - f"Stream {stream_idx} - Failed to relate object detection and tracking metadata" - ) - else: - self.logger.debug( - f"Stream {stream_idx} - Linked od_mtd {od_mtd.id} to tracking_mtd {tracking_mtd.id}" - ) - - if masks is not None: - self.add_segmentation_metadata(buf, masks[i], x1, y1, x2, y2) - - attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) - if attached_meta: - count = GstAnalytics.relation_get_length(attached_meta) - self.logger.info( - f"Stream {stream_idx} - Metadata attached to buffer {hex(id(buf))}: {count} relations" - ) - else: - self.logger.error( - f"Stream {stream_idx} - Metadata not attached to buffer after adding" - ) - - def add_segmentation_metadata(self, buf, mask, x1, y1, x2, y2): - """ - Adds segmentation mask metadata to the buffer. - """ - self.logger.info("Adding segmentation mask metadata") - pass - - -if CAN_REGISTER_ELEMENT: - GObject.type_register(YOLOTransform) - __gstelementfactory__ = ("pyml_yolo", Gst.Rank.NONE, YOLOTransform) -else: + def do_forward(self, frames): + # Push NMS/confidence knobs to the engine before it runs the model. + if self.engine: + self.engine.conf = self.confidence + self.engine.iou = self.nms_iou + self.engine.agnostic_nms = self.agnostic_nms + return super().do_forward(frames) + + +# The class is backend-agnostic: under g2g the host imports this module and +# instantiates YOLOTransform directly, so no GObject registration applies. +# GStreamer factory registration runs only under the gst backend. +if CAN_REGISTER_ELEMENT and backend.BACKEND == "gst": + __gstelementfactory__ = backend.register_gst_element("pyml_yolo", YOLOTransform) +elif not CAN_REGISTER_ELEMENT: GlobalLogger().warning( "The 'pyml_yolo' element will not be registered because required modules are missing." ) diff --git a/pyml-launch.py b/pyml-launch.py new file mode 100755 index 0000000..efc9bb2 --- /dev/null +++ b/pyml-launch.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Run a pipeline on whichever backend `PYML_BACKEND` selects, from the checkout. + +See `plugins/python/pyml_launch.py` for what the two backends spell differently. +Re-runs itself under the repo's venv when started from another interpreter: the +launcher hands its own site directories to GStreamer and to g2g, neither of +which has a venv of its own. +""" + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +VENV = ROOT / ".venv" +VENV_PYTHON = ( + VENV / "Scripts" / "python.exe" if os.name == "nt" else VENV / "bin" / "python" +) + + +def main(): + # sys.prefix, not the executable path: a venv's `python` is a symlink to the + # base interpreter, so resolving it would say we are already inside. + if VENV_PYTHON.exists() and Path(sys.prefix) != VENV: + return subprocess.call([str(VENV_PYTHON), __file__, *sys.argv[1:]]) + sys.path.insert(0, str(ROOT / "plugins" / "python")) + import pyml_launch + + return pyml_launch.main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index fc4cb7c..c0d80fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ all = [ # Optional [dependency-groups] test = [ - "pytest>=7.0", + "pytest>=9.0.3", # Add other test tools ] dev = [ @@ -113,8 +113,18 @@ dev = [ # Add linting, etc., e.g., "ruff" ] +[tool.pytest.ini_options] +markers = [ + "serial: run this test on its own, it needs the GPU or a capture device to itself", +] + +[project.scripts] +pyml-launch = "pyml_launch:main" + [tool.setuptools] include-package-data = true +py-modules = ["pyml_launch"] +package-dir = {"" = "plugins/python"} [tool.setuptools.packages.find] where = ["plugins/python"] \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5d853ec --- /dev/null +++ b/renovate.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":enableVulnerabilityAlerts", + "security:minimumReleaseAgePypi" + ], + "timezone": "Etc/UTC", + "schedule": ["* 0-3 * * 1"], + "osvVulnerabilityAlerts": true, + "vulnerabilityAlerts": { + "enabled": true, + "labels": ["security"] + }, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["* 0-3 * * 1"] + }, + "packageRules": [ + { + "matchPackageNames": ["torch", "torchvision", "torchaudio"], + "groupName": "pytorch" + } + ] +} diff --git a/tests/test_g2g_backend.py b/tests/test_g2g_backend.py new file mode 100644 index 0000000..850d424 --- /dev/null +++ b/tests/test_g2g_backend.py @@ -0,0 +1,943 @@ +"""Unit tests for the g2g element backend (`PYML_BACKEND=g2g`). + +These exercise the backend with no GStreamer present: the backend selection, the +`GObject` / `FlowReturn` shims, the `G2gFrameIO` buffer round-trip, the +`G2gAnalyticsBackend` mapping onto a flat sink, and an end-to-end `g2g_process` +on a `VideoTransform` subclass. The g2g host's `FrameBuffer` (a writable +buffer-protocol view) and `MetaSink` (write-only staging) are stubbed with a +`bytearray` and a recording object, so no Rust host is needed. + +The payload tests at the end drive one text element through both backend +drivers, so they need GStreamer for the gst half and skip without it. +""" + +import importlib +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugins" / "python" + +# Select the g2g backend before importing `backend`, and make the plugin package +# importable. +os.environ["PYML_BACKEND"] = "g2g" +sys.path.insert(0, str(PLUGIN_DIR)) + +import backend # noqa: E402 +from backend import ( + GObject, + FlowReturn, + frameio, + analytics, + BaseAggregator, + VideoTransform, +) # noqa: E402 + + +class StubMetaSink: + """Stand-in for the host's write-only `g2g.MetaSink`. + + Like the real sink, every `add_*` stages one record into a single list and + returns its index, which is the handle `relate` takes. + """ + + def __init__(self): + self.staged = [] + self.relations = [] + self.class_names = None + self.emitted = [] + self.emitted_durations = [] + + def emit(self, payload, duration_ns=None): + self.emitted.append(payload) + self.emitted_durations.append(duration_ns) + + def set_class_names(self, names): + self.class_names = list(names) + + def _stage(self, record): + self.staged.append(record) + return len(self.staged) - 1 + + def add_object(self, label, x, y, w, h, score): + return self._stage(("object", label, x, y, w, h, score)) + + def add_classification(self, label, score): + return self._stage(("classification", label, score)) + + def add_blob(self, header, payload): + return self._stage(("blob", header, payload)) + + def add_tracking(self, object_id): + return self._stage(("tracking", object_id)) + + def relate(self, src, dst): + self.relations.append((src, dst)) + + def _of_kind(self, kind): + return [record[1:] for record in self.staged if record[0] == kind] + + @property + def objects(self): + return self._of_kind("object") + + @property + def blobs(self): + return self._of_kind("blob") + + +#: What the shadowed `gi` raises, so a warning naming it means something wanted it. +NO_PYGOBJECT = "no pygobject" + +#: The families that run hosted on g2g, so none of them may need GStreamer. +HOSTED_ELEMENT_MODULES = [ + "base_translate", + "base_transcribe", + "base_llm", + "base_separate", + "base_tts", + "base_caption", + "mariantranslate", + "whispertranscribe", + "whisperlive", + "llm", + "demucs", + "sepformer", + "coquitts", + "whisperspeechtts", + "caption_phi", + "caption_qwen", +] + + +def test_hosted_elements_import_under_g2g_with_no_pygobject(tmp_path): + """A fresh interpreter, with `gi` shadowed by one that refuses to import. + + In-process the check cannot be honest: once any test here has initialised + Gst, these imports succeed whether or not the element guards its Gst + construction. Shadowing `gi` rather than just checking stderr also makes an + element that reaches for GStreamer fail here, instead of quietly depending + on a pygobject that happens to be installed. + """ + shadow = tmp_path / "gi" + shadow.mkdir() + (shadow / "__init__.py").write_text(f'raise ImportError("{NO_PYGOBJECT}")\n') + + result = subprocess.run( + [sys.executable, "-c", "import " + ", ".join(HOSTED_ELEMENT_MODULES)], + env={ + **os.environ, + "PYML_BACKEND": "g2g", + "PYTHONPATH": os.pathsep.join([str(tmp_path), str(PLUGIN_DIR)]), + }, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + # The shadow raises with this text, so it appears only if something imported gi. + assert ( + NO_PYGOBJECT not in result.stderr + ), "an element reached for GStreamer at import time" + + +def test_every_element_module_imports_under_g2g_without_gst_init(): + """The same check over the whole plugin directory, so a new element cannot + quietly reintroduce the crash. + + Kept apart from the payload check above because a module here may warn about + a dependency it cannot find, which is not what this is looking for. A pad + template built without `Gst.init` segfaults, so an element that forgets the + backend guard takes down whatever process imports it. + """ + pytest.importorskip("gi", reason="the element modules import it at module scope") + + modules = sorted(p.stem for p in PLUGIN_DIR.glob("*.py")) + + result = subprocess.run( + [sys.executable, "-c", "import " + ", ".join(modules)], + env={**os.environ, "PYML_BACKEND": "g2g", "PYTHONPATH": str(PLUGIN_DIR)}, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_backend_selected_is_g2g(): + assert backend.BACKEND == "g2g" + assert FlowReturn.OK == 0 + assert FlowReturn.ERROR != FlowReturn.OK + + +def test_gobject_property_shim_decorator_and_attribute_forms(): + class Widget: + @GObject.Property(type=str) + def name(self): + return getattr(self, "_n", "default") + + @name.setter + def name(self, value): + self._n = value.upper() + + size = GObject.Property(type=int, default=7, nick="Size", blurb="px") + + w = Widget() + assert w.name == "default" # getter default + w.name = "yolo" + assert w.name == "YOLO" # setter ran + assert w.size == 7 # attribute-form default + w.size = 42 + assert w.size == 42 + + +def test_a_property_set_from_a_pipeline_line_arrives_as_its_declared_type(): + """The host cannot know a hosted class's property types, so it forwards the + text a pipeline line carries and the declaration here converts it.""" + + class Widget: + count = GObject.Property(type=int, default=1) + enabled = GObject.Property(type=bool, default=False) + ratio = GObject.Property(type=float, default=0.0) + name = GObject.Property(type=str, default="") + + w = Widget() + w.count, w.enabled, w.ratio, w.name = "4", "TRUE", "0.5", "3" + assert (w.count, w.enabled, w.ratio, w.name) == (4, True, 0.5, "3") + + w.enabled = "no" + assert w.enabled is False + w.count = 9 # already typed, set from Python + assert w.count == 9 + + with pytest.raises(ValueError, match="enabled"): + w.enabled = "maybe" + + +def test_an_element_lists_the_properties_it_declares(): + """The host checks a pipeline against this, so a knob the element has must be + in it and a name it does not have must not.""" + pytest.importorskip("gi", reason="the video transform needs it on the gst backend") + pytest.importorskip("torch", reason="the depth engine imports it") + from depth import DepthTransform + + declared = DepthTransform().g2g_properties() + + assert "colormap" in declared, "the element's own knob" + assert "batch_size" in declared, "one it inherits from the shared tunables" + assert "speaker" not in declared, "a detector has no speaker" + assert len(declared) == len(set(declared)), "an overridden property listed twice" + + +def test_frameio_read_write_round_trip(): + width, height = 4, 3 + buf = bytearray(width * height * 3) # RGB, writable buffer-protocol object + sink = StubMetaSink() + frameio.bind(sink, "RGB") + + frame, num_sources, fmt = frameio.read_frames(buf, None, width, height) + assert num_sources == 1 and fmt == "RGB" + assert frame.shape == (height, width, 3) + + frameio.write_frame(buf, np.full((height, width, 3), 200, dtype=np.uint8)) + assert all(b == 200 for b in buf), "write_frame must update the buffer in place" + + frameio.append_blob(buf, "tag", b"\x01\x02") + assert sink.blobs == [("tag", b"\x01\x02")] + + +def test_analytics_maps_onto_flat_sink(): + sink = StubMetaSink() + analytics.bind(sink) + + meta = analytics.add_relation_meta(buf=None) + assert meta is not None + assert analytics.get_relation_meta(None) is meta + + # String labels intern to stable u32 ids (quark), reused across calls. + analytics.add_object(meta, "person", 1, 2, 3, 4, 0.9) + analytics.add_object(meta, "person", 5, 6, 7, 8, 0.8) + analytics.add_object(meta, "handbag", 0, 0, 1, 1, 0.5) + + assert analytics.relation_length(meta) == 3 + labels = [o[0] for o in sink.objects] + assert labels[0] == labels[1], "same string -> same id" + assert labels[2] != labels[0], "different string -> different id" + assert sink.objects[0][5] == 0.9 + + +def test_class_names_are_published_so_a_consumer_can_name_a_label(): + sink = StubMetaSink() + analytics.bind(sink) + meta = analytics.add_relation_meta(buf=None) + + person = analytics.add_object(meta, "person", 1, 2, 3, 4, 0.9) + handbag = analytics.add_object(meta, "handbag", 0, 0, 1, 1, 0.5) + + # The table is indexed by the label id staged on the detection, so a + # consumer holding only the id can look the name up. + names = sink.class_names + assert names is not None, "the sink was never sent a name table" + assert names[sink.staged[person][1]] == "person" + assert names[sink.staged[handbag][1]] == "handbag" + + +def test_class_names_are_resent_for_each_frames_sink(): + """Each frame gets a fresh sink, so a name interned on an earlier frame has + to be published again rather than assumed already known.""" + first = StubMetaSink() + analytics.bind(first) + staged = analytics.add_object( + analytics.add_relation_meta(None), "person", 1, 2, 3, 4, 0.9 + ) + assert first.class_names[first.staged[staged][1]] == "person" + + second = StubMetaSink() + analytics.bind(second) + staged = analytics.add_object( + analytics.add_relation_meta(None), "person", 5, 6, 7, 8, 0.8 + ) + assert second.class_names is not None, "the second sink was sent no table" + assert second.class_names[second.staged[staged][1]] == "person" + + +def test_tracking_relates_to_its_detection(): + sink = StubMetaSink() + analytics.bind(sink) + meta = analytics.add_relation_meta(buf=None) + + od = analytics.add_object(meta, "person", 1, 2, 3, 4, 0.9) + track = analytics.add_tracking(meta, 77) + assert analytics.relate(meta, od, track) is True + + # Handles are the sink's own staging indices, so the relation names the + # detection and the tracking record that were actually staged. + assert sink.staged[od][0] == "object" + assert sink.staged[track] == ("tracking", 77) + assert sink.relations == [(od, track)] + + +def test_video_transform_g2g_process_end_to_end(): + """A VideoTransform subclass inverts the frame and stages one detection, + driven exactly as the host drives it: instance.g2g_process(buf, w, h, fmt, sink).""" + + class InvertAndDetect(VideoTransform): + def process_frames(self, frames, num_sources, fmt, target): + assert num_sources == 1 + inverted = 255 - frames + frameio.write_frame(target, inverted) + meta = analytics.add_relation_meta(target) + analytics.add_object(meta, "person", 0, 0, 10, 10, 0.99) + + width, height = 8, 8 + buf = bytearray([10] * (width * height * 3)) + sink = StubMetaSink() + + elem = InvertAndDetect() + # EngineManager defaults to the pytorch engine, so the first frame would try + # to load a model. This test covers the frame plumbing, not inference. + elem.engine_name = None + ret = elem.g2g_process(buf, width, height, "RGB", sink) + + assert ret is None + assert all(b == 245 for b in buf), "frame inverted in place (255 - 10)" + assert len(sink.objects) == 1 + assert sink.objects[0][5] == 0.99 + assert elem.width == width and elem.height == height + + +def test_aggregator_drives_the_same_hook_a_transform_fills_in(): + """The N-source case spells the same on both backends: one `process_frames` + taking (H, W, C) for a single source and (N, H, W, C) for several.""" + seen = [] + + class Batching(BaseAggregator): + def process_frames(self, frames, num_sources, fmt, target): + seen.append((frames.shape, num_sources, fmt)) + + width, height = 4, 3 + elem = Batching() + elem.engine_name = None + + buffers = [bytearray([n] * (width * height * 3)) for n in (1, 2)] + elem.g2g_process_batch(buffers, width, height, "RGB", StubMetaSink()) + elem.g2g_process_batch(buffers[:1], width, height, "RGB", StubMetaSink()) + + assert seen == [ + ((2, height, width, 3), 2, "RGB"), + ((height, width, 3), 1, "RGB"), + ] + + +def test_g2g_caption_stages_the_caption_on_the_frame(): + """The caption family runs on the shared per-frame seam, so it works with no + text pad and no GStreamer: the caption is staged as a classification.""" + from base_caption import BaseCaption + + class FakeCaption(BaseCaption): + def forward(self, frames): + return "a cat on a mat" + + leaf = FakeCaption() + leaf.mgr.engine_name = None # the fake above is the model + sink = StubMetaSink() + width, height = 4, 3 + + leaf.g2g_process(bytearray(width * height * 4), width, height, "RGBA", sink) + + captions = [record for record in sink.staged if record[0] == "classification"] + assert len(captions) == 1, "the caption was not staged" + assert sink.class_names[captions[0][1]] == "a cat on a mat" + + +def test_a_packed_frame_is_reduced_to_rgb_in_channel_order(): + from backend.g2g.frameio import as_rgb + + pixel = np.array([[[10, 20, 30, 40]]], dtype=np.uint8) + + assert as_rgb(pixel, "RGBA").tolist() == [[[10, 20, 30]]] + assert as_rgb(pixel, "BGRA").tolist() == [[[30, 20, 10]]] + assert as_rgb(pixel, "ARGB").tolist() == [[[20, 30, 40]]] + assert as_rgb(pixel, "ABGR").tolist() == [[[40, 30, 20]]] + + rgb = np.array([[[10, 20, 30]]], dtype=np.uint8) + assert as_rgb(rgb, "RGB") is rgb, "an RGB frame is handed over untouched" + assert as_rgb(rgb, "BGR").tolist() == [[[30, 20, 10]]] + + +def test_two_elements_on_their_own_threads_keep_their_own_sinks(): + """The host runs one thread per element, so a binding made on one thread must + not redirect what another thread stages.""" + import threading + + sinks = {} + staged = {} + start = threading.Barrier(2) + bound = threading.Barrier(2) + + def stage(name, label): + sinks[name] = StubMetaSink() + start.wait() + analytics.bind(sinks[name]) + frameio.bind(sinks[name], "RGB") + # Both threads have bound before either stages anything, so a shared + # binding would send both records to whichever bound last. + bound.wait() + analytics.add_object(analytics.add_relation_meta(None), label, 0, 0, 1, 1, 1.0) + frameio.append_blob(bytearray(4), "tag", label.encode()) + staged[name] = sinks[name].staged + + threads = [ + threading.Thread(target=stage, args=(name, label)) + for name, label in (("first", "person"), ("second", "handbag")) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(staged["first"]) == 2, "the first element lost a record to the second" + assert len(staged["second"]) == 2 + assert sinks["first"].blobs == [("tag", b"person")] + assert sinks["second"].blobs == [("tag", b"handbag")] + + +class RecordingLogger: + """Stands in for the element's logger, keeping what it was told.""" + + def __init__(self): + self.warnings = [] + + def warning(self, message): + self.warnings.append(message) + + def info(self, message): + pass + + def error(self, message): + pass + + +def gst(): + pytest.importorskip("gi", reason="the gst driver and the pad templates need it") + import gi + + gi.require_version("Gst", "1.0") + from gi.repository import Gst + + Gst.init(None) + return Gst + + +def gst_payload_driver(): + """The gst backend's driver half, to mix into a leaf. + + This process selected the g2g backend, so a leaf's element base is the g2g + one. Adding the driver gives a single instance both backends' entry points, + which is what the seam claims: the same element runs under either. + """ + gst() + from backend.gst.aggregator import PayloadDriver + + return PayloadDriver + + +CHUNKED_OUTPUTS = [b"one", b"two", b"three"] + + +def chunking_leaf(): + """A payload element that answers one input buffer with several outputs.""" + driver = gst_payload_driver() + + class ChunkingLeaf(BaseAggregator, driver): + def process_payload(self, payload): + return list(CHUNKED_OUTPUTS) + + leaf = ChunkingLeaf() + leaf.engine_name = None + leaf.logger = RecordingLogger() + return leaf + + +def translate_leaf(translate_text): + """A real `BaseTranslate` whose model is the given text -> text function. + + Importing the element needs GStreamer even under the g2g backend, since the + family still declares its pad templates with Gst types. + """ + driver = gst_payload_driver() + from base_translate import BaseTranslate + + class FakeTranslate(BaseTranslate, driver): + def do_translate_text(self, text): + return translate_text(text) + + leaf = FakeTranslate() + leaf.engine_name = None # nothing to load: the fake above is the model + return leaf + + +class StubSrcPad: + """Stands in for the element's src pad, for the family that pushes straight + out of it instead of through the aggregator.""" + + def __init__(self, pushed): + self._pushed = pushed + self.push_count = 0 + + def push(self, buf): + self._pushed.append(buf) + self.push_count += 1 + return 0 + + +def drive_gst_payload(leaf, payload, pts=1000, duration=500): + """Run the gst driver over one input buffer, returning what it sent. + + Both send routes are stubbed, the aggregator's `finish_buffer` and the src + pad, so `leaf.srcpad.push_count` says which one the element took. + """ + Gst = gst() + from backend.gst.aggregator import BaseAggregator as GstBaseAggregator + + pushed = [] + leaf.finish_buffer = pushed.append + leaf.srcpad = StubSrcPad(pushed) + + inbuf = Gst.Buffer.new_allocate(None, len(payload), None) + inbuf.fill(0, payload) + inbuf.pts = pts + inbuf.duration = duration + + ret = GstBaseAggregator.do_process(leaf, inbuf) + return ret, pushed + + +def buffer_bytes(buf): + Gst = gst() + success, map_info = buf.map(Gst.MapFlags.READ) + assert success + data = bytes(map_info.data) + buf.unmap(map_info) + return data + + +def test_g2g_payload_driver_emits_the_translated_bytes(): + leaf = translate_leaf(lambda text: "hello" if text == "hola" else "") + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"hola")], "text/x-raw,format=utf8", sink) + + assert sink.emitted == [b"hello"] + assert sink.emitted_durations == [None], "text keeps the input buffer's timing" + + +def test_g2g_payload_driver_emits_nothing_when_the_element_has_no_output(): + leaf = translate_leaf(lambda text: "") + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"hola")], "text/x-raw,format=utf8", sink) + + assert sink.emitted == [], "an empty result must not reach the host" + + +def test_g2g_payload_driver_emits_every_payload(): + leaf = chunking_leaf() + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"in")], "audio/x-raw", sink) + + assert sink.emitted == CHUNKED_OUTPUTS + assert leaf.logger.warnings == [] + + +def test_gst_payload_driver_pushes_the_translated_buffer(): + Gst = gst() + leaf = translate_leaf(lambda text: "hello" if text == "hola" else "") + + ret, pushed = drive_gst_payload(leaf, b"hola") + + assert ret == Gst.FlowReturn.OK + assert len(pushed) == 1 + assert buffer_bytes(pushed[0]) == b"hello" + assert pushed[0].pts == 1000 and pushed[0].duration == 500 + + +def test_gst_payload_driver_pushes_nothing_when_the_element_has_no_output(): + Gst = gst() + leaf = translate_leaf(lambda text: "") + + ret, pushed = drive_gst_payload(leaf, b"hola") + + assert ret == Gst.FlowReturn.OK + assert pushed == [] + + +def test_gst_payload_driver_pushes_every_payload_as_its_own_buffer(): + leaf = chunking_leaf() + + _, pushed = drive_gst_payload(leaf, b"in") + + assert [buffer_bytes(buf) for buf in pushed] == CHUNKED_OUTPUTS + assert leaf.logger.warnings == [] + + +VAD_CHUNK_SAMPLES = 2400 # 150 ms at 16 kHz, so two silent chunks end a clip + + +class Segment: + """One piece of a transcript, as the Whisper models hand it back.""" + + def __init__(self, text): + self.text = text + + +def stub_vad(monkeypatch): + """Stand in for the optional VAD package the transcribe family builds in its + constructor. This one calls any non-zero sample speech, which lets a test + write silence and speech as buffer contents.""" + import types + + class SpeechIsNonZero: + def chunk_samples(self): + return VAD_CHUNK_SAMPLES + + def process_chunk(self, chunk): + return 1.0 if any(chunk) else 0.0 + + module = types.ModuleType("pysilero_vad") + module.SileroVoiceActivityDetector = SpeechIsNonZero + monkeypatch.setitem(sys.modules, "pysilero_vad", module) + + +def transcribe_leaf(monkeypatch, transcript): + """A real `BaseTranscribe` with a scripted VAD and transcriber.""" + driver = gst_payload_driver() + stub_vad(monkeypatch) + + from base_transcribe import BaseTranscribe + + class FakeTranscribe(BaseTranscribe, driver): + def do_transcribe(self, audio_data, task): + return [Segment(word) for word in transcript.split()] + + leaf = FakeTranscribe() + leaf.engine_name = None + return leaf + + +def speech(chunks=1): + return np.full(VAD_CHUNK_SAMPLES * chunks, 1000, dtype=np.int16).tobytes() + + +def silence(chunks=1): + return np.zeros(VAD_CHUNK_SAMPLES * chunks, dtype=np.int16).tobytes() + + +def separate_leaf(sample_rate=4): + """A real `BaseSeparate` whose model returns the audio it was given.""" + driver = gst_payload_driver() + from base_separate import BaseSeparate + + class PassThroughSeparate(BaseSeparate, driver): + SAMPLE_RATE = sample_rate + + def do_separate(self, audio_data): + return audio_data + + leaf = PassThroughSeparate() + leaf.engine_name = None + leaf.streaming = True # a one second chunk, so four samples at this rate + return leaf + + +class FakeEngine: + def do_generate(self, text, system_prompt=None): + return "answered " + text + + +def llm_leaf(): + """A real `BaseLlm` whose engine is a canned generator.""" + driver = gst_payload_driver() + from base_llm import BaseLlm + + class FakeLlm(BaseLlm, driver): + @property + def engine(self): + return self._fake_engine + + def get_tokenizer(self): + return "tokenizer" + + def get_model(self): + return "model" + + leaf = FakeLlm() + leaf._fake_engine = FakeEngine() + leaf.engine_name = None + return leaf + + +PAYLOAD_FAMILY_BASES = [ + ("base_translate", "BaseTranslate"), + ("base_transcribe", "BaseTranscribe"), + ("base_llm", "BaseLlm"), + ("base_separate", "BaseSeparate"), + ("base_tts", "BaseTts"), +] + + +def declared_properties(cls): + """Every property the class declares or inherits, mapped to its owner.""" + return { + name: klass.__name__ + for klass in reversed(cls.__mro__) + for name, value in vars(klass).items() + if isinstance(value, GObject.Property) + } + + +@pytest.mark.parametrize("module_name,class_name", PAYLOAD_FAMILY_BASES) +def test_every_declared_property_reads_before_anything_sets_it( + monkeypatch, module_name, class_name +): + """A freshly built element has to answer every property it declares. + + Nothing applies a declared default: GObject keeps it in the pspec and never + routes it through the setter, so the constructor is the only thing that can + create the backing attribute a custom getter reads. + """ + gst() + stub_vad(monkeypatch) + element = getattr(importlib.import_module(module_name), class_name)() + + unreadable = [] + for name, owner in sorted(declared_properties(type(element)).items()): + try: + getattr(element, name) + except AttributeError as exception: + unreadable.append(f"{owner}.{name}: {exception}") + + assert unreadable == [] + + +def test_g2g_transcribe_emits_only_once_the_clip_ends(monkeypatch): + leaf = transcribe_leaf(monkeypatch, "hello world") + sink = StubMetaSink() + caps = "audio/x-raw,format=S16LE,rate=16000,channels=1" + + leaf.g2g_process_payload([bytearray(speech())], caps, sink) + assert sink.emitted == [], "speech is still being accumulated" + + leaf.g2g_process_payload([bytearray(silence(3))], caps, sink) + assert sink.emitted == [b"hello world"], "the silence should end the clip" + + +def test_gst_transcribe_pushes_only_once_the_clip_ends(monkeypatch): + leaf = transcribe_leaf(monkeypatch, "hello world") + + _, pushed = drive_gst_payload(leaf, speech()) + assert pushed == [], "speech is still being accumulated" + + _, pushed = drive_gst_payload(leaf, silence(3)) + assert [buffer_bytes(buf) for buf in pushed] == [b"hello world"] + assert pushed[0].pts == 1000 and pushed[0].duration == 500 + + +def test_gst_transcribe_pushes_nothing_for_a_buffer_below_one_vad_chunk(monkeypatch): + leaf = transcribe_leaf(monkeypatch, "hello world") + + _, pushed = drive_gst_payload(leaf, np.zeros(8, dtype=np.int16).tobytes()) + + assert pushed == [] + + +def test_gst_separate_pushes_one_buffer_per_whole_chunk(): + leaf = separate_leaf() + samples = np.arange(1, 11, dtype=np.int16) # ten samples, so two chunks of four + + _, pushed = drive_gst_payload(leaf, samples.tobytes()) + + assert [buffer_bytes(buf) for buf in pushed] == [ + samples[:4].tobytes(), + samples[4:8].tobytes(), + ] + assert len(leaf.clip_buffer) == 2, "the remainder waits for the next buffer" + + +def test_g2g_separate_emits_one_buffer_per_whole_chunk(): + leaf = separate_leaf() + leaf.logger = RecordingLogger() + sink = StubMetaSink() + samples = np.arange(1, 11, dtype=np.int16) + + leaf.g2g_process_payload([bytearray(samples.tobytes())], "audio/x-raw", sink) + + assert sink.emitted == [samples[:4].tobytes(), samples[4:8].tobytes()] + assert leaf.logger.warnings == [] + + +def test_g2g_llm_emits_the_generated_text(): + leaf = llm_leaf() + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"question")], "text/x-raw,format=utf8", sink) + + assert sink.emitted == [b"answered question"] + + +def test_gst_llm_pushes_out_of_the_src_pad_rather_than_the_aggregator(): + Gst = gst() + leaf = llm_leaf() + + ret, pushed = drive_gst_payload(leaf, b"question") + + assert ret == Gst.FlowReturn.OK + assert [buffer_bytes(buf) for buf in pushed] == [b"answered question"] + assert leaf.srcpad.push_count == 1, "this family has never used finish_buffer" + assert pushed[0].pts == 1000 and pushed[0].duration == 500 + + +TTS_SAMPLE_RATE = 22050 +TTS_SAMPLES = 8000 + + +def tts_leaf(): + """A real `BaseTts` whose voice is a ramp of the right sample count.""" + driver = gst_payload_driver() + from base_tts import BaseTts + + class FakeTts(BaseTts, driver): + def do_load_model(self): + pass + + def do_generate_speech(self, transcript): + return np.linspace(-0.5, 0.5, TTS_SAMPLES, dtype=np.float32) + + def do_get_sample_rate(self): + return TTS_SAMPLE_RATE + + leaf = FakeTts() + leaf.engine_name = None + return leaf + + +def expected_tts_duration_ns(): + return int(TTS_SAMPLES / TTS_SAMPLE_RATE * 1_000_000_000) + + +def test_gst_tts_stamps_its_own_duration_and_no_presentation_time(): + Gst = gst() + leaf = tts_leaf() + + ret, pushed = drive_gst_payload(leaf, b"speak this") + + assert ret == Gst.FlowReturn.OK + assert len(pushed) == 1 + assert len(buffer_bytes(pushed[0])) == TTS_SAMPLES * 2, "S16LE, one channel" + assert ( + pushed[0].pts == Gst.CLOCK_TIME_NONE + ), "the text buffer's pts is not the audio's" + assert pushed[0].dts == Gst.CLOCK_TIME_NONE + assert pushed[0].duration == expected_tts_duration_ns() + assert leaf.srcpad.push_count == 1, "this family has never used finish_buffer" + + +def test_g2g_tts_emits_the_duration_the_audio_actually_runs_for(): + leaf = tts_leaf() + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"speak this")], "text/x-raw,format=utf8", sink) + + assert len(sink.emitted) == 1 + assert len(sink.emitted[0]) == TTS_SAMPLES * 2 + assert sink.emitted_durations == [expected_tts_duration_ns()] + + +def test_tts_streaming_speaks_each_chunk_of_text_separately(): + """Streaming splits the text into 20 character chunks, one payload each.""" + gst() + leaf = tts_leaf() + leaf.streaming = True + + _, pushed = drive_gst_payload(leaf, b"x" * 45) + + assert len(pushed) == 3, "45 characters is three chunks" + assert {buf.duration for buf in pushed} == {expected_tts_duration_ns()} + + +def test_g2g_tts_streaming_emits_each_chunk_of_text_separately(): + leaf = tts_leaf() + leaf.streaming = True + sink = StubMetaSink() + + leaf.g2g_process_payload([bytearray(b"x" * 45)], "text/x-raw,format=utf8", sink) + + assert len(sink.emitted) == 3, "45 characters is three chunks" + assert sink.emitted_durations == [expected_tts_duration_ns()] * 3 + + +def test_gst_driver_keeps_the_input_timing_including_dts(): + """base_llm timestamped its output with the input's dts; the driver, which + now builds that buffer, has to keep doing it.""" + Gst = gst() + leaf = llm_leaf() + + pushed = [] + leaf.finish_buffer = pushed.append + leaf.srcpad = StubSrcPad(pushed) + inbuf = Gst.Buffer.new_allocate(None, len(b"question"), None) + inbuf.fill(0, b"question") + inbuf.pts = 90 + inbuf.dts = 80 + inbuf.duration = 70 + + from backend.gst.aggregator import BaseAggregator as GstBaseAggregator + + GstBaseAggregator.do_process(leaf, inbuf) + + assert (pushed[0].pts, pushed[0].dts, pushed[0].duration) == (90, 80, 70) diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index ef13558..29e0be7 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -1,5 +1,6 @@ import subprocess import os +import signal import re import pytest from pathlib import Path @@ -12,8 +13,28 @@ BASE_DIR = Path(__file__).resolve().parent.parent LOG_DIR = BASE_DIR / "tests" / "logs" -# Check if gst-launch-1.0 is available -if not shutil.which("gst-launch-1.0"): +# Seconds to let a pipeline run. Overridable because a local LLM generating a +# few hundred tokens takes minutes, not seconds. +PIPELINE_TIMEOUT = int(os.environ.get("PIPELINE_TIMEOUT", "30")) + +# Only these mean the pipeline broke. Warnings are not fatal: plugins unrelated +# to the pipeline warn during setup and would fail a run that went fine. +FATAL_LOG_PATTERNS = ( + re.compile(r"^ERROR:.*", re.MULTILINE), + re.compile(r"^WARNING: erroneous pipeline.*", re.MULTILINE), + re.compile(r"^\S+ +\S+ +\S+ +ERROR +python .*", re.MULTILINE), + # g2g's own failures: the launcher's parse / run errors and the rewrite's. + re.compile(r"^(?:parse|pipeline) error:.*", re.MULTILINE), + re.compile(r"^pyml-launch: (?:no|unknown) .*", re.MULTILINE), +) + +BACKEND = os.environ.get("PYML_BACKEND", "gst").lower() + +# The launcher the README examples name. Runs from a tmp dir, so it is spelled +# absolute here. +LAUNCHER = f"python {BASE_DIR / 'pyml-launch.py'}" + +if BACKEND == "gst" and not shutil.which("gst-launch-1.0"): raise RuntimeError("gst-launch-1.0 not found in PATH. Please install GStreamer.") @@ -26,9 +47,9 @@ def get_pipelines_from_readme(): with open(readme_path, "r") as f: content = f.read() - # Match gst-launch-1.0 commands, accounting for Markdown backticks + # Match pyml-launch commands, accounting for Markdown backticks pipeline_pattern = ( - r"(?:`)?\s*(GST_DEBUG=\d+\s+gst-launch-1\.0\s+.*?)(?:`)?(?=\n\n|\n\s*\n|$)" + r"(?:`)?\s*(python pyml-launch\.py\s+.*?)(?:`)?(?=\n\n|\n\s*\n|$)" ) pipelines = re.findall(pipeline_pattern, content, re.DOTALL) @@ -37,13 +58,16 @@ def get_pipelines_from_readme(): pipeline = pipeline.strip().strip("`") print(f"Raw pipeline after stripping: {pipeline}") - if not pipeline.startswith(("GST_DEBUG=", "gst-launch-1.0")): + if not pipeline.startswith("python pyml-launch.py"): print(f"Skipping invalid pipeline: {pipeline}") continue + pipeline = pipeline.replace("python pyml-launch.py", LAUNCHER, 1) parts = pipeline.split("!") - modified = False + # A `filesrc` run has no equivalent cap: these mp4s carry `moov` at the + # end, so bounding the source by bytes leaves the decoder with no index. + # Those pipelines run until PIPELINE_TIMEOUT instead. for i, part in enumerate(parts): part_clean = part.strip() if "videotestsrc" in part_clean: @@ -52,20 +76,8 @@ def get_pipelines_from_readme(): parts[i] = f"{part_clean} num-buffers=100" else: parts[i] = re.sub(r"num-buffers=\d+", "num-buffers=100", part_clean) - modified = True break - if not modified: - for i, part in enumerate(parts): - part_clean = part.strip() - if "filesrc" in part_clean: - parts.insert(i + 1, "queue max-size-buffers=100 leaky=upstream") - modified = True - break - - if not modified: - print(f"Warning: No filesrc or videotestsrc found in pipeline: {pipeline}") - modified_pipeline = " ! ".join(parts).strip() print(f"Modified pipeline: {modified_pipeline}") modified_pipelines.append(modified_pipeline) @@ -75,14 +87,51 @@ def get_pipelines_from_readme(): PIPELINES = get_pipelines_from_readme() +def end_process_group(process): + """Stop the pipeline and everything it started. + + `shell=True` makes the shell the direct child, so signalling the process + alone leaves the launcher and its window behind. + """ + try: + group = os.getpgid(process.pid) + except ProcessLookupError: + return + os.killpg(group, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(group, signal.SIGKILL) + + +def absolutize_project_inputs(pipeline): + """Point a pipeline's relative input paths at the project directory. + + Lets the pipeline run from the test's tmp dir so its output lands there. + Only values that already name a file are rewritten, so output paths and + caps strings are left alone. + """ + + def rewrite(match): + key, value = match.group(1), match.group(2) + candidate = BASE_DIR / value.strip('"') + return f"{key}={candidate}" if candidate.is_file() else match.group(0) + + return re.sub(r"([\w-]+)=([^\s!]+)", rewrite, pipeline) + + @pytest.mark.serial @pytest.mark.parametrize("pipeline", PIPELINES, ids=lambda p: p) def test_pipeline(pipeline, tmp_path): """ - Test a GStreamer pipeline for 100 frames, checking for errors, with latency tracing. + Run a README pipeline and check its log for errors. + + A pipeline still running at `PIPELINE_TIMEOUT` passes: only `videotestsrc` + takes a frame cap, so a file-backed one runs as long as its media lasts. """ LOG_DIR.mkdir(parents=True, exist_ok=True) os.sync() + pipeline = absolutize_project_inputs(pipeline) unique_id = uuid.uuid4().hex[:8] log_file = LOG_DIR / f"test_{unique_id}.log" @@ -124,30 +173,37 @@ def test_pipeline(pipeline, tmp_path): # Set up environment with latency tracer env = os.environ.copy() env["GST_TRACERS"] = "latency" + # Colour escapes land in the log file and break matching on the level field. + env["GST_DEBUG_NO_COLOR"] = "1" # Run the pipeline + ran_to_the_cap = False try: with open(log_file, "w") as log: + # Own process group: the shell is not the pipeline, it is the + # launcher's parent, so killing the group is what stops the run. + # Terminating the shell alone leaves the launcher holding a window + # and the GPU until the machine is rebooted. process = subprocess.Popen( pipeline, shell=True, stdout=log, stderr=subprocess.STDOUT, - cwd=BASE_DIR, + cwd=tmp_path, env=env, + start_new_session=True, ) - process.wait(timeout=30) + process.wait(timeout=PIPELINE_TIMEOUT) return_code = process.returncode except subprocess.TimeoutExpired: - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - pytest.fail( - f"Pipeline timed out after 30s. Full pipeline: {pipeline}. See {log_file}" - ) + # Still running at the cap, which is what a healthy uncapped pipeline + # does: the media outlasts any timeout worth waiting. The log below says + # whether it was working, so the run is judged on that, not on exiting. + end_process_group(process) + ran_to_the_cap = True + return_code = None except Exception as e: + end_process_group(process) pytest.fail( f"Failed to execute pipeline: {e}. Full pipeline: {pipeline}. See {log_file}" ) @@ -157,18 +213,21 @@ def test_pipeline(pipeline, tmp_path): pytest.fail(f"Log file {log_file} was not created. Full pipeline: {pipeline}") with open(log_file, "r") as log: log_content = log.read() - error_lines = [ - line - for line in log_content.splitlines() - if "ERROR" in line or "WARN" in line - ] - if error_lines: - pytest.fail( - f"Errors/Warnings found in pipeline:\n{''.join(error_lines)}\nFull pipeline: {pipeline}\nSee {log_file}" - ) + failures = [m.group(0) for p in FATAL_LOG_PATTERNS for m in p.finditer(log_content)] + if failures: + reported = "\n".join(failures) + pytest.fail( + f"Errors found in pipeline:\n{reported}\nFull pipeline: {pipeline}\nSee {log_file}" + ) + + # Without this a pipeline that never left PAUSED passes on an empty log. + if "Setting pipeline to PLAYING" not in log_content: + pytest.fail( + f"Pipeline never reached PLAYING. Full pipeline: {pipeline}. See {log_file}" + ) # Check exit code - if return_code != 0: + if not ran_to_the_cap and return_code != 0: if ( "End-Of-Stream" not in log_content and "reached end of stream" not in log_content @@ -177,13 +236,14 @@ def test_pipeline(pipeline, tmp_path): f"Pipeline failed with exit code {return_code}. Full pipeline: {pipeline}. See {log_file}" ) - print(f"Pipeline processed 100 frames successfully: {pipeline}") + ending = f"ran the full {PIPELINE_TIMEOUT}s" if ran_to_the_cap else "ran to the end" + print(f"Pipeline {ending} with no errors: {pipeline}") def test_pipelines_found(): """Ensure at least one pipeline was found in README.""" if not PIPELINES: - pytest.fail("No gst-launch-1.0 pipelines found in README.md") + pytest.fail("No pyml-launch pipelines found in README.md") print(f"Found {len(PIPELINES)} pipelines to test") diff --git a/tests/test_pyml_launch.py b/tests/test_pyml_launch.py new file mode 100644 index 0000000..39d5453 --- /dev/null +++ b/tests/test_pyml_launch.py @@ -0,0 +1,365 @@ +"""Unit tests for `pyml-launch`'s gst -> g2g pipeline rewrite. + +These run the real `element_shells` scan over the real plugin directory, so a +plugin that stops declaring `register_gst_element` fails a test rather than +silently dropping out of the map. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins" / "python")) + +import pyml_launch # noqa: E402 + + +@pytest.fixture(scope="module") +def shells(): + return pyml_launch.element_shells() + + +def test_element_shells_finds_the_hosted_elements(shells): + assert shells["pyml_yolo"] == pyml_launch.ElementShell("yolo", "YOLOTransform") + assert shells["pyml_objectdetector"].module == "objectdetector" + assert shells["pyml_overlay"] == pyml_launch.ElementShell("overlay", "Overlay") + + +def test_an_element_several_chains_feed_becomes_the_batching_host(shells): + pipeline = ( + "videotestsrc ! pyml_streammux name=mux " + "videotestsrc ! mux. videotestsrc ! mux. " + "mux. ! fakesink" + ).split() + rewritten = pyml_launch.rewrite_for_g2g(pipeline, shells) + assert "pyaggregator" in rewritten + assert "pyelement" not in rewritten + + +def test_a_single_input_element_stays_on_the_one_in_host(shells): + # Whisper derives from GstBase.Aggregator but takes one chain, which g2g + # hosts as a transform. + assert ( + pyml_launch.rewrite_segment("pyml_whispertranscribe language=ko", shells)[0] + == "pyelement" + ) + + +def test_element_shells_resolves_a_name_given_as_a_class_constant(shells): + # kafkasink registers under `KafkaSink.GST_PLUGIN_NAME`, not a literal. + assert shells["pyml_kafkasink"] == pyml_launch.ElementShell( + "kafkasink", "KafkaSink" + ) + + +def test_hosted_element_becomes_pyelement_keeping_its_properties(shells): + assert pyml_launch.rewrite_segment( + "pyml_yolo model-name=yolo11m device=cuda:0", shells + ) == [ + "pyelement", + "module=yolo", + "class=YOLOTransform", + "model-name=yolo11m", + "device=cuda:0", + ] + + +def test_overlay_becomes_the_native_element(shells): + assert pyml_launch.rewrite_segment("pyml_overlay", shells) == ["analyticsoverlay"] + + +def test_overlay_properties_carry_over_under_the_native_name(shells): + assert pyml_launch.rewrite_segment("pyml_overlay tracking=True", shells) == [ + "analyticsoverlay", + "show-track=True", + ] + + +def test_an_overlay_property_with_no_counterpart_is_refused(shells): + with pytest.raises(SystemExit, match="meta-path"): + pyml_launch.rewrite_segment( + "pyml_overlay meta-path=data/sample_metadata.json", shells + ) + + +def test_unknown_pyml_element_is_refused(shells): + with pytest.raises(SystemExit, match="pyml_nonesuch"): + pyml_launch.rewrite_segment("pyml_nonesuch", shells) + + +def test_shared_elements_pass_through_untouched(shells): + for segment in ("filesrc location=data/people.mp4", "decodebin", "videoscale"): + assert pyml_launch.rewrite_segment(segment, shells) == segment.split() + + +def test_raw_video_caps_gain_a_format_only_when_they_lack_one(shells): + assert pyml_launch.rewrite_segment("video/x-raw,width=640,height=480", shells) == [ + "video/x-raw,width=640,height=480,format=RGBA" + ] + pinned = "video/x-raw,format=NV12,width=640,height=480" + assert pyml_launch.rewrite_segment(pinned, shells) == [pinned] + + +def test_caps_written_with_spaces_become_one_token(shells): + assert pyml_launch.rewrite_segment( + ["video/x-raw,", "width=320,", "height=240"], shells + ) == ["video/x-raw,width=320,height=240,format=RGBA"] + + +def test_a_property_value_with_spaces_keeps_them(shells): + assert pyml_launch.rewrite_segment( + ["pyml_clip", "labels=person, bicycle, car", "top-k=3"], shells + ) == [ + "pyelement", + "module=clip", + "class=CLIPTransform", + 'labels="person, bicycle, car"', + "top-k=3", + ] + + +def test_a_property_value_keeps_the_quotes_inside_it(shells): + assert ( + pyml_launch.rewrite_segment(["pyml_alert", 'rules={"class":"person"}'], shells)[ + -1 + ] + == r"rules={\"class\":\"person\"}" + ) + + +def test_a_sink_drops_the_clock_properties_g2g_has_no_counterpart_for(shells): + assert pyml_launch.rewrite_segment("fakesink async=0 sync=0", shells) == [ + "fakesink" + ] + + +def test_a_sink_asked_to_wait_on_the_clock_is_refused(shells): + with pytest.raises(SystemExit, match="clocksync"): + pyml_launch.rewrite_segment("autovideosink sync=true", shells) + + +def test_a_hosted_element_named_like_a_sink_keeps_its_own_properties(shells): + # `sync` on a g2g sink names a clock wait that does not exist there, but on + # `pyml_kafkasink` it is the Kafka producer's own knob. + assert pyml_launch.rewrite_segment("pyml_kafkasink sync=true", shells) == [ + "pyelement", + "module=kafkasink", + "class=KafkaSink", + "sync=true", + ] + + +def test_textoverlay_drops_the_wait_it_never_does(shells): + assert pyml_launch.rewrite_segment( + "textoverlay name=overlay wait-text=false", shells + ) == ["textoverlay", "name=overlay"] + + +def test_whole_readme_pipeline_rewrites(shells): + pipeline = ( + "filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvert " + "! videoscale ! video/x-raw,width=640,height=480 " + "! pyml_yolo model-name=yolo11m device=cuda:0 track=True " + "! pyml_overlay ! videoconvert ! autovideosink" + ) + assert ( + pyml_launch.rewrite_for_g2g(pipeline.split(), shells) + == ( + "filesrc location=data/soccer_tracking.mp4 ! decodebin ! videoconvert " + "! videoscale ! video/x-raw,width=640,height=480,format=RGBA " + "! pyelement module=yolo class=YOLOTransform model-name=yolo11m " + "device=cuda:0 track=True " + "! analyticsoverlay ! videoconvert ! autovideosink" + ).split() + ) + + +def test_hosted_element_takes_the_format_the_caps_ahead_of_it_pin(shells): + pipeline = ( + "videoconvert ! video/x-raw,format=RGB,width=640,height=640 " + "! pyml_inference engine-name=onnx" + ).split() + assert pyml_launch.rewrite_for_g2g(pipeline, shells)[-4:] == [ + "module=inference", + "class=GenericInferenceTransform", + "format=RGB", + "engine-name=onnx", + ] + + +def test_the_batching_host_takes_the_caps_of_an_audio_in_text_out_element(shells): + assert pyml_launch.rewrite_segment( + "pyml_whispertranscribe", shells, host=pyml_launch.PY_AGGREGATOR + ) == [ + "pyaggregator", + "module=whispertranscribe", + "class=WhisperTranscribe", + "input-caps=audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1", + "output-caps=text/x-raw,format=utf8", + ] + + +def test_the_batching_host_takes_the_caps_of_a_text_in_audio_out_element(shells): + assert pyml_launch.rewrite_segment( + "pyml_coquitts", shells, host=pyml_launch.PY_AGGREGATOR + ) == [ + "pyaggregator", + "module=coquitts", + "class=CoquiTTS", + "input-caps=text/x-raw,format=utf8", + "output-caps=audio/x-raw,format=S16LE,layout=interleaved,rate=22050,channels=1", + ] + + +def test_the_one_in_host_takes_the_caps_of_an_audio_in_text_out_element(shells): + pipeline = ( + "filesrc location=data/audio_sample.wav ! decodebin ! audioconvert " + "! pyml_whispertranscribe language=ko ! fakesink" + ).split() + assert ( + pyml_launch.rewrite_for_g2g(pipeline, shells) + == ( + "filesrc location=data/audio_sample.wav ! decodebin ! audioconvert " + "! pyelement module=whispertranscribe class=WhisperTranscribe " + "input-caps=audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1 " + "output-caps=text/x-raw,format=utf8 language=ko " + "! fakesink" + ).split() + ) + + +def test_the_one_in_host_takes_the_caps_of_a_text_in_audio_out_element(shells): + pipeline = "filesrc location=data/lines.txt ! pyml_coquitts ! fakesink".split() + assert ( + pyml_launch.rewrite_for_g2g(pipeline, shells) + == ( + "filesrc location=data/lines.txt " + "! pyelement module=coquitts class=CoquiTTS " + "input-caps=text/x-raw,format=utf8 " + "output-caps=audio/x-raw,format=S16LE,layout=interleaved,rate=22050,channels=1 " + "! fakesink" + ).split() + ) + + +def test_a_leaf_that_restates_a_rate_wins_over_the_base_it_inherits_from(shells): + # Demucs runs at the base class's 44100, Sepformer restates both pads at 8000. + assert shells["pyml_demucs"].caps == ( + ( + "input-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=44100,channels=1", + ), + ( + "output-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=44100,channels=1", + ), + ) + assert shells["pyml_sepformer"].caps == ( + ( + "input-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=8000,channels=1", + ), + ( + "output-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=8000,channels=1", + ), + ) + + +def test_a_leaf_inherits_the_pad_it_does_not_restate(shells): + # WhisperLive transcribes into speech, so only its src pad differs. + assert shells["pyml_whisperlive"].caps == ( + ( + "input-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1", + ), + ( + "output-caps", + "audio/x-raw,format=S16LE,layout=interleaved,rate=24000,channels=1", + ), + ) + + +def test_an_element_declaring_no_caps_gets_no_caps_properties(shells): + pipeline = ( + "videotestsrc ! pyml_streammux name=mux " + "videotestsrc ! mux. videotestsrc ! mux. " + "mux. ! fakesink" + ).split() + rewritten = pyml_launch.rewrite_for_g2g(pipeline, shells) + assert "pyaggregator" in rewritten + assert not [token for token in rewritten if token.startswith(("input-", "output-"))] + + +def test_the_caps_survive_the_rewrite_of_a_whole_pipeline(shells): + pipeline = ( + "filesrc location=data/audio_sample.wav ! decodebin ! audioconvert " + "! pyml_whispertranscribe name=stt language=ko " + "audiotestsrc ! stt. audiotestsrc ! stt. stt. ! fakesink" + ).split() + assert ( + pyml_launch.rewrite_for_g2g(pipeline, shells) + == ( + "filesrc location=data/audio_sample.wav ! decodebin ! audioconvert " + "! pyaggregator module=whispertranscribe class=WhisperTranscribe " + "input-caps=audio/x-raw,format=S16LE,layout=interleaved,rate=16000,channels=1 " + "output-caps=text/x-raw,format=utf8 name=stt language=ko " + "audiotestsrc ! stt. audiotestsrc ! stt. stt. ! fakesink" + ).split() + ) + + +def g2g_pipeline(*segments): + """Run a pipeline on the g2g launcher, returning what it printed. + + Skips where there is no build to check against, so the drift check runs for + whoever has both repos and never blocks whoever has one. + """ + binary = pyml_launch.g2g_binary() + if not binary: + pytest.skip("needs a g2g-launch-py build to check the names against") + return subprocess.run( + [binary, *segments], + capture_output=True, + text=True, + timeout=120, + ) + + +#: Enough of a pipeline to negotiate raw video into the element under test. +G2G_VIDEO_SOURCE = ( + "videotestsrc", + "num-buffers=1", + "!", + "videoconvert", + "!", + f"video/x-raw,format={pyml_launch.G2G_RAW_VIDEO_FORMAT}", + "!", +) + + +def test_the_native_element_and_the_names_it_renames_to_still_exist(): + """`NATIVE_EQUIVALENTS` and `NATIVE_PROPERTIES` copy names out of glass2glass, + and `G2G_RAW_VIDEO_FORMAT` states what that element negotiates. Nothing here + notices when any of the three changes there, so run one and see.""" + for element, native in pyml_launch.NATIVE_EQUIVALENTS.items(): + renames = pyml_launch.NATIVE_PROPERTIES.get(element, {}) + properties = [f"{name}=true" for name in renames.values()] + result = g2g_pipeline(*G2G_VIDEO_SOURCE, native, *properties, "!", "fakesink") + assert result.returncode == 0, ( + f"{element} rewrites to `{native} {' '.join(properties)}`, which g2g " + f"no longer runs:\n{result.stdout}{result.stderr}" + ) + + +def test_an_element_read_from_twice_is_not_a_muxer(shells): + # Two chains start at `cap.`, reading two source pads, which is a fan-out. + pipeline = ( + "videotestsrc ! pyml_caption_qwen name=cap " + "cap.src ! fakesink cap.text_src ! fakesink" + ).split() + rewritten = pyml_launch.rewrite_for_g2g(pipeline, shells) + assert "pyelement" in rewritten + assert "pyaggregator" not in rewritten diff --git a/uv.lock b/uv.lock index 4050a24..171a7d4 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,15 @@ resolution-markers = [ "python_full_version < '3.13' and sys_platform != 'win32'", ] +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + [[package]] name = "accelerate" version = "1.13.0" @@ -28,6 +37,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl", hash = "sha256:cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0", size = 383744, upload-time = "2026-03-04T19:34:10.313Z" }, ] +[[package]] +name = "ai-edge-litert" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-strenum" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/37/cf525a4ed6aff573b10a162b7bff19673e50ce6f45daac0a095dcd099a28/ai_edge_litert-2.1.5-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b62fd3d90e643bcc3e3a31885a7636b5662527d48c27154cf07793f26896f018", size = 9711976, upload-time = "2026-05-15T23:34:16.988Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6f/b43fc56831ecc439a6e6eb0e1bfdffbfe6213e8c706aa421017dce1ac56b/ai_edge_litert-2.1.5-cp312-cp312-manylinux_2_27_aarch64.whl", hash = "sha256:1d282722cdfb70bb42d457f4bfe7789f88187ccadd73fea9f0107dce6e98fe45", size = 12867617, upload-time = "2026-05-15T23:11:22.759Z" }, + { url = "https://files.pythonhosted.org/packages/c9/20/a76ba29b1c4c3009b5c64f4ec7f5fe5165104fe15481bd79b55927948541/ai_edge_litert-2.1.5-cp312-cp312-manylinux_2_27_x86_64.whl", hash = "sha256:f1c6d8db4382890881baeb8ed13c0802ada022e0b104b0db8fccf31353899ee0", size = 17570644, upload-time = "2026-05-15T23:05:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/5399085daffd9949668d13a83a48db9f69af535ef9f9d06fdf71f0205321/ai_edge_litert-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:6d2db6759188b12be9fd095468c3c7069c1bc7c128de78f42f862fb654d247ea", size = 17755006, upload-time = "2026-05-15T23:39:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fd/28f24305904954f31ee1a979eea39b670c6a1ab13cf6e5e4fe5f128e9eff/ai_edge_litert-2.1.5-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2af16685a4cc8923d9a89ee5c732b8c66ae2c4d0cb538dbcebba3b93afb662ca", size = 9713185, upload-time = "2026-05-15T23:34:19.431Z" }, + { url = "https://files.pythonhosted.org/packages/d3/88/e7e32b37f972b4877df788d1ad0995388f823d74dc2743b200bf71a25a53/ai_edge_litert-2.1.5-cp313-cp313-manylinux_2_27_aarch64.whl", hash = "sha256:57506e9e9b91ee04e4cf3e986d184b0c92f1aa9f510c32841835c0d719a7fbb5", size = 12867247, upload-time = "2026-05-15T23:11:24.667Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c1/5192c360a467dd6e09fa28388022c37bca5d9777e616da40dc5d517186a9/ai_edge_litert-2.1.5-cp313-cp313-manylinux_2_27_x86_64.whl", hash = "sha256:b7825e13454a90e7a4782ecdb6f6d987a2bcc9147aa90eb181193867bf696f24", size = 17570488, upload-time = "2026-05-15T23:05:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/154599984a8eccbdd14bcd89fabc2e9cbdfcbf537a3c1ab08712c7ce7d02/ai_edge_litert-2.1.5-cp313-cp313-win_amd64.whl", hash = "sha256:602fc90f6baf396df0fe4b0317b35627305c2233beb48db194b79e7e420bafa7", size = 17755085, upload-time = "2026-05-15T23:39:26.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/34f0ad0def8b15093d602ca6904b129d3ab242ae4711c872190d5c95c010/ai_edge_litert-2.1.5-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:597ea79f947452c79ca7e9dd7abfedad6179302851df4a4bb4c755069a2f5ffe", size = 9712496, upload-time = "2026-05-15T23:34:21.36Z" }, + { url = "https://files.pythonhosted.org/packages/42/43/2b8ef317dca3096372cbf820aa5a3a6c8857dad67f31840a1e17a951e2d3/ai_edge_litert-2.1.5-cp314-cp314-manylinux_2_27_aarch64.whl", hash = "sha256:41d2e8267f047ed14f14a5ff4e0f677a9c5143dd17a36991513f8cc5ef580a3b", size = 12867899, upload-time = "2026-05-15T23:11:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d5/9640952de755409c84e09eec5d107675c59fe4f4859391464275eb2c3db6/ai_edge_litert-2.1.5-cp314-cp314-manylinux_2_27_x86_64.whl", hash = "sha256:4fa10d99b2f8647678850684d31075fbb304f50535d5788c2ca93e273ce727a8", size = 17570849, upload-time = "2026-05-15T23:05:37.981Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d4/0843e5a41bf71eb99ccd1e54714485df527bec19e8c8e679e1395176345d/ai_edge_litert-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:14b35558bfce76146046cc2fc647f3fde97146e8b7622ea7fb02e85ac16cd906", size = 18380722, upload-time = "2026-05-15T23:39:29.365Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -39,7 +75,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -48,78 +84,93 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -236,6 +287,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/77/43b27c14865dd4204ef353b875b4251e270b2518296e90b9bda479776c58/apswutils-0.1.2-py3-none-any.whl", hash = "sha256:9cd73744f9ae83c2e6f4337d4fcb092f5ea2f1814037e9ff7d953e2bc9c8362a", size = 48171, upload-time = "2025-12-18T06:24:31.312Z" }, ] +[[package]] +name = "astunparse" +version = "1.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -343,7 +407,8 @@ dependencies = [ { name = "tokenizers" }, { name = "torch" }, { name = "transformers" }, - { name = "triton" }, + { name = "triton", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'win32'" }, + { name = "triton", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'win32'" }, { name = "typing-extensions" }, { name = "zstandard" }, ] @@ -373,6 +438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/af/0a6e1d2a845988039f6c197fa7269b5e9abbe17354fb41cc9d75bb260fcb/av-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a87a42c36e29f75e7dff7281944f2a6876a2c8875e225ccbf6c1ae62748b4caa", size = 22072676, upload-time = "2026-04-18T17:12:31.836Z" }, ] +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -734,7 +808,7 @@ name = "cuda-bindings" version = "13.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, @@ -757,45 +831,51 @@ wheels = [ [[package]] name = "cuda-toolkit" -version = "13.0.2" +version = "13.0.3.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, ] [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -855,7 +935,7 @@ wheels = [ [[package]] name = "diffusers" -version = "0.37.1" +version = "0.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -868,9 +948,9 @@ dependencies = [ { name = "requests" }, { name = "safetensors" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/5c/f4c2eb8d481fe8784a7e2331fbaab820079c06676185fa6d2177b386d590/diffusers-0.37.1.tar.gz", hash = "sha256:2346c21f77f835f273b7aacbaada1c34a596a3a2cc6ddc99d149efcd0ec298fa", size = 4135139, upload-time = "2026-03-25T08:04:04.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/81/6095237b86a3116c4789f28c4435d5296c00c0fc74ffde99008fd6b3a36c/diffusers-0.39.0.tar.gz", hash = "sha256:14bb1d98c85a0e463d734c99aaa73b480a7bc9bad22af30fbf730ef8f09c1d67", size = 4651240, upload-time = "2026-07-03T08:48:47.904Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/dd/51c38785ce5e1c287b5ad17ba550edaaaffce0deb0da4857019c6700fbaf/diffusers-0.37.1-py3-none-any.whl", hash = "sha256:0537c0b28cb53cf39d6195489bcf8f833986df556c10f5e28ab7427b86fc8b90", size = 5001536, upload-time = "2026-03-25T08:04:02.385Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/7469c46e9d22307ea686bab687d70e6bf328722952f9d10339f5e913e608/diffusers-0.39.0-py3-none-any.whl", hash = "sha256:912aca51b5787365110806e984d5555735bf8a461073bb8459029d0bca7870ef", size = 5631176, upload-time = "2026-07-03T08:48:45.337Z" }, ] [[package]] @@ -921,18 +1001,9 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/62/59/e47bbd0542d0e6f4ce9983d5eb458a01d4b42c81e5c410cb9e159b1061ae/encodec-0.1.1.tar.gz", hash = "sha256:36dde98ccfe6c51a15576476cadfcb3b35a63507b8b8555abd69889a6fba6772", size = 3736037, upload-time = "2022-10-25T16:13:21.471Z" } -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "executorch" -version = "1.2.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coremltools", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux')" }, @@ -947,10 +1018,6 @@ dependencies = [ { name = "packaging", marker = "python_full_version < '3.14'" }, { name = "pandas", marker = "python_full_version < '3.14'" }, { name = "parameterized", marker = "python_full_version < '3.14'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pytest-json-report", marker = "python_full_version < '3.14'" }, - { name = "pytest-rerunfailures", marker = "python_full_version < '3.14'" }, - { name = "pytest-xdist", marker = "python_full_version < '3.14'" }, { name = "pytorch-tokenizers", marker = "python_full_version < '3.14'" }, { name = "pyyaml", marker = "python_full_version < '3.14'" }, { name = "ruamel-yaml", marker = "python_full_version < '3.14'" }, @@ -962,14 +1029,18 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2a/7b624076e67d71a82b68f07fad90fc9b1e6f3f887460f915cd665d0a27b3/executorch-1.2.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ea6848902acabf21c7a6fe078947f50c386bacd9081161761bff27931b3e2af1", size = 12027466, upload-time = "2026-04-01T21:10:32.961Z" }, - { url = "https://files.pythonhosted.org/packages/a0/59/55a707e4a97667b4e3c03c60d4883891c482e033edd32f3d30966abdb358/executorch-1.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ebcf508bafed6a7eb4f186459ccc921aabd7b1f6c13a2045ed16654075b58a36", size = 12828287, upload-time = "2026-04-01T21:10:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/11/27/d88311cad6ce5181562fb6cc2123ff197d774e8ec236a2d5a5f0b6bdb361/executorch-1.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:44090bb8361de77ae551efae045ec577abfb9a4b7f42365627768d2e5735f542", size = 20526090, upload-time = "2026-04-01T21:10:39.094Z" }, - { url = "https://files.pythonhosted.org/packages/6c/eb/92ace5cfefbba29217137edabf5ec68b2b60b9e00ae3cc7ecebf79973d5b/executorch-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:1e0a885d8ac567c9ff9cf97902745e5090d91b12cd57921edcd3b8cd5a1e2393", size = 9705134, upload-time = "2026-04-01T21:10:42.509Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a1/fc395be78ae85cd3c19f1436322dc4e8622317cf8ed2ece1155f0ad39e24/executorch-1.2.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:9e14736f2357aaec6930eda98dc870b08e78ece604a3cdb11fe66d493e6c68c3", size = 12027961, upload-time = "2026-04-01T21:10:45.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/43/8b94829c2df0ed57d4286afc8ff2fe54d7108c378c02ecd5190f50c6e74e/executorch-1.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:148fc9ac6e477fd3421f2795fb9918a0e1c0136d54f50c229f77a5bed6d3138c", size = 12829767, upload-time = "2026-04-01T21:10:48.53Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c5/d3b77ec2cc6ff4550ffd63f0154e000670730fad5134b4df80419afb89e6/executorch-1.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b93a56a7e080ecc8b0648662d39b0d23d74554e475e97960a8c1ff487488b4c0", size = 20526098, upload-time = "2026-04-01T21:10:52.116Z" }, - { url = "https://files.pythonhosted.org/packages/07/7b/18fbc1f0f21401e013b91fca3f67d4ae2c426a1ede84a88846ddb4322185/executorch-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c66a5289f9319a64c9a962432aa9cacf4ffa0ac8c99f5f2a694408ebf5102a02", size = 9705167, upload-time = "2026-04-01T21:10:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/3a/40/067a21f1ca501fda2f673b410a766309b0641042e8d2b93f614b78f61044/executorch-1.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1391236f2b72815004e6b43d1073cb62668a7588f640662dafce92f4b5f6996d", size = 16668345, upload-time = "2026-08-07T19:53:28.415Z" }, + { url = "https://files.pythonhosted.org/packages/f5/06/c37c63ed08a0d7717d90e078b36768c6a300a40bd7fa3fa61efa25247b45/executorch-1.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:29871cde0ec60431f1056c531e7903616320695f4036f503827f3a81a4e542fd", size = 16074566, upload-time = "2026-08-07T19:53:31.095Z" }, + { url = "https://files.pythonhosted.org/packages/1c/55/afb80f13ffd785b1235718c74d2fda17748b53bc111b4c3168d9c2055ca6/executorch-1.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fb13ebf8fab5b5c7ef586fa7b347ecd38be8fd42684545193a495ecb21287b55", size = 17567549, upload-time = "2026-08-07T19:53:33.751Z" }, + { url = "https://files.pythonhosted.org/packages/06/cd/5235f6a5116005ac68ae3c7a12158e8973a0052a391f26d055cc0cfd9320/executorch-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c5bb4a2f3e30e7b9819b75bd8f41e083922d341d9147730fda973f8dbf1ef7d", size = 12803395, upload-time = "2026-08-07T19:53:36.471Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/b23fb1ed45f9d8ac0193b0209677a1398a73f5638aee16dd8a67a19ac02c/executorch-1.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7677c6808c10831b0ee3d534ad4c9acb038ea4f50f8c6feb5e5ef4c6c7e25c7f", size = 16668640, upload-time = "2026-08-07T19:53:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/13/ab/1fb5bd2d3577ecad6a4b0b25b35f23e952ce8b6ae6bddc0df768ff90ba38/executorch-1.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8a6647beda101543eff7f1ed6568f907dc6edcf85451eb45c20c771aff254eee", size = 16074966, upload-time = "2026-08-07T19:53:41.452Z" }, + { url = "https://files.pythonhosted.org/packages/13/0f/fef87d44aafb6afd8db087e8b84aae784d0977c7fff4ba10f7298193dc17/executorch-1.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:79f8418ed0f86c5591940d4c3044934444d63add39251b3e843f2b695d7e6426", size = 17568357, upload-time = "2026-08-07T19:53:43.846Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4e/f4461f32e59f783f264ab243da216b589197fff2defbf8419dafbae99f84/executorch-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:77549dd42496fd3e2af48d4b0dee33c559f6d6ee480bf17ca32355be091ceada", size = 12803501, upload-time = "2026-08-07T19:53:46.404Z" }, + { url = "https://files.pythonhosted.org/packages/87/6d/c5f203bf78342a93d6374cf28fb66ba082f27748c0f760e034642f3d01c5/executorch-1.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:b568c66abb381ae95e9a2aab7d9459687fa54c20c374b49f6d24682816afb025", size = 16670480, upload-time = "2026-08-07T19:53:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/74/68/b8149335b8e6ab57f257b333d1ec5f5d16aecdc7ea205e07ea5cde5d7285/executorch-1.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1e5576141b755aad344184326266561180040a0c86dbb30ac3c77e21f720c245", size = 16078636, upload-time = "2026-08-07T19:53:51.375Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/1974f95a5ae0b853262a41cb2acccce2cdaf2f7e0289fb1c081371811faf/executorch-1.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:cd24213687eac2ba5f255d3b0a02420879bae3e86666c372d018c38d72e37921", size = 17568993, upload-time = "2026-08-07T19:53:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/6f46aa84c676962926e1e5de260314d0ad177298d7450958e520e12eb663/executorch-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:bf76414dc0062e2d4f42ea8a226b26977550e80930c35ee718f9fbf50fc37612", size = 12805319, upload-time = "2026-08-07T19:53:56.834Z" }, ] [[package]] @@ -1193,9 +1264,71 @@ http = [ { name = "aiohttp" }, ] +[[package]] +name = "gast" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/f6/e73969782a2ecec280f8a176f2476149dd9dba69d5f8779ec6108a7721e6/gast-0.7.0.tar.gz", hash = "sha256:0bb14cd1b806722e91ddbab6fb86bba148c22b40e7ff11e248974e04c8adfdae", size = 33630, upload-time = "2025-11-29T15:30:05.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/33/f1c6a276de27b7d7339a34749cc33fa87f077f921969c47185d34a887ae2/gast-0.7.0-py3-none-any.whl", hash = "sha256:99cbf1365633a74099f69c59bd650476b96baa5ef196fec88032b00b31ba36f7", size = 22966, upload-time = "2025-11-29T15:30:03.983Z" }, +] + +[[package]] +name = "google-pasta" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430, upload-time = "2020-03-13T18:57:50.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + [[package]] name = "gst-python-ml" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "accelerate" }, @@ -1228,6 +1361,17 @@ dependencies = [ ] [package.optional-dependencies] +all = [ + { name = "ai-edge-litert" }, + { name = "faster-whisper" }, + { name = "llama-cpp-python" }, + { name = "onnxruntime" }, + { name = "openvino" }, + { name = "pysilero" }, + { name = "pysilero-vad" }, + { name = "tensorflow" }, + { name = "tinygrad" }, +] executorch = [ { name = "executorch", marker = "python_full_version < '3.14'" }, ] @@ -1240,6 +1384,9 @@ jax-gpu = [ jax-tpu = [ { name = "jax", extra = ["tpu"] }, ] +litert = [ + { name = "ai-edge-litert" }, +] llamacpp = [ { name = "llama-cpp-python" }, ] @@ -1253,6 +1400,12 @@ onnx = [ onnx-gpu = [ { name = "onnxruntime-gpu" }, ] +openvino = [ + { name = "openvino" }, +] +tensorflow = [ + { name = "tensorflow" }, +] tinygrad = [ { name = "tinygrad" }, ] @@ -1264,17 +1417,16 @@ vad = [ [package.dev-dependencies] dev = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pytest" }, ] test = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ { name = "accelerate", specifier = ">=1.13.0" }, + { name = "ai-edge-litert", marker = "extra == 'litert'" }, { name = "autoawq", specifier = ">=0.2.9" }, { name = "bitsandbytes", specifier = ">=0.49.2" }, { name = "confluent-kafka" }, @@ -1282,6 +1434,7 @@ requires-dist = [ { name = "easydict" }, { name = "executorch", marker = "python_full_version < '3.14' and extra == 'executorch'" }, { name = "faster-whisper", marker = "extra == 'vad'" }, + { name = "gst-python-ml", extras = ["onnx", "tinygrad", "llamacpp", "openvino", "tensorflow", "litert", "vad"], marker = "extra == 'all'" }, { name = "huggingface-hub" }, { name = "jax", extras = ["cpu"], marker = "extra == 'jax-cpu'" }, { name = "jax", extras = ["cuda12"], marker = "extra == 'jax-gpu'" }, @@ -1297,6 +1450,7 @@ requires-dist = [ { name = "opencv-contrib-python", marker = "python_full_version >= '3.14'", specifier = ">=4.13.0" }, { name = "opencv-python", marker = "python_full_version < '3.14'", specifier = ">=4.9.0" }, { name = "opencv-python", marker = "python_full_version >= '3.14'", specifier = ">=4.13.0" }, + { name = "openvino", marker = "extra == 'openvino'", specifier = ">=2024.0" }, { name = "protobuf" }, { name = "pycairo" }, { name = "pyflann-py3" }, @@ -1308,6 +1462,7 @@ requires-dist = [ { name = "sentencepiece" }, { name = "soundfile" }, { name = "speechbrain" }, + { name = "tensorflow", marker = "extra == 'tensorflow'", specifier = ">=2.16.0" }, { name = "tinygrad", marker = "extra == 'tinygrad'" }, { name = "torch", marker = "python_full_version < '3.14'", specifier = ">=2.7.0" }, { name = "torch", marker = "python_full_version >= '3.14'", specifier = ">=2.11.0" }, @@ -1321,11 +1476,11 @@ requires-dist = [ { name = "webdataset" }, { name = "whisperspeech" }, ] -provides-extras = ["onnx", "onnx-gpu", "tinygrad", "mlx", "executorch", "llamacpp", "jax-cpu", "jax-gpu", "jax-tpu", "vad"] +provides-extras = ["onnx", "onnx-gpu", "tinygrad", "mlx", "executorch", "llamacpp", "jax-cpu", "jax-gpu", "jax-tpu", "openvino", "tensorflow", "litert", "vad", "all"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=7.0" }] -test = [{ name = "pytest", specifier = ">=7.0" }] +dev = [{ name = "pytest", specifier = ">=9.0.3" }] +test = [{ name = "pytest", specifier = ">=9.0.3" }] [[package]] name = "h11" @@ -1336,6 +1491,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h5py" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/77/8f651053c1843391e38a189ccf50df7e261ef8cd8bfd8baba0cbe694f7c3/h5py-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0045115d83272090b0717c555a31398c2c089b87d212ceba800d3dc5d952e23", size = 3312740, upload-time = "2025-06-06T14:05:01.193Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/20436a6cf419b31124e59fefc78d74cb061ccb22213226a583928a65d715/h5py-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6da62509b7e1d71a7d110478aa25d245dd32c8d9a1daee9d2a42dba8717b047a", size = 2829207, upload-time = "2025-06-06T14:05:05.061Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/c8bfe8543bfdd7ccfafd46d8cfd96fce53d6c33e9c7921f375530ee1d39a/h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c", size = 4708455, upload-time = "2025-06-06T14:05:11.528Z" }, + { url = "https://files.pythonhosted.org/packages/86/f9/f00de11c82c88bfc1ef22633557bfba9e271e0cb3189ad704183fc4a2644/h5py-3.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbd41f4e3761f150aa5b662df991868ca533872c95467216f2bec5fcad84882", size = 4929422, upload-time = "2025-06-06T14:05:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6d/6426d5d456f593c94b96fa942a9b3988ce4d65ebaf57d7273e452a7222e8/h5py-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bf4897d67e613ecf5bdfbdab39a1158a64df105827da70ea1d90243d796d367f", size = 2862845, upload-time = "2025-06-06T14:05:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/7efe82d09ca10afd77cd7c286e42342d520c049a8c43650194928bcc635c/h5py-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa4b7bbce683379b7bf80aaba68e17e23396100336a8d500206520052be2f812", size = 3289245, upload-time = "2025-06-06T14:05:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/4f/31/f570fab1239b0d9441024b92b6ad03bb414ffa69101a985e4c83d37608bd/h5py-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9603a501a04fcd0ba28dd8f0995303d26a77a980a1f9474b3417543d4c6174", size = 2807335, upload-time = "2025-06-06T14:05:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ce/3a21d87896bc7e3e9255e0ad5583ae31ae9e6b4b00e0bcb2a67e2b6acdbc/h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb", size = 4700675, upload-time = "2025-06-06T14:05:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ec/86f59025306dcc6deee5fda54d980d077075b8d9889aac80f158bd585f1b/h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13", size = 4921632, upload-time = "2025-06-06T14:05:43.464Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6d/0084ed0b78d4fd3e7530c32491f2884140d9b06365dac8a08de726421d4a/h5py-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae18e3de237a7a830adb76aaa68ad438d85fe6e19e0d99944a3ce46b772c69b3", size = 2852929, upload-time = "2025-06-06T14:05:47.659Z" }, +] + [[package]] name = "hf-xet" version = "1.4.3" @@ -1495,11 +1671,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1655,6 +1831,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "keras" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "h5py" }, + { name = "ml-dtypes" }, + { name = "namex" }, + { name = "numpy" }, + { name = "optree" }, + { name = "packaging" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/a6/4cebb4196f4e6284b35e68dc17dda9a4111a2e2bf21db8211de12881ef89/keras-3.15.1.tar.gz", hash = "sha256:92ae7c1dd7f61041953dc2d42253181ee099086f0b58ae36fcf98147a6f08a29", size = 1919818, upload-time = "2026-07-29T18:20:13.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/b3/c9b848bbdba18e765a8051917c0cc82585b64278ce87895f2e521a27438f/keras-3.15.1-py3-none-any.whl", hash = "sha256:836460e480930acbd19bb7a17e62f9ecad40e8a9af9a651fc8d0586a96d27e20", size = 2398595, upload-time = "2026-07-29T18:20:12.345Z" }, +] + [[package]] name = "kgb" version = "7.3" @@ -1797,6 +1992,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, ] +[[package]] +name = "libclang" +version = "18.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/ca35e19a4f142adffa27e3d652196b7362fa612243e2b916845d801454fc/libclang-18.1.1.tar.gz", hash = "sha256:a1214966d08d73d971287fc3ead8dfaf82eb07fb197680d8b3859dbbbbf78250", size = 39612, upload-time = "2024-03-17T16:04:37.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/49/f5e3e7e1419872b69f6f5e82ba56e33955a74bd537d8a1f5f1eff2f3668a/libclang-18.1.1-1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b2e143f0fac830156feb56f9231ff8338c20aecfe72b4ffe96f19e5a1dbb69a", size = 25836045, upload-time = "2024-06-30T17:40:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e5/fc61bbded91a8830ccce94c5294ecd6e88e496cc85f6704bf350c0634b70/libclang-18.1.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6f14c3f194704e5d09769108f03185fce7acaf1d1ae4bbb2f30a72c2400cb7c5", size = 26502641, upload-time = "2024-03-18T15:52:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1df62b44db2583375f6a8a5e2ca5432bbdc3edb477942b9b7c848c720055/libclang-18.1.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:83ce5045d101b669ac38e6da8e58765f12da2d3aafb3b9b98d88b286a60964d8", size = 26420207, upload-time = "2024-03-17T15:00:26.63Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/716c1e62e512ef1c160e7984a73a5fc7df45166f2ff3f254e71c58076f7c/libclang-18.1.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:c533091d8a3bbf7460a00cb6c1a71da93bffe148f172c7d03b1c31fbf8aa2a0b", size = 24515943, upload-time = "2024-03-17T16:03:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3d/f0ac1150280d8d20d059608cf2d5ff61b7c3b7f7bcf9c0f425ab92df769a/libclang-18.1.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:54dda940a4a0491a9d1532bf071ea3ef26e6dbaf03b5000ed94dd7174e8f9592", size = 23784972, upload-time = "2024-03-17T16:12:47.677Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2f/d920822c2b1ce9326a4c78c0c2b4aa3fde610c7ee9f631b600acb5376c26/libclang-18.1.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:cf4a99b05376513717ab5d82a0db832c56ccea4fd61a69dbb7bccf2dfb207dbe", size = 20259606, upload-time = "2024-03-17T16:17:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/de1db8c6d413597076a4259cea409b83459b2db997c003578affdd32bf66/libclang-18.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:69f8eb8f65c279e765ffd28aaa7e9e364c776c17618af8bff22a8df58677ff4f", size = 24921494, upload-time = "2024-03-17T16:14:20.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2d/3f480b1e1d31eb3d6de5e3ef641954e5c67430d5ac93b7fa7e07589576c7/libclang-18.1.1-py2.py3-none-win_amd64.whl", hash = "sha256:4dd2d3b82fab35e2bf9ca717d7b63ac990a3519c7e312f19fa8e86dcc712f7fb", size = 26415083, upload-time = "2024-03-17T16:42:21.703Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/e01dc4cc79779cd82d77888a88ae2fa424d93b445ad4f6c02bfc18335b70/libclang-18.1.1-py2.py3-none-win_arm64.whl", hash = "sha256:3f0e1f49f04d3cd198985fea0511576b0aee16f9ff0e0f0cad7f9c57ec3c20e8", size = 22361112, upload-time = "2024-03-17T16:42:59.565Z" }, +] + [[package]] name = "librosa" version = "0.11.0" @@ -2127,46 +2339,54 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -2285,6 +2505,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, ] +[[package]] +name = "namex" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/c0/ee95b28f029c73f8d49d8f52edaed02a1d4a9acb8b69355737fdb1faa191/namex-0.1.0.tar.gz", hash = "sha256:117f03ccd302cc48e3f5c58a296838f6b89c83455ab8683a1e85f2a430aa4306", size = 6649, upload-time = "2025-05-26T23:17:38.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/bc/465daf1de06409cdd4532082806770ee0d8d7df434da79c76564d0f69741/namex-0.1.0-py3-none-any.whl", hash = "sha256:e2012a474502f1e2251267062aae3114611f07df4224b6e06334c57b0f2ce87c", size = 5905, upload-time = "2025-05-26T23:17:37.695Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -2385,11 +2614,14 @@ wheels = [ [[package]] name = "nvidia-cublas" -version = "13.1.0.3" +version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, - { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, ] [[package]] @@ -2490,14 +2722,14 @@ wheels = [ [[package]] name = "nvidia-cudnn-cu13" -version = "9.19.0.56" +version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, ] [[package]] @@ -2505,7 +2737,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -2547,9 +2779,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -2575,7 +2807,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2596,11 +2828,11 @@ wheels = [ [[package]] name = "nvidia-cusparselt-cu13" -version = "0.8.0" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, - { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, ] [[package]] @@ -2614,11 +2846,11 @@ wheels = [ [[package]] name = "nvidia-nccl-cu13" -version = "2.28.9" +version = "2.29.7" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, ] [[package]] @@ -2780,6 +3012,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] +[[package]] +name = "openvino" +version = "2026.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "openvino-telemetry" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/25/5c3e836afd92c9c07a411ea9af9e9ac3fff40aae612e93c5c0a997da83a1/openvino-2026.2.1-21919-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7afed0219cd29fa73f54b7351ffab8e7c7fefb64290dd5264d7c447617ee09ff", size = 31169975, upload-time = "2026-06-17T09:20:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/62/c2/46b88970c6d8ff40703b58a96bec40c354ca1e6977511e01af0fa0f12b8a/openvino-2026.2.1-21919-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:efae5ceb94943aeab8a3be04241cbb89110070ce9bacfbfbe947d8d7ef691a82", size = 58367861, upload-time = "2026-06-17T09:21:02.968Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1f/e43283e3a2879b0ebdf1b4591461504255bfc639471ea3698a35ebcbcab2/openvino-2026.2.1-21919-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:6154b26f63804cb671e20b8872abccafbe88b15fdd41fbd35ffa08bb4ac5e29b", size = 28337803, upload-time = "2026-06-17T09:21:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/93/81/f4605ab2bdbb3daff7cadf7d5265e87c4fe95d01af00ae8e1638b9a4abe9/openvino-2026.2.1-21919-cp312-cp312-win_amd64.whl", hash = "sha256:6928a707c447dbd43dab62c8bbc24a7953fd1b5e1fbea0f89a4135ca1ee4083b", size = 76171228, upload-time = "2026-06-17T09:21:11.156Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/5788f1ce7906f33f003de3bb55c2477d9911c19f72d7ed3f7b719538d4a3/openvino-2026.2.1-21919-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddd393566ab5cdcf106028e532f0f3789d7796fac848284c4d2ef31e3b81ef8", size = 31170279, upload-time = "2026-06-17T09:21:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/41/7045a639e3879b0c858533f64c93e9c998f292b810dfaff58c23540c738a/openvino-2026.2.1-21919-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:00483723163c8d4d6f436ad0e4d05247494de4d280e4b065f67c537b440f4b56", size = 58367437, upload-time = "2026-06-17T09:21:20.215Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/3f402a78755c1c848128126f145fc32a6d091c2257c543522d9ca979456a/openvino-2026.2.1-21919-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:f9619c51027b6b0514e82ade09794b109a45553e8afcb073eedb1c8c1ffd43c4", size = 28344673, upload-time = "2026-06-17T09:21:23.873Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/13b9bfc20c02706d015e96e5b3e932227d658b3826b4aa5007cdc2455ed4/openvino-2026.2.1-21919-cp313-cp313-win_amd64.whl", hash = "sha256:197435104d18657a7cc4d05850615b913618ebb0d3baaec052fda25a2d831c74", size = 76171396, upload-time = "2026-06-17T09:21:29.03Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/1bd1e01be4eb7434711638b551521b3859f6df13a7b5cdaf591870ee9065/openvino-2026.2.1-21919-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f1dfd9d8ffbe21c2d40089c8137903432535b89ee8eb3d177b75604f4273ad4", size = 31151204, upload-time = "2026-06-17T09:21:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/43/65/b7f2a382e1da48c6cce109e7960714aa0a0608f2fb6b00388594b5d8edd2/openvino-2026.2.1-21919-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0ec716ddd84a63534613171d27c8657c8417734a1cccedeb40533e8673a12c6d", size = 58369839, upload-time = "2026-06-17T09:21:37.636Z" }, + { url = "https://files.pythonhosted.org/packages/45/4e/c0730932f7e0c34f627cedb569ac1f21222c4c5e0ed904a7f86e4508849d/openvino-2026.2.1-21919-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:6fb513f36dde11dcccf5143e44b37bb64368689e3d746c283e9907e181e32226", size = 28351270, upload-time = "2026-06-17T09:21:41.833Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2c/c4f651c675c0271efdf7733c1a1adc9bfbf6cfcb5fce384565582f65e646/openvino-2026.2.1-21919-cp314-cp314-win_amd64.whl", hash = "sha256:c53788fcf66b5059eb5feeaba8f96782f3b672ea760df7cebbb96a28467eb472", size = 76173523, upload-time = "2026-06-17T09:21:47.05Z" }, + { url = "https://files.pythonhosted.org/packages/18/e6/8c6942a06b86bdea8e55c630770946f5ef5d17a4131cec2ff677a1d836fb/openvino-2026.2.1-21919-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a4d99ffc01216fe7fbb41b246db2d92ae00a7e980de9e1c8ead5eac81ca3202", size = 31379331, upload-time = "2026-06-17T09:21:50.722Z" }, + { url = "https://files.pythonhosted.org/packages/38/71/890efb337ad5d0ab1e70bfb0e3382bbb9efaed1f67dd27b77a275acbec13/openvino-2026.2.1-21919-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:863c55be074494a0e879f780eb2a7b25cf102105fd1835af1046376fbcb35a93", size = 58413666, upload-time = "2026-06-17T09:21:55.225Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/56015860afcb19f20a684a2b1fa6f8662b53db7dde0b5219ce73e250d5f4/openvino-2026.2.1-21919-cp314-cp314t-manylinux_2_35_aarch64.whl", hash = "sha256:daf95cfd841070e17588f66826a59cccb562beceb98ce78637e3296dfecbce3c", size = 25494110, upload-time = "2026-06-17T09:21:59.279Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/a8fd84f8289003dec261840f32a06476c793830d4562c2c2d76724ffdcc6/openvino-2026.2.1-21919-cp314-cp314t-win_amd64.whl", hash = "sha256:7ebc506896b082acd744b479d400e090ed566c7d1ff64a0071f2d54460032f9a", size = 76338896, upload-time = "2026-06-17T09:22:05.122Z" }, +] + +[[package]] +name = "openvino-telemetry" +version = "2025.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/8a/89d82f1a9d913fb266c2e6dc2f6030935db24b7152963a8db6c4f039787f/openvino_telemetry-2025.2.0.tar.gz", hash = "sha256:8bf8127218e51e99547bf38b8fb85a8b31c9bf96e6f3a82eb0b3b6a34155977c", size = 18894, upload-time = "2025-07-07T10:29:51.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ac/5ab0ca0aa269ad3c73f7bfc3801b10e5f56f75a31bf68c1ae8bd51cf70a4/openvino_telemetry-2025.2.0-py3-none-any.whl", hash = "sha256:bcb667e83a44f202ecf4cfa49281715c6d7e21499daec04ff853b7f964833599", size = 25227, upload-time = "2025-07-07T10:29:50.189Z" }, +] + [[package]] name = "opt-einsum" version = "3.4.0" @@ -2789,6 +3057,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, ] +[[package]] +name = "optree" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/63/92328a17ab7836562fe0129e605f685a88db35ce98427c34ff48ee4ec157/optree-0.19.1.tar.gz", hash = "sha256:4497d1c9197b8c6842e511368163d318ce536521ebdcff8bebb7551dcdfac532", size = 177531, upload-time = "2026-05-06T02:32:39.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/a7/cb5567029a608a296b0ca224025d03bba0365b41df19085b9b580191f6f2/optree-0.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:96e5c7c3b9144f08ae40c3d9848cfbcfa36b6bead0f8215ad071d5922ee6c4a5", size = 424023, upload-time = "2026-05-06T02:30:57.732Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a1/3651fb32fa8617108204aa4056d283af742020e0987d106f41402005d800/optree-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d9d198343e1e6ced18bef0cbff84091c1877964fc4a121df33f18840e073a01", size = 394782, upload-time = "2026-05-06T02:30:59.239Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1e/676470909aa64d7aba7c5edf83b171dc83b7af901d9ebb8e6d7512fe913a/optree-0.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a1202371d9fe3aa75f3e886b1f871aac4991a655aadb65e54f58a3ae9388ab2", size = 413157, upload-time = "2026-05-06T02:31:00.339Z" }, + { url = "https://files.pythonhosted.org/packages/f4/41/1a4c58f2af5742b9d9e21ea9e45c6c3c49463b5e2a0537e84ead1e9597ca/optree-0.19.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d41ccc4c20bfeae01d1d221c057a6d026e84e32229664952eddcdbe4b9b71417", size = 476923, upload-time = "2026-05-06T02:31:01.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/f62167bd9d6f6c948b191a0943923404678d47100f777f4a8fb37816e6f8/optree-0.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d934f240b109c6891dd06b2e30400b123b8a4b6ed31dcd0db2ae2378d30a6e8", size = 475385, upload-time = "2026-05-06T02:31:02.836Z" }, + { url = "https://files.pythonhosted.org/packages/30/5e/5323c5fa3024fdd900bdd8f14621139ed844c2247bf1a26e7cf5c1116188/optree-0.19.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddeefb7ca799c09647e332ebc1a5f6c09888a5a0e51f2dff4ca55e65b42a8c14", size = 474406, upload-time = "2026-05-06T02:31:04.023Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/54e4c47e61a51504a5224c933722e0c8a69925aacec4c08175e9675aeb81/optree-0.19.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ce49f64f804f7f35f2f9c2a21e3ba94c090199fccdcfd40e3ded4426c5c175", size = 457596, upload-time = "2026-05-06T02:31:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/bba07c0b769586c6bd54e81f1f734cad103dbe30abbadee940fe7d3e330e/optree-0.19.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e0f02600832ab8d0f6c934dcb5c339e17a36938d477641a45798e02625ebe107", size = 417900, upload-time = "2026-05-06T02:31:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/6ae994bb47f9394b33912a14593f9247737dd6c3303811550e5a3e918107/optree-0.19.1-cp312-cp312-win32.whl", hash = "sha256:f10d58c1a17e1b32f9d9b5e1b9d1ad964d99c1113d9df0b9f62f2fe7dde19909", size = 317302, upload-time = "2026-05-06T02:31:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/31/97/d7e3ec79dcdde81f785a0446acf75fea77723f5ca4b98556350d7877986f/optree-0.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:06f5c8a4cf356a1a276ce5cec1be44719ed260690f79c036d04b4d427e801258", size = 341362, upload-time = "2026-05-06T02:31:09.689Z" }, + { url = "https://files.pythonhosted.org/packages/33/97/813afb84a81fd8ae65444730907c05f0775fd6c79d3359c9e84bd3370445/optree-0.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:a33bd23fc5c67ecb9ff491b75fde10cd9b53f47f8a876de842090e8c7a2437e1", size = 351838, upload-time = "2026-05-06T02:31:11.086Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7b/0f2f3c9d55dda5127624daf68ff802ab624b739dd4b32aef505dac0c8e02/optree-0.19.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:f144cfd65fb17c6aa2c51818614eb009e6052d3d6ace91f7e570b1318cdcac4c", size = 929090, upload-time = "2026-05-06T02:31:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/670d260dfd0532d64272dd6f7edd540a09d7040c0342b6cc6cf773568ea4/optree-0.19.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:39a006735d2a0a68751a3bc33d670184fddcd86db63b0293e1e819739e8105e4", size = 391528, upload-time = "2026-05-06T02:31:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/f4/96/46c15e80b0c97e2ba6aba11339008a37cabc5ccf55c31c6c60aecdb79638/optree-0.19.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d2cb43c36638f469f5d8f4cf638e914de90c62242d8bed29f1b4487e0346ab94", size = 398231, upload-time = "2026-05-06T02:31:15.519Z" }, + { url = "https://files.pythonhosted.org/packages/7e/39/9d7d22cdaeb9a40ace2485f91c5b7c5f3a7f688575e2621e436561211cc1/optree-0.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e70faa00ab69331f49f8337d45021bed09ae2265d1db72eea9d7817af2b73c64", size = 429852, upload-time = "2026-05-06T02:31:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/79/4c/1da9e8375e7b7fd9671dc5987682b042f6412c4d6fd9da03296403818d9f/optree-0.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1c5d21176b670407f4555aae40711668832599c4fb0627000c5ce3ed0d6e2dae", size = 398688, upload-time = "2026-05-06T02:31:18.113Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/cd2d178099618093f5a9fd1c9de80af2b428879922eae1e9f27f1002c8be/optree-0.19.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f658fa46305b2bdccdc5bb2cb07818aeaef88a1085499deda5be48a0a58d2971", size = 417560, upload-time = "2026-05-06T02:31:19.391Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/f22ff5632083b5032caa80208dd202f8e963ed4aac11afa0a0f6a307fd68/optree-0.19.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:e757079d44a00319447f43df5c51e55bf9b62d9f05eea0e2db5ff7c7ca5ec71d", size = 482937, upload-time = "2026-05-06T02:31:20.799Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d4/7499d28be8b11eb40668262d27802119fe7e6ec4cd8816b76a1acd7b08f5/optree-0.19.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9690c132822d9dee479cf7dff8cc52a67c8af42a4f7529d21f0f4f1d99e4c84e", size = 477864, upload-time = "2026-05-06T02:31:22.077Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6e/6c6fa6f1159ac68f4ee7666610127fb4c14d47a2fa7a0a48de3aecc24d4b/optree-0.19.1-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:544b70958dbd7e732bc6874e0180c609c9052115937d0ec28123bb49c1a574aa", size = 478319, upload-time = "2026-05-06T02:31:23.266Z" }, + { url = "https://files.pythonhosted.org/packages/68/b5/8a2427bbe4ee59e2ce26a14125728e3b48c7030c80984ba07d0e5d804d37/optree-0.19.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dde5b756946c1f1458aeab248a7a9b0c01bb06b5787de9f06d52ad38b745557", size = 462379, upload-time = "2026-05-06T02:31:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0c/a073eeaea4d4f68e02d5883ed8268746a296e6749e3c46e0124ca45f306c/optree-0.19.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:f1d7838e8b1b62258abd73a5911afad1153ed76822070558c3ba7e0bb5b44192", size = 423061, upload-time = "2026-05-06T02:31:25.652Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/637b151d071ca94aea0087322f470ce84c5828ef6b9c0de7dc7b4420a1cf/optree-0.19.1-cp313-cp313-win32.whl", hash = "sha256:9870d33ec50cca0c46c2b431cea24c6247457da15fd4ad66ccb8ab78145c1490", size = 317439, upload-time = "2026-05-06T02:31:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/49b8a8d9e94c57c6fa5008953f84a1c36a4119a3b90dcb7df745f1f05a00/optree-0.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:aa0845b725bcd0029e179cf9b4bc2cc016c7358e56fc7c0d2c43bf4d514c96cf", size = 343906, upload-time = "2026-05-06T02:31:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/1ae0a9685f5301f454f01d2490065b98df6956f90b1b2fd1cea9daa6d820/optree-0.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:6f0b1efc177bed6495f78d39d5aa495ccb31cc20bcf64bb1b806ca4c919f4049", size = 353146, upload-time = "2026-05-06T02:31:29.976Z" }, + { url = "https://files.pythonhosted.org/packages/9c/77/4c8108cbce2c8ae2aa4b6adc7874082882e32cf131cb64b3a4411f50dec4/optree-0.19.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b964bcdb5cfe367cdf56447e80ba5a49123098d8c4e8e68b41c20890eec6e58e", size = 469723, upload-time = "2026-05-06T02:31:31.425Z" }, + { url = "https://files.pythonhosted.org/packages/64/33/ce9b54646ed4ab5773a9dc59767dadfe3de8bb2e97a3ed19205b995a7a31/optree-0.19.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ccec0ee5a565eb5aa4fe30383016a358627ea23d968ec8ab28b1f2ce4ce3d8", size = 437071, upload-time = "2026-05-06T02:31:33.027Z" }, + { url = "https://files.pythonhosted.org/packages/79/55/04260128a726e3550b49467a65bff859452897144b68bae54b2f2e5c27f1/optree-0.19.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:672588408906051d3e9a99aca6c0af93c6e0b638137a701418088eaa0bb6c719", size = 433503, upload-time = "2026-05-06T02:31:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/d6/99/6a4cc29389667efa089a0c476b7c36b7d0a66e10dd2d8c2d19c776977566/optree-0.19.1-cp313-cp313t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d16cef4d0555d49ce221d80249f1285a2d3faf932e451c3ce6cb8ccb6a846767", size = 496305, upload-time = "2026-05-06T02:31:35.835Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/506aa1a64abce69e2f4cec9cdac3da0cae207cf04c5e70e7f143bf8b29d8/optree-0.19.1-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc2db0b449baff53aa7e583306101de0ade5e5ae9e6fce78400eb2319bbd23dc", size = 492759, upload-time = "2026-05-06T02:31:37.265Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/2210de9a68722007fe007da3cae1a5971b92fc8113b5eecef66a04637959/optree-0.19.1-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:76b3e9e5d37e6b05ec82fff91758c8c0e27e159b35faea4b33d5eb975d720257", size = 495447, upload-time = "2026-05-06T02:31:38.505Z" }, + { url = "https://files.pythonhosted.org/packages/d9/61/40c3463e52914d552c66c760ae15e673137c4cc1d1d9f8da0d745656193a/optree-0.19.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03faa8e23fdaf3a18f9a1568c2c0eb0641a6aa05baf3a20639bd11fb34664700", size = 475564, upload-time = "2026-05-06T02:31:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/1603680fa924e68e5697c1229510c0645db0a9c633a12d1a9bfdbfc9cb74/optree-0.19.1-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:a9b9c7e9148ec470124dc4c1d1cd1485dbeb35973357b5911b181a79090426d2", size = 442414, upload-time = "2026-05-06T02:31:40.908Z" }, + { url = "https://files.pythonhosted.org/packages/a5/58/34820bab11f28ba6b03fe9e151880ad591b43f26648f058c94451fbdfc3a/optree-0.19.1-cp313-cp313t-win32.whl", hash = "sha256:ab8ad9803376d553a2958471b6bb6842b7e15888e19cc6aeb76da96c6afd948d", size = 348644, upload-time = "2026-05-06T02:31:42.038Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2b/0be3f8b9765f366e3e12d0590e9c6514de110d0c5b3b9002f49e56bf15b1/optree-0.19.1-cp313-cp313t-win_amd64.whl", hash = "sha256:afd4abeb2783b2367093287bc6268ac9af244b20c8d9b01696ccfe817483b66c", size = 382445, upload-time = "2026-05-06T02:31:43.166Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/8c0882cdd42e28a23c1998297c8ad1202194510cbba8b050251429c641c0/optree-0.19.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b9120510d3f951e268e417a3f64f335bc1c539e1e80bff2129ddc6fb60ac7b56", size = 388040, upload-time = "2026-05-06T02:31:44.661Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/4e16e26375c56c9e40760697af4e2b72f196c2099e96cc783b63dcc862a8/optree-0.19.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:e1951ddc870f67430310fd17393971c30510ee9fd290525b44c12afe25f3c307", size = 927808, upload-time = "2026-05-06T02:31:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/87/ff1c6bb6b79a5d0b70b83f7ae8b78811a406a749b3ae4478a2122a7afb66/optree-0.19.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ae9d42718ebf985cdad3182364b5cf829193b8fd2c6d993fbb4111d38e2bdf96", size = 390981, upload-time = "2026-05-06T02:31:47.38Z" }, + { url = "https://files.pythonhosted.org/packages/82/25/fc648710102960f87d18cd8fc8a24afe14a5ec7827c64dfb1340230c0794/optree-0.19.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:930268ebfdebca43a8808f6293910d6ade2fe7c84fa784692017d7120d285226", size = 397756, upload-time = "2026-05-06T02:31:48.76Z" }, + { url = "https://files.pythonhosted.org/packages/24/f6/a7bf5d75a6481038bbb61846d87d43124d63741385796ef7b37d326f46bd/optree-0.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b2757c5d922aab76cfc9b870c373fb35209c2094e3c912733b326c043e85a0c6", size = 427424, upload-time = "2026-05-06T02:31:49.838Z" }, + { url = "https://files.pythonhosted.org/packages/49/cc/14dd93887295859457e507fc46a847b68ae8f20c42b2fde4d8a749c94bbc/optree-0.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b17a7b70ff8bd406c2142914c5ab0a57f8bcfb9f52181f7012e32406bbdbfdda", size = 398242, upload-time = "2026-05-06T02:31:51.262Z" }, + { url = "https://files.pythonhosted.org/packages/17/b5/ac51aa118dd918761519fbc031865b1d6f850453e9a7ac0c3da21109c4f0/optree-0.19.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:987bba55366917d9829f45b5ee86499ecc87a30e9103072db9ab8d67f9958179", size = 419568, upload-time = "2026-05-06T02:31:52.349Z" }, + { url = "https://files.pythonhosted.org/packages/ec/41/25144e61f76278b9e0a5d4189c7083fe853164c5f7328a1f5aac43d964c2/optree-0.19.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d3bba2af7a5fce0c25e99024688e68dfe9be41e3d6e92720febbefdc879fba38", size = 482797, upload-time = "2026-05-06T02:31:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/2c76c7ce937323988770c41126e0e380bcb73a816f68a767f23b5c33aced/optree-0.19.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dae6c247cc8751bd2f167951468769f5c98f8cfdae31c0db0f2eb4145a6ec560", size = 479794, upload-time = "2026-05-06T02:31:54.843Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ca/bd9553f94bec0bc7860f10ae177c14ca265ab19ddb463122be22fa335ee8/optree-0.19.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17a986fd91ccdc18bb7b587ca1f508c1761580a93517e6db33a13b22e46acb9b", size = 481084, upload-time = "2026-05-06T02:31:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1a/4834b1f2fb1847412353d7342eb7a1d001a4f3bd9d24155e057135a4aa44/optree-0.19.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d0e1493429ae1d1a5e34855774ee604c974a8f76656bd0e562cdbf9466c9b1f", size = 462955, upload-time = "2026-05-06T02:31:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/f4/88/598fb91c06fee3d8b08568779b011225dc2b66140927bd0b2b2d9b40a566/optree-0.19.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:f61a01ed9991193ed6f3db8e956ede05218190a32ca2ddfb71cfc40c8daba1d5", size = 423754, upload-time = "2026-05-06T02:31:59.291Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/83c64ecadc686e08310fc9c20bc0bbe6453e89b69257e08887818dac7886/optree-0.19.1-cp314-cp314-win32.whl", hash = "sha256:b0c920579bddc3b18a0e051850f017618e24efcc19ba83dcd415cf74db5fd904", size = 325214, upload-time = "2026-05-06T02:32:00.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/c3/4f2f318b98465376bbb7a06a33da553c688b3ed39dafbb8307f824eef74a/optree-0.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:50d77b91a8cd01adf422472b7edf39fc445b0268816176a868a385d28f8367c2", size = 351654, upload-time = "2026-05-06T02:32:01.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ab/55d7508e87055c730fe7207cfd0c45183a07ddf1f91d9e73d017a7f8c1f4/optree-0.19.1-cp314-cp314-win_arm64.whl", hash = "sha256:c682ab6711b7a623503711fa661a2bba7886e1c21dc06c3b7febba101b458051", size = 361610, upload-time = "2026-05-06T02:32:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/4f7facd482d56079b7adb8ce3fede19f41629bc0463e8ee25907f1dba36c/optree-0.19.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:068edb89fadd94f6f57fdb51f4ad2c764b5a0bfd00903c55ffe433c2863a8037", size = 469130, upload-time = "2026-05-06T02:32:04.395Z" }, + { url = "https://files.pythonhosted.org/packages/92/60/f7539012aa8a7488c1e34f66b76eadc384c3152dd9800973f1b5fe045dfd/optree-0.19.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a609c90e4f64e4f3e2b5b3cc022210314834737e0e61a745485e33b33eae773b", size = 437286, upload-time = "2026-05-06T02:32:05.527Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3f/a5f8fb3ec3840f885de52d7a793ba57ace17990e3a9b3797218425ffe842/optree-0.19.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfae64c4c371640a4b3e2a9e3e6aa3a3e8cdf2da5247a88fef5b632614b948a6", size = 431954, upload-time = "2026-05-06T02:32:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/dc/6d0ef14bc82bd54046c1a066d25fa6854123a6b29fd691f1f95dec3ab45f/optree-0.19.1-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:470742544ff2d4b63843023f38dcfb83e82c3a9877c783dee0e69cbb974de6d1", size = 494631, upload-time = "2026-05-06T02:32:08.038Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9a/9e183c610c414cba581f9afda7610589d89cae229d627b14f8480425d975/optree-0.19.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a74e0656ccef45b1fec07b9d964ce97f3def8bab73711f56175076c4259884f", size = 491786, upload-time = "2026-05-06T02:32:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/266b9de8eb5b16bfe7010c90c55840517d5d61ee6e0ca64901440296d97a/optree-0.19.1-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55841132ba8a34dbbd85e0c2cf990602384eea0e4638df986cd3266482f4a17", size = 490876, upload-time = "2026-05-06T02:32:11.388Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/42a8ca6277ef93d47ab0986e30a25134206afe0c6e6c3425c8736b2677ba/optree-0.19.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5f8383952f18d5a4ec6b248d8ae6fe27012434ad9750aa33a821ad4846da5af", size = 475079, upload-time = "2026-05-06T02:32:12.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/91/e363f4adda292f891ca0cf5748010fea955737bdf494cc11d4c3bcda6935/optree-0.19.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:de8acbed5965beae6f6b0456fcb8d1afaea1fe300810739e88645e22138849bc", size = 440119, upload-time = "2026-05-06T02:32:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/489d22ef3cadb2f5f3bbd6e6099d17b5a521ff533e086f78f005c3358017/optree-0.19.1-cp314-cp314t-win32.whl", hash = "sha256:312048e69dc88de26915674f961bf38980a765a6b48ead2f1672858a39402c41", size = 357465, upload-time = "2026-05-06T02:32:15.424Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/7f48b7034ff75d2eb3e94e2196709ddbf762798fb621f9508899fa66b44e/optree-0.19.1-cp314-cp314t-win_amd64.whl", hash = "sha256:60e9345405d7b06cafdf1b1dd2e2261ceddddce10f35729240f90e2bab845a0b", size = 397783, upload-time = "2026-05-06T02:32:16.853Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/6d6f93416c66820cb8753e65b5ff43c47480af9c4911bd2b8406ff0f7f27/optree-0.19.1-cp314-cp314t-win_arm64.whl", hash = "sha256:4e103e212d1e8fe0399ed076eff80a905fb14929729bbd994d3660110a27a252", size = 396064, upload-time = "2026-05-06T02:32:18.077Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -2861,71 +3201,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -3316,95 +3658,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/d6/f5/9be84ec8bcbe77d79 [[package]] name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform != 'win32'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "iniconfig", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pluggy", marker = "python_full_version < '3.14'" }, - { name = "pygments", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.14' and sys_platform == 'win32'" }, - { name = "iniconfig", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pluggy", marker = "python_full_version >= '3.14'" }, - { name = "pygments", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-json-report" -version = "1.5.0" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pytest-metadata", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4f/d3/765dae9712fcd68d820338908c1337e077d5fdadccd5cacf95b9b0bea278/pytest-json-report-1.5.0.tar.gz", hash = "sha256:2dde3c647851a19b5f3700729e8310a6e66efb2077d674f27ddea3d34dc615de", size = 21241, upload-time = "2022-03-15T21:03:10.2Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/35/d07400c715bf8a88aa0c1ee9c9eb6050ca7fe5b39981f0eea773feeb0681/pytest_json_report-1.5.0-py3-none-any.whl", hash = "sha256:9897b68c910b12a2e48dd849f9a284b2c79a732a8a9cb398452ddd23d3c8c325", size = 13222, upload-time = "2022-03-15T21:03:08.65Z" }, -] - -[[package]] -name = "pytest-metadata" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, -] - -[[package]] -name = "pytest-rerunfailures" -version = "15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/78/e6e358545537a8e82c4dc91e72ec0d6f80546a3786dd27c76b06ca09db77/pytest_rerunfailures-15.1.tar.gz", hash = "sha256:c6040368abd7b8138c5b67288be17d6e5611b7368755ce0465dda0362c8ece80", size = 26981, upload-time = "2025-05-08T06:36:33.483Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/30/11d836ff01c938969efa319b4ebe2374ed79d28043a12bfc908577aab9f3/pytest_rerunfailures-15.1-py3-none-any.whl", hash = "sha256:f674c3594845aba8b23c78e99b1ff8068556cc6a8b277f728071fdc4f4b0b355", size = 13274, upload-time = "2025-05-08T06:36:32.029Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet", marker = "python_full_version < '3.14'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -3451,16 +3716,16 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] name = "pytorch-tokenizers" -version = "1.2.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sentencepiece", marker = "python_full_version < '3.14'" }, @@ -3468,14 +3733,18 @@ dependencies = [ { name = "tokenizers", marker = "python_full_version < '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/cc/9d77d52ab1422cacfa0d4ebb144dc85edcf45c230cb89627721f1d357229/pytorch_tokenizers-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a2893a30ad3db3e0d690dc7669b0b6da68dde2fd4d81dcad608e9e66ea8848ec", size = 1111123, upload-time = "2026-04-01T00:19:00.985Z" }, - { url = "https://files.pythonhosted.org/packages/1c/30/5279d5f6415596ae6b768867dd082e4a2af22b6bc9445b0fc9a205c77e1c/pytorch_tokenizers-1.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7619cf3c9f39d0ddbdfb1e2f34cd2a1e4971d6ea6d5306e3a9efb1cfc73b8c7c", size = 1441086, upload-time = "2026-04-01T00:19:02.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/dd634211e2be38067b77d4dadd59c726f8cfc19bb1ce25fd048e4c8fa0d6/pytorch_tokenizers-1.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:251f959843fd6a7af204b58f71d11b73c39e6eeb0491720170301da2ad8c251b", size = 1556129, upload-time = "2026-04-01T00:19:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/6fafe2ae2d09b05dea79b7696047f287bc229e366e476e9ea67dfa17ff9e/pytorch_tokenizers-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:97a82db21665023b25afb869c1bf07c31047c442df15d33e036c4712d07cb109", size = 848594, upload-time = "2026-04-01T00:19:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/600bd39381e620ff8c004a1e1215cec0fc2e9f9dbdbe77275481168c069c/pytorch_tokenizers-1.2.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:9d1f4f2eb0f2ba5ce97c106c3af9ce7b5349028bf99229fae906cb836b249525", size = 1106909, upload-time = "2026-04-01T00:19:06.488Z" }, - { url = "https://files.pythonhosted.org/packages/46/3f/7d39b2ad4d1cfcdf2d37a9ac3caaaa9f5a4a87c75b179eee1d0c17f010c1/pytorch_tokenizers-1.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a3d559e92145ce9d2d48b512beccd5cba8c9da920d23f4f373e376e571fe5647", size = 1442674, upload-time = "2026-04-01T00:19:08.366Z" }, - { url = "https://files.pythonhosted.org/packages/18/c7/4f6abd29eb68022458da29845caa12b525300afd885236ceb9a31358fb03/pytorch_tokenizers-1.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:27e954013155ebe38bb25d1f5e5279315e145cdb31b479e9eaf74b6a793fdaa4", size = 1556922, upload-time = "2026-04-01T00:19:09.88Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d7/5a2a30613c73a613b03ebd1dd8ecf695e7a9490ede195ffb2862fd9471d6/pytorch_tokenizers-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:f4304cbc66eb46c98ba21de46ae030ea08a65391ed18cde4ca3f5b48955313f0", size = 848532, upload-time = "2026-04-01T00:19:11.043Z" }, + { url = "https://files.pythonhosted.org/packages/26/91/c3d2564c80587fa501c29652c317e8a4eec6f7b13c7b53f1de6bc0abddc0/pytorch_tokenizers-1.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e3ad0a9c474396267e0f70273d74fec402cb88541afb6617f98c6042e71faedb", size = 1112739, upload-time = "2026-08-05T19:37:40.356Z" }, + { url = "https://files.pythonhosted.org/packages/d6/cf/69d6d3c6fad65f0f1c63a8f0f0db85009a9db4e25fa50d573ead4e527cbb/pytorch_tokenizers-1.4.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84ee954611ec869f875dd23618c66c15b718c2a4eed639fefb72e7806091ff76", size = 1451907, upload-time = "2026-08-05T19:37:41.622Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b3/4d38b4e5cd951df08511e0a11637baad56dec6ca82bca7b594c8d0c85e59/pytorch_tokenizers-1.4.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3feb33e04dcaa2cea6dcbe99aba22da2ad81d1ed305bc0af52acab5b3f8ff094", size = 1568882, upload-time = "2026-08-05T19:37:43.108Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/8ab61ce7f666b4b06016a1517836c24f2dd06b7e6d7d5255200aeacd2656/pytorch_tokenizers-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:40affc76c3f98ef5c26910b68b4dcd27b5d9209e2eccfd18e4a9cdc0f0048f47", size = 857910, upload-time = "2026-08-05T19:37:44.502Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/b811e0990964e82d0616707bf53bc6fce4692580aef42351ca1ae3041b8a/pytorch_tokenizers-1.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:2561c5187e028d906fc24c98288d183e05e8b2b27d565e5462cc2f5611801653", size = 1112791, upload-time = "2026-08-05T19:37:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/f7/51/3494837ac254666e3c4dfca14d2bf8a08bd8a3d454f066ba7701462f5928/pytorch_tokenizers-1.4.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a647af855ec93485a6f97074a78a906b0c4d227896f2a8498dd674d5e8be14fd", size = 1451825, upload-time = "2026-08-05T19:37:47.32Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3f/ae7e9d6ed87b3089aa88e8b00c5b474ab5f29c28875a3f44156a5caaf2aa/pytorch_tokenizers-1.4.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:944d149ece8fe278d7aee6520e82bed14f6d14c890af1f45cc3399fa4c31ad44", size = 1568950, upload-time = "2026-08-05T19:37:48.752Z" }, + { url = "https://files.pythonhosted.org/packages/c6/52/7e1bdab83e7c2a16846f2733c3f1b7ca386cb108e2f84c695ab26d9b1abe/pytorch_tokenizers-1.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:75b16fe8bfb69686f4b5c5435deae6eeec859bd6aaf85b66bda743263db796bc", size = 857879, upload-time = "2026-08-05T19:37:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/162945a49f2c9147c2db24bd9a6c21fc550571e0f9071ad3f24eb4625c44/pytorch_tokenizers-1.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:97ee2d2ae991a0ca7e26b4bc6215293bb6498d5090efffb8300064d0e4f874cb", size = 1113125, upload-time = "2026-08-05T19:37:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e9/a999083759060840880d441ee0c8ec7859fdf0ae3c87d35ce825c976f6d5/pytorch_tokenizers-1.4.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:60cbe48d2b81b250f2c2fc85cfa89f5f4247e451ee968a68d958855447dbc855", size = 1451956, upload-time = "2026-08-05T19:37:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/61ca1ccc90b7efcd998fb0fa5f1d446a21c0d3978101c60f514e3b65802b/pytorch_tokenizers-1.4.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b2d99c7db1d0033cf76818cbc786cdcc2f8582677da74cf60bdef504e5b9dad2", size = 1570276, upload-time = "2026-08-05T19:37:54.73Z" }, + { url = "https://files.pythonhosted.org/packages/81/1d/558212519a4704baad64535ad14d568b37c723664736c3127715b58b0955/pytorch_tokenizers-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:ebc91f9c010d1eea5291028e2e0352e63e2ea147e2a1b05e308134f247d016dd", size = 858339, upload-time = "2026-08-05T19:37:56.221Z" }, ] [[package]] @@ -3712,24 +3981,26 @@ wheels = [ [[package]] name = "safetensors" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] [[package]] @@ -3926,11 +4197,11 @@ wheels = [ [[package]] name = "setuptools" -version = "81.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -3993,11 +4264,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] @@ -4080,15 +4351,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -4112,6 +4383,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "tensorflow" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "astunparse" }, + { name = "flatbuffers" }, + { name = "gast" }, + { name = "google-pasta" }, + { name = "grpcio" }, + { name = "h5py" }, + { name = "keras" }, + { name = "libclang" }, + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "opt-einsum" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "six" }, + { name = "termcolor" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/59/27adba20bbd1088b00fc0e9232aa21493b4800af2299eb6005017a6053f4/tensorflow-2.21.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:56ecd7d47429acbe1df2694d50b75bf9fc3995ac92cb367cd9af6c4780ead712", size = 223488205, upload-time = "2026-03-06T17:24:03.48Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f139fbbd4e81a2e556170718698acf518a71a84927fa1dc1f5935b6ab3d2/tensorflow-2.21.0-cp312-cp312-manylinux_2_27_aarch64.whl", hash = "sha256:b07d15737406533898e36c7ef7944b1be18e9028dc521b9229952c537bbc5552", size = 281946471, upload-time = "2026-03-06T17:24:11.896Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d7/6e71b4ded8ce99cd21add95611ca14af6e9ad4f2baeabdeb79a4a6b3cb1f/tensorflow-2.21.0-cp312-cp312-manylinux_2_27_x86_64.whl", hash = "sha256:b3b95643c4e70eb925839938fb35cbe142f317ec84af6844ee61513713bb13c0", size = 572611111, upload-time = "2026-03-06T17:24:26.209Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0d/4ee4bc074597b41c9c00dc97b4418ef1eb8736fe9186ffdb3961efdfb730/tensorflow-2.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:27ba0682572b1e50a0db1ee74cfb787eafcfdb1b751bbbf401fed62fe32a92e0", size = 350945509, upload-time = "2026-03-06T17:24:41.65Z" }, + { url = "https://files.pythonhosted.org/packages/40/09/268b45a61be2bce136dabf3a3cd7099c8a984ae398198f71920b4c60c502/tensorflow-2.21.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a145ed46c58192b7c3f9916d070caf4f6afc6dc7e5511f83dd97677f4c4947f4", size = 223766900, upload-time = "2026-03-06T17:24:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/72/72/343b86b4c9bfe28e81f749439f908c1e26aeac73d9f12b8dcdb996eb8ecb/tensorflow-2.21.0-cp313-cp313-manylinux_2_27_aarch64.whl", hash = "sha256:a10abdfb8b1189210c251021a3f153b7ccc52a8e6521351f3dc3331e8ba593e0", size = 282213003, upload-time = "2026-03-06T17:24:59.859Z" }, + { url = "https://files.pythonhosted.org/packages/86/6c/10d075ffc09754c7f10e749ba3c9d46dd809fb007990c7f788128044180c/tensorflow-2.21.0-cp313-cp313-manylinux_2_27_x86_64.whl", hash = "sha256:e9d8da8dcab9650efb45f032ba70af2f016f907e6e0c6bda29dd101bba945406", size = 572881074, upload-time = "2026-03-06T17:25:14.453Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/dedad8403e7b0036d99be4878987693b7b7f62097eb8537fa6ce62ea131c/tensorflow-2.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:76cccbe0a95d9392dee1ae501ae0656b6c73c1cac29a7f8f32d570e0670863f7", size = 351205371, upload-time = "2026-03-06T17:25:33.144Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -4205,10 +4522,10 @@ wheels = [ [[package]] name = "torch" -version = "2.11.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, @@ -4220,39 +4537,35 @@ dependencies = [ { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, { name = "setuptools" }, { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "triton", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, - { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, - { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, - { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, - { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, - { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, - { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, - { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, - { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, - { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, - { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, ] [[package]] name = "torchao" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/fe/a4036a8e80fa800c92dbcbf75f541cd4c106248b6b579db6dab1800f616a/torchao-0.17.0-cp310-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87a418ce0ec064a821ceab83c921b501acef0ce9a6ccd1be358fcd16c3ae8c58", size = 3206172, upload-time = "2026-03-30T22:25:52.974Z" }, - { url = "https://files.pythonhosted.org/packages/c9/37/ef37ca885265e5f79a168616767dd416a3cea1cc3b28bb6b503ce4a5b652/torchao-0.17.0-py3-none-any.whl", hash = "sha256:02eba449036715b9ae784fbaa1a6f97994bb7b0421ce92d1d5d1c08e5bd6d349", size = 1200680, upload-time = "2026-03-30T22:25:54.457Z" }, + { url = "https://files.pythonhosted.org/packages/19/55/ed9ad98f0f09d5a1124d09830043d13a39e63539f9590d2bdb6d71cbc4a4/torchao-0.18.0-cp310-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6540b148e40ba81cbd4de86392225a076a1591146e9cebb099b3b234ba9feebe", size = 3372585, upload-time = "2026-08-03T19:43:10.993Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4d/485477bb8f05bd501016059c6d8abd742f830cb1b24ab7704e086c7cc35a/torchao-0.18.0-py3-none-any.whl", hash = "sha256:5c2b4485341bf28b7fed2c4fc95b9f298e209f41685350f067de85527a05585e", size = 1369798, upload-time = "2026-08-03T19:43:12.649Z" }, ] [[package]] @@ -4284,7 +4597,7 @@ wheels = [ [[package]] name = "torchvision" -version = "0.26.0" +version = "0.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -4292,26 +4605,22 @@ dependencies = [ { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/e7/56b47cc3b132aea90ccce22bcb8975dec688b002150012acc842846039d0/torchvision-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c409e1c3fdebec7a3834465086dbda8bf7680eff79abf7fd2f10c6b59520a7a4", size = 1863502, upload-time = "2026-03-23T18:12:57.326Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ec/5c31c92c08b65662fe9604a4067ae8232582805949f11ddc042cebe818ed/torchvision-0.26.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:406557718e62fdf10f5706e88d8a5ec000f872da913bf629aab9297622585547", size = 7767944, upload-time = "2026-03-23T18:12:42.805Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d8/cb6ccda1a1f35a6597645818641701207b3e8e13553e75fce5d86bac74b2/torchvision-0.26.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d61a5abb6b42a0c0c311996c2ac4b83a94418a97182c83b055a2a4ae985e05aa", size = 7522205, upload-time = "2026-03-23T18:12:54.654Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a9/c272623a0f735c35f0f6cd6dc74784d4f970e800cf063bb76687895a2ab9/torchvision-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:7993c01648e7c61d191b018e84d38fe0825c8fcb2720cd0f37caf7ba14404aa1", size = 4255155, upload-time = "2026-03-23T18:12:32.652Z" }, - { url = "https://files.pythonhosted.org/packages/da/80/0762f77f53605d10c9477be39bb47722cc8e383bbbc2531471ce0e396c07/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4", size = 1860809, upload-time = "2026-03-23T18:12:47.629Z" }, - { url = "https://files.pythonhosted.org/packages/e6/81/0b3e58d1478c660a5af4268713486b2df7203f35abd9195fea87348a5178/torchvision-0.26.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a39c7a26538c41fda453f9a9692b5ff9b35a5437db1d94f3027f6f509c160eac", size = 7727494, upload-time = "2026-03-23T18:12:46.062Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/d9ab5d29115aa05e12e30f1397a3eeae1d88a511241dc3bce48dc4342675/torchvision-0.26.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b7e6213620bbf97742e5f79832f9e9d769e6cf0f744c5b53dad80b76db633691", size = 7521747, upload-time = "2026-03-23T18:12:36.815Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1b/f1bc86a918c5f6feab1eeff11982e2060f4704332e96185463d27855bdf5/torchvision-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:4280c35ec8cba1fcc8294fb87e136924708726864c379e4c54494797d86bc474", size = 4319880, upload-time = "2026-03-23T18:12:38.168Z" }, - { url = "https://files.pythonhosted.org/packages/66/28/b4ad0a723ed95b003454caffcc41894b34bd8379df340848cae2c33871de/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23", size = 1951973, upload-time = "2026-03-23T18:12:48.781Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/7a89096e6cf2f3336353b5338ba925e0addf9d8601920340e6bdf47e8eb3/torchvision-0.26.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3daf9cc149cf3cdcbd4df9c59dae69ffca86c6823250442c3bbfd63fc2e26c61", size = 7728679, upload-time = "2026-03-23T18:12:26.196Z" }, - { url = "https://files.pythonhosted.org/packages/69/1d/4e1eebc17d18ce080a11dcf3df3f8f717f0efdfa00983f06e8ba79259f61/torchvision-0.26.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:82c3965eca27e86a316e31e4c3e5a16d353e0bcbe0ef8efa2e66502c54493c4b", size = 7609138, upload-time = "2026-03-23T18:12:35.327Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a4/f1155e943ae5b32400d7000adc81c79bb0392b16ceb33bcf13e02e48cced/torchvision-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ebc043cc5a4f0bf22e7680806dbba37ffb19e70f6953bbb44ed1a90aeb5c9bea", size = 4248202, upload-time = "2026-03-23T18:12:41.423Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c8/9bffa9c7f7bdf95b2a0a2dc535c290b9f1cc580c3fb3033ab1246ffffdeb/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3", size = 1860813, upload-time = "2026-03-23T18:12:39.636Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ac/48f28ffd227991f2e14f4392dde7e8dc14352bb9428c1ef4a4bbf5f7ed85/torchvision-0.26.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:9a904f2131cbfadab4df828088a9f66291ad33f49ff853872aed1f86848ef776", size = 7727777, upload-time = "2026-03-23T18:12:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f3e572efe62ad645017ea847e0b5e4f2f638d4e39f05bc011d1eb9ac68d4806", size = 7522174, upload-time = "2026-03-23T18:12:29.565Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ba/1666f90bc0bdd77aaa11dcc42bb9f621a9c3668819c32430452e3d404730/torchvision-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:114bec0c0e98aa4ba446f63e2fe7a2cbca37b39ac933987ee4804f65de121800", size = 4348469, upload-time = "2026-03-23T18:12:24.44Z" }, - { url = "https://files.pythonhosted.org/packages/45/8f/1f0402ac55c2ae15651ff831957d083fe70b2d12282e72612a30ba601512/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da", size = 1860826, upload-time = "2026-03-23T18:12:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6a/18a582fe3c5ee26f49b5c9fb21ad8016b4d1c06d10178894a58653946fda/torchvision-0.26.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7058c5878262937e876f20c25867b33724586aa4499e2853b2d52b99a5e51953", size = 7729089, upload-time = "2026-03-23T18:12:31.394Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9b/f7e119b59499edc00c55c03adc9ec3bd96144d9b81c46852c431f9c64a9a/torchvision-0.26.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8008474855623c6ba52876589dc52df0aa66e518c25eca841445348e5f79844c", size = 7522704, upload-time = "2026-03-23T18:12:20.301Z" }, - { url = "https://files.pythonhosted.org/packages/d0/6a/09f3844c10643f6c0de5d95abc863420cfaf194c88c7dffd0ac523e2015f/torchvision-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e9d0e022c19a78552fb055d0414d47fecb4a649309b9968573daea160ba6869c", size = 4454275, upload-time = "2026-03-23T18:12:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/c1cab1ecbb3ff1a380a3f99283db1dee61b8afe354f6352c643b65937130/torchvision-0.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee", size = 1856020, upload-time = "2026-07-08T16:07:52.182Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4c/95233776e2def960e5abb7a07931230a545f43717a56a1e1140162033598/torchvision-0.28.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a", size = 7842127, upload-time = "2026-07-08T16:07:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/93/e4/e9b2495d0d57b9f60d63c57d0a910410a81b4b073bf70917bef815291119/torchvision-0.28.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940", size = 7675040, upload-time = "2026-07-08T16:07:58.017Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9c/55ed9cb6dfe3ee9c837df5cd0e758372e5829aa38b8dd71343aa632cc4e2/torchvision-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d", size = 4085785, upload-time = "2026-07-08T16:07:50.928Z" }, + { url = "https://files.pythonhosted.org/packages/20/55/08a726c14c67b37c8aca04b077766909f1c7ed23f76116884fe63b9bd033/torchvision-0.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123", size = 1856021, upload-time = "2026-07-08T16:07:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/40beacd53809194f5259e590d1afaeaa8ad57da15f77c646e6560bcc4616/torchvision-0.28.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf", size = 7797014, upload-time = "2026-07-08T16:07:43.04Z" }, + { url = "https://files.pythonhosted.org/packages/32/db/062cdb5a84380a60439775311fff34d89229760d2a50680393dc18699956/torchvision-0.28.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237", size = 7674669, upload-time = "2026-07-08T16:07:38.91Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a6/b4081e2d04e1541abf82785ac9e5178a494c19330391f551356c8c18b7b3/torchvision-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b", size = 4157380, upload-time = "2026-07-08T16:07:40.22Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b9/da40eca5bbe9596c12ae9899ab7abaf887f5e20f29d08b924b4633714821/torchvision-0.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b", size = 1856014, upload-time = "2026-07-08T16:07:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/06/d6/313aafd3df4eaf5f330211bd4e75b7598bddbfee4f55580d3b58536e1b20/torchvision-0.28.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5", size = 7796873, upload-time = "2026-07-08T16:07:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/41/31f8e959ab8f942600b6357f8999c21d779d5fd3304b0fd204ff4b518239/torchvision-0.28.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd", size = 7674634, upload-time = "2026-07-08T16:07:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/15/15/4c5115253fd470672cdac0a1cf139e06b4f3e29d041238a2b255937f63be/torchvision-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769", size = 4184005, upload-time = "2026-07-08T16:07:35.805Z" }, + { url = "https://files.pythonhosted.org/packages/6a/80/822a6163da716f8a78141cf6678d74e26a572285d4ea866ef8aa657bb307/torchvision-0.28.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49", size = 1856011, upload-time = "2026-07-08T16:07:33.404Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d1/cd3f9463b39a790ec8c0c2f6e6c8061edb1562114d04fcdfa786ed889345/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542", size = 7796742, upload-time = "2026-07-08T16:07:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/3e0a7ad18e99831e2d7f4713d3be717b7159ff5a920862dd5c23c454aa71/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204", size = 7675526, upload-time = "2026-07-08T16:07:34.572Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/23aea03b28297bc66a4461f55ae4296368a9d85fa9a454bafcb2a5348bd7/torchvision-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47", size = 4291452, upload-time = "2026-07-08T16:07:32.236Z" }, ] [[package]] @@ -4350,17 +4659,30 @@ wheels = [ name = "triton" version = "3.6.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'win32'", +] wheels = [ - { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, - { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, - { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, ] [[package]] @@ -4434,11 +4756,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -4645,6 +4967,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] + [[package]] name = "whisperspeech" version = "0.7"