From 3580d251a3c3a82d19c6af7a2ef31a27ca9b70bd Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:27:05 +0200 Subject: [PATCH 01/32] feat(utils): add TkImageWindow and switch to opencv-python-headless - Add sv.TkImageWindow: tkinter+pillow desktop window replacing cv2.imshow/waitKey/destroyAllWindows under headless OpenCV; supports BGR/grayscale/BGRA frames, key polling, mouse callbacks, context manager - Switch pyproject.toml from opencv-python to opencv-python-headless (breaking: cv2.imshow/waitKey/namedWindow no longer provided transitively; restore with pip install opencv-python) - Migrate all 17 runnable examples from cv2.imshow/waitKey/destroyAllWindows to sv.TkImageWindow - Add docstring with webcam ownership pattern to VideoInfo.from_video_path and get_video_frames_generator - Add breaking-change CHANGELOG entry and two FAQ entries covering headless and webcam usage - Add 18 tests for TkImageWindow covering show(), wait_key(), close(), context manager, mouse callbacks, _bgr_to_pil --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/changelog.md | 14 ++ docs/faq.md | 31 +++ .../count_people_in_zone/inference_example.py | 8 +- .../ultralytics_example.py | 8 +- .../speed_estimation/inference_example.py | 7 +- .../speed_estimation/ultralytics_example.py | 7 +- examples/speed_estimation/yolo_nas_example.py | 7 +- .../time_in_zone/inference_file_example.py | 8 +- .../inference_naive_stream_example.py | 8 +- .../time_in_zone/inference_stream_example.py | 6 +- examples/time_in_zone/rfdetr_file_example.py | 8 +- .../rfdetr_naive_stream_example.py | 8 +- .../time_in_zone/rfdetr_stream_example.py | 6 +- examples/time_in_zone/scripts/draw_zones.py | 43 +++-- .../time_in_zone/ultralytics_file_example.py | 8 +- .../ultralytics_naive_stream_example.py | 8 +- .../ultralytics_stream_example.py | 6 +- .../traffic_analysis/inference_example.py | 8 +- .../traffic_analysis/ultralytics_example.py | 8 +- pyproject.toml | 2 +- src/supervision/__init__.py | 2 + src/supervision/utils/image_window.py | 182 ++++++++++++++++++ src/supervision/utils/video.py | 16 ++ tests/utils/test_image_window.py | 178 +++++++++++++++++ uv.lock | 35 ++-- 25 files changed, 523 insertions(+), 99 deletions(-) create mode 100644 src/supervision/utils/image_window.py create mode 100644 tests/utils/test_image_window.py diff --git a/docs/changelog.md b/docs/changelog.md index 06d4a56bd8..eb3c9ee470 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,20 @@ date_modified: 2026-06-09 ### UnReleased +#### Breaking change + +- Changed: `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. + + **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. + + **How to restore GUI support:** install the full wheel explicitly after upgrading supervision: + ```bash + pip install opencv-python + ``` + Both wheel families share the `cv2` namespace; installing `opencv-python` on top of `opencv-python-headless` is unsafe — install only one. + + **Co-installation conflict:** packages that pin `opencv-python` (e.g. `ultralytics`, `inference-sdk`) cannot be installed alongside `supervision` without a resolver conflict. If you depend on both, pin `opencv-python` explicitly in your environment and the resolver will prefer it over `opencv-python-headless`. + - Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged. - Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`DetectionDataset.as_yolo`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) now accepts `is_obb=True` to round-trip oriented bounding box datasets without losing the rotation. Previously the save path had no OBB option and silently wrote 5-token axis-aligned bbox lines for datasets loaded with `from_yolo(..., is_obb=True)`, dropping the four corners stored in `detections.data["xyxyxyxy"]`. Re-loading those saved files with `is_obb=True` then crashed the validator. Mirrors the existing `from_yolo(..., is_obb=True)` load path. diff --git a/docs/faq.md b/docs/faq.md index 36555b8fef..e009087b91 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -53,6 +53,37 @@ Use `supervision.metrics.mean_average_precision.MeanAveragePrecision` for mAP an Yes. Supervision is free and open source under the MIT license. +## Why does cv2.imshow stop working after installing supervision? + +Supervision depends on `opencv-python-headless`, which does not include desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`). To restore them, install the full wheel explicitly: + +```bash +pip install opencv-python +``` + +Both wheel families share the `cv2` namespace. Do not install both simultaneously. + +## How do I process frames from a webcam with supervision? + +Supervision does not support live camera capture. Manage the capture device yourself with `cv2.VideoCapture` (requires `opencv-python`) and pass individual frames to supervision annotators: + +```python +import cv2 # requires: pip install opencv-python +import supervision as sv + +cap = cv2.VideoCapture(0) +annotator = sv.BoxAnnotator() + +while True: + ret, frame = cap.read() + if not ret: + break + # run your detector, then annotate: + # annotated = annotator.annotate(frame, detections) + +cap.release() +``` + ## Where is the source code? The source code is available at [github.com/roboflow/supervision](https://github.com/roboflow/supervision). diff --git a/examples/count_people_in_zone/inference_example.py b/examples/count_people_in_zone/inference_example.py index 677af69391..ba5cdf95de 100644 --- a/examples/count_people_in_zone/inference_example.py +++ b/examples/count_people_in_zone/inference_example.py @@ -1,7 +1,6 @@ import json import os -import cv2 import numpy as np from inference.core.models.roboflow import RoboflowInferenceModel from inference.models.utils import get_roboflow_model @@ -179,6 +178,7 @@ def main( ) sink.write_frame(annotated_frame) else: + window = sv.TkImageWindow("Processed Video") for frame in tqdm(frames_generator, total=video_info.total_frames): detections = detect(frame, model, confidence_threshold, iou_threshold) annotated_frame = annotate( @@ -188,11 +188,11 @@ def main( box_annotators=box_annotators, detections=detections, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/count_people_in_zone/ultralytics_example.py b/examples/count_people_in_zone/ultralytics_example.py index 35a09616f0..4228ad3684 100644 --- a/examples/count_people_in_zone/ultralytics_example.py +++ b/examples/count_people_in_zone/ultralytics_example.py @@ -1,6 +1,5 @@ import json -import cv2 import numpy as np from tqdm import tqdm from ultralytics import YOLO @@ -167,6 +166,7 @@ def main( ) sink.write_frame(annotated_frame) else: + window = sv.TkImageWindow("Processed Video") for frame in tqdm(frames_generator, total=video_info.total_frames): detections = detect(frame, model, confidence_threshold, iou_threshold) annotated_frame = annotate( @@ -176,11 +176,11 @@ def main( box_annotators=box_annotators, detections=detections, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/speed_estimation/inference_example.py b/examples/speed_estimation/inference_example.py index cb4f77c0df..baae21042b 100644 --- a/examples/speed_estimation/inference_example.py +++ b/examples/speed_estimation/inference_example.py @@ -96,6 +96,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: + window = sv.TkImageWindow("frame") for frame in frame_generator: results = model.infer( frame, confidence=confidence_threshold, iou=iou_threshold @@ -136,10 +137,10 @@ def main( ) sink.write_frame(annotated_frame) - cv2.imshow("frame", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/speed_estimation/ultralytics_example.py b/examples/speed_estimation/ultralytics_example.py index 423e53acea..68deb852d8 100644 --- a/examples/speed_estimation/ultralytics_example.py +++ b/examples/speed_estimation/ultralytics_example.py @@ -82,6 +82,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: + window = sv.TkImageWindow("frame") for frame in frame_generator: result = model(frame, conf=confidence_threshold, iou=iou_threshold)[0] detections = sv.Detections.from_ultralytics(result) @@ -120,10 +121,10 @@ def main( ) sink.write_frame(annotated_frame) - cv2.imshow("frame", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/speed_estimation/yolo_nas_example.py b/examples/speed_estimation/yolo_nas_example.py index 9fe4148baf..dc1033da35 100644 --- a/examples/speed_estimation/yolo_nas_example.py +++ b/examples/speed_estimation/yolo_nas_example.py @@ -83,6 +83,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: + window = sv.TkImageWindow("frame") for frame in frame_generator: result = model.predict(frame, conf=confidence_threshold, iou=iou_threshold)[ 0 @@ -123,10 +124,10 @@ def main( ) sink.write_frame(annotated_frame) - cv2.imshow("frame", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/inference_file_example.py b/examples/time_in_zone/inference_file_example.py index 59f3b4c0bb..275df59d21 100644 --- a/examples/time_in_zone/inference_file_example.py +++ b/examples/time_in_zone/inference_file_example.py @@ -1,4 +1,3 @@ -import cv2 import numpy as np from inference import get_model from utils.general import find_in_list, load_zones_config @@ -49,6 +48,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: results = model.infer( frame, confidence=confidence_threshold, iou_threshold=iou_threshold @@ -84,10 +84,10 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/inference_naive_stream_example.py b/examples/time_in_zone/inference_naive_stream_example.py index 30f77ee798..b186bbf724 100644 --- a/examples/time_in_zone/inference_naive_stream_example.py +++ b/examples/time_in_zone/inference_naive_stream_example.py @@ -1,4 +1,3 @@ -import cv2 import numpy as np from inference import get_model from utils.general import find_in_list, get_stream_frames_generator, load_zones_config @@ -49,6 +48,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps @@ -94,10 +94,10 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/inference_stream_example.py b/examples/time_in_zone/inference_stream_example.py index 3f659d1c06..47580c406c 100644 --- a/examples/time_in_zone/inference_stream_example.py +++ b/examples/time_in_zone/inference_stream_example.py @@ -1,4 +1,3 @@ -import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -28,6 +27,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] + self.window = sv.TkImageWindow("Processed Video") def on_prediction(self, result: dict, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -70,8 +70,8 @@ def on_prediction(self, result: dict, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - cv2.waitKey(1) + self.window.show(annotated_frame) + self.window.wait_key(1) def main( diff --git a/examples/time_in_zone/rfdetr_file_example.py b/examples/time_in_zone/rfdetr_file_example.py index bb6625aa99..e7ac0e5cf4 100644 --- a/examples/time_in_zone/rfdetr_file_example.py +++ b/examples/time_in_zone/rfdetr_file_example.py @@ -2,7 +2,6 @@ from enum import Enum -import cv2 import numpy as np from rfdetr import RFDETRBase, RFDETRLarge, RFDETRMedium, RFDETRNano, RFDETRSmall from utils.general import find_in_list, load_zones_config @@ -126,6 +125,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: detections = model.predict(frame, threshold=confidence_threshold) detections = detections[find_in_list(detections.class_id, classes)] @@ -159,10 +159,10 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/rfdetr_naive_stream_example.py b/examples/time_in_zone/rfdetr_naive_stream_example.py index 5f78bcd0fa..84302e1351 100644 --- a/examples/time_in_zone/rfdetr_naive_stream_example.py +++ b/examples/time_in_zone/rfdetr_naive_stream_example.py @@ -2,7 +2,6 @@ from enum import Enum -import cv2 import numpy as np from rfdetr import RFDETRBase, RFDETRLarge, RFDETRMedium, RFDETRNano, RFDETRSmall from utils.general import find_in_list, get_stream_frames_generator, load_zones_config @@ -126,6 +125,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps @@ -169,11 +169,11 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/rfdetr_stream_example.py b/examples/time_in_zone/rfdetr_stream_example.py index 7eff2cbc98..635fa0f069 100644 --- a/examples/time_in_zone/rfdetr_stream_example.py +++ b/examples/time_in_zone/rfdetr_stream_example.py @@ -2,7 +2,6 @@ from enum import Enum -import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -90,6 +89,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] + self.window = sv.TkImageWindow("Processed Video") def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -128,8 +128,8 @@ def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - cv2.waitKey(1) + self.window.show(annotated_frame) + self.window.wait_key(1) def main( diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py index de7a7bab6d..e370a3ff0b 100644 --- a/examples/time_in_zone/scripts/draw_zones.py +++ b/examples/time_in_zone/scripts/draw_zones.py @@ -2,7 +2,6 @@ import json import os -from typing import Any import cv2 import numpy as np @@ -10,11 +9,10 @@ import supervision as sv -KEY_ENTER = 13 -KEY_NEWLINE = 10 -KEY_ESCAPE = 27 -KEY_QUIT = ord("q") -KEY_SAVE = ord("s") +KEY_ENTER = "Return" +KEY_ESCAPE = "Escape" +KEY_QUIT = "q" +KEY_SAVE = "s" THICKNESS = 2 COLORS = sv.ColorPalette.DEFAULT @@ -37,15 +35,17 @@ def resolve_source(source_path: str) -> np.ndarray | None: return frame -def mouse_event(event: int, x: int, y: int, flags: int, param: Any) -> None: +def mouse_event(x: int, y: int, event_type: str) -> None: global current_mouse_position - if event == cv2.EVENT_MOUSEMOVE: + if event_type == "move": current_mouse_position = (x, y) - elif event == cv2.EVENT_LBUTTONDOWN: + elif event_type == "down": POLYGONS[-1].append((x, y)) -def redraw(image: np.ndarray, original_image: np.ndarray) -> None: +def redraw( + image: np.ndarray, original_image: np.ndarray, window: sv.TkImageWindow +) -> None: global POLYGONS, current_mouse_position image[:] = original_image.copy() for idx, polygon in enumerate(POLYGONS): @@ -80,10 +80,12 @@ def redraw(image: np.ndarray, original_image: np.ndarray) -> None: color=color, thickness=THICKNESS, ) - cv2.imshow(WINDOW_NAME, image) + window.show(image) -def close_and_finalize_polygon(image: np.ndarray, original_image: np.ndarray) -> None: +def close_and_finalize_polygon( + image: np.ndarray, original_image: np.ndarray, window: sv.TkImageWindow +) -> None: if len(POLYGONS[-1]) > 2: cv2.line( img=image, @@ -95,7 +97,7 @@ def close_and_finalize_polygon(image: np.ndarray, original_image: np.ndarray) -> POLYGONS.append([]) image[:] = original_image.copy() redraw_polygons(image) - cv2.imshow(WINDOW_NAME, image) + window.show(image) def redraw_polygons(image: np.ndarray) -> None: @@ -140,13 +142,14 @@ def main(source_path: str, zone_configuration_path: str) -> None: return image = original_image.copy() - cv2.imshow(WINDOW_NAME, image) - cv2.setMouseCallback(WINDOW_NAME, mouse_event, image) + window = sv.TkImageWindow(WINDOW_NAME) + window.set_mouse_callback(mouse_event) + window.show(image) while True: - key = cv2.waitKey(1) & 0xFF - if key == KEY_ENTER or key == KEY_NEWLINE: - close_and_finalize_polygon(image, original_image) + key = window.wait_key(1) + if key == KEY_ENTER: + close_and_finalize_polygon(image, original_image, window) elif key == KEY_ESCAPE: POLYGONS[-1] = [] current_mouse_position = None @@ -154,11 +157,11 @@ def main(source_path: str, zone_configuration_path: str) -> None: save_polygons_to_json(POLYGONS, zone_configuration_path) print(f"Polygons saved to {zone_configuration_path}") break - redraw(image, original_image) + redraw(image, original_image, window) if key == KEY_QUIT: break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/ultralytics_file_example.py b/examples/time_in_zone/ultralytics_file_example.py index 55e9ddc29a..dcb8e87b71 100644 --- a/examples/time_in_zone/ultralytics_file_example.py +++ b/examples/time_in_zone/ultralytics_file_example.py @@ -1,4 +1,3 @@ -import cv2 import numpy as np from ultralytics import YOLO from utils.general import find_in_list, load_zones_config @@ -49,6 +48,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: results = model( frame, @@ -88,10 +88,10 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/ultralytics_naive_stream_example.py b/examples/time_in_zone/ultralytics_naive_stream_example.py index 2f50aab4a0..cffd4d431f 100644 --- a/examples/time_in_zone/ultralytics_naive_stream_example.py +++ b/examples/time_in_zone/ultralytics_naive_stream_example.py @@ -1,4 +1,3 @@ -import cv2 import numpy as np from ultralytics import YOLO from utils.general import find_in_list, get_stream_frames_generator, load_zones_config @@ -49,6 +48,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] + window = sv.TkImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps @@ -98,10 +98,10 @@ def main( custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() if __name__ == "__main__": diff --git a/examples/time_in_zone/ultralytics_stream_example.py b/examples/time_in_zone/ultralytics_stream_example.py index b18462b339..962509df5c 100644 --- a/examples/time_in_zone/ultralytics_stream_example.py +++ b/examples/time_in_zone/ultralytics_stream_example.py @@ -1,6 +1,5 @@ from __future__ import annotations -import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -31,6 +30,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] + self.window = sv.TkImageWindow("Processed Video") def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -72,8 +72,8 @@ def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - cv2.imshow("Processed Video", annotated_frame) - cv2.waitKey(1) + self.window.show(annotated_frame) + self.window.wait_key(1) def main( diff --git a/examples/traffic_analysis/inference_example.py b/examples/traffic_analysis/inference_example.py index 85c1ff4a8c..750ae1947b 100644 --- a/examples/traffic_analysis/inference_example.py +++ b/examples/traffic_analysis/inference_example.py @@ -3,7 +3,6 @@ import os from collections.abc import Iterable -import cv2 import numpy as np from inference.models.utils import get_roboflow_model from tqdm import tqdm @@ -114,12 +113,13 @@ def process_video(self): annotated_frame = self.process_frame(frame) sink.write_frame(annotated_frame) else: + window = sv.TkImageWindow("Processed Video") for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() def annotate_frame( self, frame: np.ndarray, detections: sv.Detections diff --git a/examples/traffic_analysis/ultralytics_example.py b/examples/traffic_analysis/ultralytics_example.py index 9db82a65ee..9a850082f3 100644 --- a/examples/traffic_analysis/ultralytics_example.py +++ b/examples/traffic_analysis/ultralytics_example.py @@ -2,7 +2,6 @@ from collections.abc import Iterable -import cv2 import numpy as np from tqdm import tqdm from ultralytics import YOLO @@ -111,12 +110,13 @@ def process_video(self): annotated_frame = self.process_frame(frame) sink.write_frame(annotated_frame) else: + window = sv.TkImageWindow("Processed Video") for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) - cv2.imshow("Processed Video", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord("q"): + window.show(annotated_frame) + if window.wait_key(1) == "q": break - cv2.destroyAllWindows() + window.close() def annotate_frame( self, frame: np.ndarray, detections: sv.Detections diff --git a/pyproject.toml b/pyproject.toml index f456327976..c98f7a10af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "defusedxml>=0.7.1", "matplotlib>=3.6", "numpy>=1.21.2", - "opencv-python>=4.5.5.64", + "opencv-python-headless>=4.5.5.64", "pillow>=9.4", "pydeprecate>=0.9,<0.10", "pyyaml>=5.3", diff --git a/src/supervision/__init__.py b/src/supervision/__init__.py index fa0f67dd60..900038940c 100644 --- a/src/supervision/__init__.py +++ b/src/supervision/__init__.py @@ -145,6 +145,7 @@ scale_image, tint_image, ) +from supervision.utils.image_window import TkImageWindow from supervision.utils.notebook import plot_image, plot_images_grid from supervision.utils.video import ( FPSMonitor, @@ -207,6 +208,7 @@ "Rect", "RichLabelAnnotator", "RoundBoxAnnotator", + "TkImageWindow", "TraceAnnotator", "TriangleAnnotator", "VertexAnnotator", diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py new file mode 100644 index 0000000000..765fce0341 --- /dev/null +++ b/src/supervision/utils/image_window.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import Any + +import numpy as np +import numpy.typing as npt +from PIL import Image + +MouseCallback = Callable[[int, int, str], None] + + +class TkImageWindow: + """Desktop image window backed by stdlib tkinter + pillow. + + Drop-in replacement for `cv2.imshow` / `cv2.waitKey` that works under + `opencv-python-headless`. Requires tkinter (stdlib) and pillow (already a + supervision dependency). On headless servers with no display, instantiation + succeeds but `show()` / `wait_key()` will raise `tkinter.TclError` when + the window is first created. + + Attributes: + title: Window title bar text. + + Examples: + ```python + import supervision as sv + + window = sv.TkImageWindow("preview") + for frame in sv.get_video_frames_generator(source_path="video.mp4"): + annotated = ... # annotate frame + window.show(annotated) + if window.wait_key(delay_ms=1) == "q": + break + window.close() + ``` + + Context-manager form closes automatically: + + ```python + import supervision as sv + + with sv.TkImageWindow("preview") as window: + for frame in sv.get_video_frames_generator(source_path="video.mp4"): + window.show(frame) + if window.wait_key(delay_ms=1) == "q": + break + ``` + """ + + def __init__(self, title: str = "supervision") -> None: + self.title = title + self._mouse_callback: MouseCallback | None = None + self._root: Any = None + self._label: Any = None + self._photo: Any = None + self._key_queue: list[str] = [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def show(self, image: npt.NDArray[np.uint8]) -> None: + """Display a BGR, grayscale, or BGRA frame in the window. + + Args: + image: uint8 numpy array. Accepted shapes: + - ``(H, W)`` — grayscale + - ``(H, W, 3)`` — BGR (OpenCV convention; channels are swapped + to RGB before display) + - ``(H, W, 4)`` — BGRA (channels reordered to RGBA) + + Raises: + TypeError: If `image.dtype` is not `uint8`. + ValueError: If `image` is not 2-D or 3-D with 3 or 4 channels. + """ + if image.dtype != np.uint8: + raise TypeError( + f"image must be uint8, got {image.dtype}. " + "Convert with image.astype(np.uint8) before calling show()." + ) + pil_image = _bgr_to_pil(image) + self._ensure_window() + from PIL import ImageTk + + self._photo = ImageTk.PhotoImage(pil_image) + self._label.configure(image=self._photo) + self._root.update_idletasks() + self._root.update() + + def wait_key(self, delay_ms: int = 0) -> str | None: + """Wait for a keypress and return its name. + + Args: + delay_ms: How long to wait in milliseconds. ``0`` blocks until a + key is pressed. Positive values poll for up to `delay_ms` ms + and return ``None`` if no key arrives in time. + + Returns: + The tkinter keysym string (e.g. ``"q"``, ``"Return"``, ``"Escape"``) + or ``None`` if the timeout elapsed without a key event. + """ + self._ensure_window() + if self._key_queue: + return self._key_queue.pop(0) + if delay_ms <= 0: + while not self._key_queue: + self._root.update() + else: + deadline = time.monotonic() + delay_ms / 1000.0 + while not self._key_queue and time.monotonic() < deadline: + self._root.update() + return self._key_queue.pop(0) if self._key_queue else None + + def set_mouse_callback(self, callback: MouseCallback | None) -> None: + """Register a callback for mouse events on the image. + + Args: + callback: Callable receiving ``(x, y, event_type)`` where + ``event_type`` is one of ``"down"``, ``"up"``, or ``"move"``. + Pass ``None`` to remove the callback. + """ + self._mouse_callback = callback + + def close(self) -> None: + """Destroy the window and release its resources.""" + if self._root is not None: + self._root.destroy() + self._root = None + self._label = None + self._photo = None + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> TkImageWindow: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_window(self) -> None: + if self._root is not None: + return + import tkinter as tk + + self._root = tk.Tk() + self._root.title(self.title) + self._label = tk.Label(self._root) + self._label.pack() + self._root.bind("", self._on_key) + self._label.bind("", lambda e: self._on_mouse(e, "down")) + self._label.bind("", lambda e: self._on_mouse(e, "up")) + self._label.bind("", lambda e: self._on_mouse(e, "move")) + + def _on_key(self, event: Any) -> None: + self._key_queue.append(event.keysym) + + def _on_mouse(self, event: Any, event_type: str) -> None: + if self._mouse_callback is not None: + self._mouse_callback(event.x, event.y, event_type) + + +# ------------------------------------------------------------------ +# Module-level helper +# ------------------------------------------------------------------ + + +def _bgr_to_pil(image: npt.NDArray[np.uint8]) -> Image.Image: + if image.ndim == 2: + return Image.fromarray(np.ascontiguousarray(image)) + if image.ndim == 3 and image.shape[2] == 3: + return Image.fromarray(np.ascontiguousarray(image[..., ::-1])) + if image.ndim == 3 and image.shape[2] == 4: + return Image.fromarray(np.ascontiguousarray(image[..., [2, 1, 0, 3]])) + raise ValueError(f"Expected shape (H,W), (H,W,3), or (H,W,4), got {image.shape}.") diff --git a/src/supervision/utils/video.py b/src/supervision/utils/video.py index 1d3fe2a464..6b31c7a0be 100644 --- a/src/supervision/utils/video.py +++ b/src/supervision/utils/video.py @@ -57,6 +57,22 @@ class VideoInfo: @classmethod def from_video_path(cls, video_path: str) -> VideoInfo: + """Read video metadata from a file path. + + Args: + video_path: Path to the video file. + + Returns: + A `VideoInfo` instance with width, height, fps, and total_frames. + + Examples: + ```python + import supervision as sv + + video_info = sv.VideoInfo.from_video_path(video_path="") + # VideoInfo(width=3840, height=2160, fps=25.0, total_frames=538) + ``` + """ video = cv2.VideoCapture(video_path) if not video.isOpened(): raise Exception(f"Could not open video at {video_path}") diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py new file mode 100644 index 0000000000..68ae6d191a --- /dev/null +++ b/tests/utils/test_image_window.py @@ -0,0 +1,178 @@ +"""Tests for TkImageWindow and _bgr_to_pil helper.""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from supervision.utils.image_window import TkImageWindow, _bgr_to_pil + + +class TestBgrToPil: + def test_grayscale(self): + """Grayscale (H,W) array produces an L-mode PIL image of the same size.""" + arr = np.zeros((10, 20), dtype=np.uint8) + img = _bgr_to_pil(arr) + assert img.mode == "L" + assert img.size == (20, 10) + + def test_bgr(self): + """BGR (H,W,3) array is converted to RGB-mode PIL image.""" + arr = np.zeros((10, 20, 3), dtype=np.uint8) + arr[:, :, 0] = 10 # B + arr[:, :, 1] = 20 # G + arr[:, :, 2] = 30 # R + img = _bgr_to_pil(arr) + assert img.mode == "RGB" + pixel = img.getpixel((0, 0)) + assert pixel == (30, 20, 10) # R, G, B after swap + + def test_bgra(self): + """BGRA (H,W,4) array is reordered to RGBA-mode PIL image.""" + arr = np.zeros((10, 20, 4), dtype=np.uint8) + arr[:, :, 0] = 10 # B + arr[:, :, 1] = 20 # G + arr[:, :, 2] = 30 # R + arr[:, :, 3] = 255 # A + img = _bgr_to_pil(arr) + assert img.mode == "RGBA" + pixel = img.getpixel((0, 0)) + assert pixel == (30, 20, 10, 255) + + @pytest.mark.parametrize( + "shape", + [ + pytest.param((10, 20, 2), id="2-channel"), + pytest.param((10, 20, 5), id="5-channel"), + pytest.param((10, 20, 3, 1), id="4d"), + ], + ) + def test_invalid_shape_raises(self, shape): + """Unsupported array shapes raise ValueError.""" + arr = np.zeros(shape, dtype=np.uint8) + with pytest.raises(ValueError, match="Expected shape"): + _bgr_to_pil(arr) + + +class TestTkImageWindowShow: + def test_show_raises_type_error_for_non_uint8(self): + """show() rejects arrays whose dtype is not uint8.""" + window = TkImageWindow("test") + with pytest.raises(TypeError, match="uint8"): + window.show(np.zeros((10, 10, 3), dtype=np.float32)) + + def test_show_raises_value_error_for_bad_shape(self): + """show() propagates ValueError for unsupported array shapes.""" + window = TkImageWindow("test") + with pytest.raises(ValueError, match="Expected shape"): + window.show(np.zeros((10, 10, 2), dtype=np.uint8)) + + def test_show_creates_window_and_updates(self): + """show() creates a Tk window, sets the PhotoImage, and calls update.""" + window = TkImageWindow("preview") + mock_root = MagicMock() + mock_label = MagicMock() + mock_photo = MagicMock() + + with ( + patch("supervision.utils.image_window.TkImageWindow._ensure_window"), + patch("PIL.ImageTk.PhotoImage", return_value=mock_photo) as mock_ph, + ): + window._root = mock_root + window._label = mock_label + + frame = np.zeros((4, 4, 3), dtype=np.uint8) + window.show(frame) + + mock_ph.assert_called_once() + mock_label.configure.assert_called_once_with(image=mock_photo) + mock_root.update_idletasks.assert_called_once() + mock_root.update.assert_called_once() + + +class TestTkImageWindowWaitKey: + def test_wait_key_returns_queued_key_immediately(self): + """wait_key() returns the first queued keysym without calling update.""" + window = TkImageWindow() + window._root = MagicMock() + window._key_queue = ["q", "Escape"] + result = window.wait_key(delay_ms=1) + assert result == "q" + assert window._key_queue == ["Escape"] + + def test_wait_key_returns_none_on_timeout(self): + """wait_key() returns None when no key arrives before the deadline.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + # key_queue stays empty — timeout should fire + result = window.wait_key(delay_ms=1) + assert result is None + mock_root.update.assert_called() + + +class TestTkImageWindowClose: + def test_close_destroys_root(self): + """close() calls destroy() on the Tk root and nulls internal refs.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + window._label = MagicMock() + window._photo = MagicMock() + + window.close() + + mock_root.destroy.assert_called_once() + assert window._root is None + assert window._label is None + assert window._photo is None + + def test_close_is_idempotent(self): + """close() on an already-closed window does not raise.""" + window = TkImageWindow() + window.close() # no window created + window.close() # second call must not raise + + +class TestTkImageWindowContextManager: + def test_context_manager_closes_on_exit(self): + """The with-statement calls close() when the block exits normally.""" + with patch.object(TkImageWindow, "close") as mock_close: + with TkImageWindow("ctx") as w: + assert isinstance(w, TkImageWindow) + mock_close.assert_called_once() + + def test_context_manager_closes_on_exception(self): + """close() is called even when the body raises.""" + with patch.object(TkImageWindow, "close") as mock_close: + with pytest.raises(RuntimeError): + with TkImageWindow("ctx"): + raise RuntimeError("boom") + mock_close.assert_called_once() + + +class TestTkImageWindowMouseCallback: + def test_set_mouse_callback_stores_callable(self): + """set_mouse_callback() stores the callable for later use.""" + window = TkImageWindow() + cb = MagicMock() + window.set_mouse_callback(cb) + assert window._mouse_callback is cb + + def test_set_mouse_callback_none_removes_callback(self): + """Passing None removes a previously set callback.""" + window = TkImageWindow() + window.set_mouse_callback(MagicMock()) + window.set_mouse_callback(None) + assert window._mouse_callback is None + + def test_on_mouse_calls_callback_with_coords(self): + """_on_mouse() forwards (x, y, event_type) to the registered callback.""" + window = TkImageWindow() + cb = MagicMock() + window._mouse_callback = cb + event = MagicMock() + event.x = 5 + event.y = 10 + window._on_mouse(event, "down") + cb.assert_called_once_with(5, 10, "down") diff --git a/uv.lock b/uv.lock index 454e97a89c..29568517aa 100644 --- a/uv.lock +++ b/uv.lock @@ -959,33 +959,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, @@ -2876,22 +2870,23 @@ wheels = [ ] [[package]] -name = "opencv-python" -version = "4.11.0.86" +name = "opencv-python-headless" +version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, - { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, ] [[package]] @@ -3263,9 +3258,9 @@ wheels = [ name = "pydeprecate" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.430153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235752Z" }, + { url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235Z" }, ] [[package]] @@ -4061,7 +4056,7 @@ wheels = [ [[package]] name = "supervision" -version = "0.29.0.dev0" +version = "0.29.0rc0" source = { editable = "." } dependencies = [ { name = "defusedxml" }, @@ -4070,7 +4065,7 @@ dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opencv-python" }, + { name = "opencv-python-headless" }, { name = "pillow" }, { name = "pydeprecate" }, { name = "pyyaml" }, @@ -4119,7 +4114,7 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "matplotlib", specifier = ">=3.6" }, { name = "numpy", specifier = ">=1.21.2" }, - { name = "opencv-python", specifier = ">=4.5.5.64" }, + { name = "opencv-python-headless", specifier = ">=4.5.5.64" }, { name = "pandas", marker = "extra == 'metrics'", specifier = ">=2" }, { name = "pillow", specifier = ">=9.4" }, { name = "pydeprecate", specifier = ">=0.9,<0.10" }, From e624ea3c3184722c7a225fbf1bb14e58f5a16d9d Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:17:16 +0200 Subject: [PATCH 02/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/changelog.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index eb3c9ee470..8a52f3e312 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,11 +13,12 @@ date_modified: 2026-06-09 **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. - **How to restore GUI support:** install the full wheel explicitly after upgrading supervision: + **How to restore GUI support:** replace the headless wheel with the full one: ```bash + pip uninstall -y opencv-python-headless pip install opencv-python ``` - Both wheel families share the `cv2` namespace; installing `opencv-python` on top of `opencv-python-headless` is unsafe — install only one. + Both wheel families share the `cv2` namespace; keep only one installed at a time. **Co-installation conflict:** packages that pin `opencv-python` (e.g. `ultralytics`, `inference-sdk`) cannot be installed alongside `supervision` without a resolver conflict. If you depend on both, pin `opencv-python` explicitly in your environment and the resolver will prefer it over `opencv-python-headless`. From 33000e837b61ad0dec428f8f4f8450560d233a12 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:17:33 +0200 Subject: [PATCH 03/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/supervision/utils/image_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 765fce0341..14e37cc284 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -107,6 +107,7 @@ def wait_key(self, delay_ms: int = 0) -> str | None: if delay_ms <= 0: while not self._key_queue: self._root.update() + time.sleep(0.001) else: deadline = time.monotonic() + delay_ms / 1000.0 while not self._key_queue and time.monotonic() < deadline: From 335caa0d373faa098ec4ee428709b827e972dd05 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:17:46 +0200 Subject: [PATCH 04/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/faq.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index e009087b91..81645cae99 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -55,13 +55,12 @@ Yes. Supervision is free and open source under the MIT license. ## Why does cv2.imshow stop working after installing supervision? -Supervision depends on `opencv-python-headless`, which does not include desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`). To restore them, install the full wheel explicitly: +Supervision depends on `opencv-python-headless`, which does not include desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`). To restore them, replace the headless wheel with the full one: -```bash -pip install opencv-python -``` + pip uninstall -y opencv-python-headless + pip install opencv-python -Both wheel families share the `cv2` namespace. Do not install both simultaneously. +Both wheel families share the `cv2` namespace; keep only one installed at a time. ## How do I process frames from a webcam with supervision? From 937ec58e11cd4804827f1ccc30884707d8b6b7c3 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:17:54 +0200 Subject: [PATCH 05/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/supervision/utils/image_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 14e37cc284..0ab6e86a29 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -112,6 +112,7 @@ def wait_key(self, delay_ms: int = 0) -> str | None: deadline = time.monotonic() + delay_ms / 1000.0 while not self._key_queue and time.monotonic() < deadline: self._root.update() + time.sleep(0.001) return self._key_queue.pop(0) if self._key_queue else None def set_mouse_callback(self, callback: MouseCallback | None) -> None: From e17a4c0d45d9d4cb897b7d075a2f6843d4b50c62 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:18:23 +0200 Subject: [PATCH 06/32] Fix Tk image window event handling Co-authored-by: Codex --- examples/time_in_zone/scripts/draw_zones.py | 4 +- src/supervision/utils/image_window.py | 58 +++++++++++++++++---- tests/utils/test_image_window.py | 51 +++++++++++++++++- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py index e370a3ff0b..79120db509 100644 --- a/examples/time_in_zone/scripts/draw_zones.py +++ b/examples/time_in_zone/scripts/draw_zones.py @@ -9,7 +9,7 @@ import supervision as sv -KEY_ENTER = "Return" +KEY_ENTER = {"Return", "KP_Enter"} KEY_ESCAPE = "Escape" KEY_QUIT = "q" KEY_SAVE = "s" @@ -148,7 +148,7 @@ def main(source_path: str, zone_configuration_path: str) -> None: while True: key = window.wait_key(1) - if key == KEY_ENTER: + if key in KEY_ENTER: close_and_finalize_polygon(image, original_image, window) elif key == KEY_ESCAPE: POLYGONS[-1] = [] diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 765fce0341..2e521ca96a 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -1,7 +1,7 @@ from __future__ import annotations -import time from collections.abc import Callable +from contextlib import suppress from typing import Any import numpy as np @@ -55,6 +55,7 @@ def __init__(self, title: str = "supervision") -> None: self._root: Any = None self._label: Any = None self._photo: Any = None + self._key_event: Any = None self._key_queue: list[str] = [] # ------------------------------------------------------------------ @@ -104,13 +105,15 @@ def wait_key(self, delay_ms: int = 0) -> str | None: self._ensure_window() if self._key_queue: return self._key_queue.pop(0) + root = self._root if delay_ms <= 0: - while not self._key_queue: - self._root.update() + self._wait_for_key_or_close() else: - deadline = time.monotonic() + delay_ms / 1000.0 - while not self._key_queue and time.monotonic() < deadline: - self._root.update() + timeout_id = root.after(delay_ms, self._signal_wait) + self._wait_for_key_or_close() + if self._root is not None: + with suppress(Exception): + root.after_cancel(timeout_id) return self._key_queue.pop(0) if self._key_queue else None def set_mouse_callback(self, callback: MouseCallback | None) -> None: @@ -126,10 +129,11 @@ def set_mouse_callback(self, callback: MouseCallback | None) -> None: def close(self) -> None: """Destroy the window and release its resources.""" if self._root is not None: - self._root.destroy() - self._root = None - self._label = None - self._photo = None + root = self._root + self._signal_wait() + with suppress(Exception): + root.destroy() + self._reset_window_refs() # ------------------------------------------------------------------ # Context manager @@ -146,26 +150,58 @@ def __exit__(self, *_: object) -> None: # ------------------------------------------------------------------ def _ensure_window(self) -> None: - if self._root is not None: + if self._window_exists(): return + self._reset_window_refs() import tkinter as tk self._root = tk.Tk() self._root.title(self.title) self._label = tk.Label(self._root) self._label.pack() + self._key_event = tk.IntVar(master=self._root, value=0) self._root.bind("", self._on_key) + self._root.protocol("WM_DELETE_WINDOW", self.close) self._label.bind("", lambda e: self._on_mouse(e, "down")) self._label.bind("", lambda e: self._on_mouse(e, "up")) self._label.bind("", lambda e: self._on_mouse(e, "move")) def _on_key(self, event: Any) -> None: self._key_queue.append(event.keysym) + self._signal_wait() def _on_mouse(self, event: Any, event_type: str) -> None: if self._mouse_callback is not None: self._mouse_callback(event.x, event.y, event_type) + def _signal_wait(self) -> None: + if self._key_event is None: + return + with suppress(Exception): + self._key_event.set(self._key_event.get() + 1) + + def _wait_for_key_or_close(self) -> None: + if self._root is None or self._key_event is None: + return + try: + self._root.wait_variable(self._key_event) + except Exception: + self._reset_window_refs() + + def _window_exists(self) -> bool: + if self._root is None: + return False + try: + return bool(self._root.winfo_exists()) + except Exception: + return False + + def _reset_window_refs(self) -> None: + self._root = None + self._label = None + self._photo = None + self._key_event = None + # ------------------------------------------------------------------ # Module-level helper diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 68ae6d191a..06944ea68d 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -100,15 +100,37 @@ def test_wait_key_returns_queued_key_immediately(self): assert result == "q" assert window._key_queue == ["Escape"] + def test_wait_key_blocks_with_tk_event_loop(self): + """Blocking wait_key() uses Tk wait_variable instead of update polling.""" + window = TkImageWindow() + mock_root = MagicMock() + mock_event = MagicMock() + mock_root.wait_variable.side_effect = lambda _: window._key_queue.append("q") + window._root = mock_root + window._key_event = mock_event + + result = window.wait_key(delay_ms=0) + + assert result == "q" + mock_root.wait_variable.assert_called_once_with(mock_event) + mock_root.update.assert_not_called() + def test_wait_key_returns_none_on_timeout(self): """wait_key() returns None when no key arrives before the deadline.""" window = TkImageWindow() mock_root = MagicMock() + mock_event = MagicMock() + mock_root.after.return_value = "timeout-id" window._root = mock_root - # key_queue stays empty — timeout should fire + window._key_event = mock_event + result = window.wait_key(delay_ms=1) + assert result is None - mock_root.update.assert_called() + mock_root.after.assert_called_once_with(1, window._signal_wait) + mock_root.wait_variable.assert_called_once_with(mock_event) + mock_root.after_cancel.assert_called_once_with("timeout-id") + mock_root.update.assert_not_called() class TestTkImageWindowClose: @@ -127,12 +149,37 @@ def test_close_destroys_root(self): assert window._label is None assert window._photo is None + def test_close_signals_waiters_and_clears_key_event(self): + """close() wakes wait_key() callers and clears stale Tk references.""" + window = TkImageWindow() + mock_root = MagicMock() + mock_event = MagicMock() + mock_event.get.return_value = 0 + window._root = mock_root + window._key_event = mock_event + + window.close() + + mock_event.set.assert_called_once_with(1) + mock_root.destroy.assert_called_once() + assert window._root is None + assert window._key_event is None + def test_close_is_idempotent(self): """close() on an already-closed window does not raise.""" window = TkImageWindow() window.close() # no window created window.close() # second call must not raise + def test_window_exists_returns_false_for_destroyed_root(self): + """Destroyed Tk roots are not reused after a window-manager close.""" + window = TkImageWindow() + mock_root = MagicMock() + mock_root.winfo_exists.return_value = 0 + window._root = mock_root + + assert window._window_exists() is False + class TestTkImageWindowContextManager: def test_context_manager_closes_on_exit(self): From a70e11ee81e57efac61dde136154802cb6c6fab4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:18:32 +0000 Subject: [PATCH 07/32] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/faq.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 81645cae99..e6f6ba0d5e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -57,8 +57,10 @@ Yes. Supervision is free and open source under the MIT license. Supervision depends on `opencv-python-headless`, which does not include desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`). To restore them, replace the headless wheel with the full one: - pip uninstall -y opencv-python-headless - pip install opencv-python +``` +pip uninstall -y opencv-python-headless +pip install opencv-python +``` Both wheel families share the `cv2` namespace; keep only one installed at a time. From b3bb85e71416926159bdb14497580a3c7e4b2814 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:02:10 +0200 Subject: [PATCH 08/32] fix(tests): patch PIL.ImageTk via sys.modules to fix Python 3.12+ CI failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit patch("PIL.ImageTk.PhotoImage") resolved via pkgutil.resolve_name on Python 3.12+, which calls getattr(PIL, "ImageTk") — fails on headless runners without python3-tk. Replace with patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}) which injects the mock before the from PIL import ImageTk call inside show(), avoiding the import entirely. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- tests/utils/test_image_window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 06944ea68d..1d09e798c7 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -73,10 +73,12 @@ def test_show_creates_window_and_updates(self): mock_root = MagicMock() mock_label = MagicMock() mock_photo = MagicMock() + fake_imagetk = MagicMock() + fake_imagetk.PhotoImage.return_value = mock_photo with ( patch("supervision.utils.image_window.TkImageWindow._ensure_window"), - patch("PIL.ImageTk.PhotoImage", return_value=mock_photo) as mock_ph, + patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}), ): window._root = mock_root window._label = mock_label @@ -84,7 +86,7 @@ def test_show_creates_window_and_updates(self): frame = np.zeros((4, 4, 3), dtype=np.uint8) window.show(frame) - mock_ph.assert_called_once() + fake_imagetk.PhotoImage.assert_called_once() mock_label.configure.assert_called_once_with(image=mock_photo) mock_root.update_idletasks.assert_called_once() mock_root.update.assert_called_once() From 703862d97210288557bd30084ad6f2a5dc0b7645 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:03:53 +0200 Subject: [PATCH 09/32] fix(examples): revert 3 stream examples from TkImageWindow to cv2.imshow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InferencePipeline calls on_prediction() from a worker thread; tkinter requires all Tk calls from the thread that created Tk() root — macOS enforces this strictly (crash), Linux is undefined behavior. Revert inference_stream_example, rfdetr_stream_example, and ultralytics_stream_example to cv2.imshow/waitKey (requires opencv-python, not headless). --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- examples/time_in_zone/inference_stream_example.py | 7 ++++--- examples/time_in_zone/rfdetr_stream_example.py | 7 ++++--- examples/time_in_zone/ultralytics_stream_example.py | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/time_in_zone/inference_stream_example.py b/examples/time_in_zone/inference_stream_example.py index 47580c406c..1d42c259e4 100644 --- a/examples/time_in_zone/inference_stream_example.py +++ b/examples/time_in_zone/inference_stream_example.py @@ -1,3 +1,4 @@ +import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -27,7 +28,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] - self.window = sv.TkImageWindow("Processed Video") + self.window_title = "Processed Video" def on_prediction(self, result: dict, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -70,8 +71,8 @@ def on_prediction(self, result: dict, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - self.window.show(annotated_frame) - self.window.wait_key(1) + cv2.imshow(self.window_title, annotated_frame) + cv2.waitKey(1) def main( diff --git a/examples/time_in_zone/rfdetr_stream_example.py b/examples/time_in_zone/rfdetr_stream_example.py index 635fa0f069..19f11bec21 100644 --- a/examples/time_in_zone/rfdetr_stream_example.py +++ b/examples/time_in_zone/rfdetr_stream_example.py @@ -2,6 +2,7 @@ from enum import Enum +import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -89,7 +90,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] - self.window = sv.TkImageWindow("Processed Video") + self.window_title = "Processed Video" def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -128,8 +129,8 @@ def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - self.window.show(annotated_frame) - self.window.wait_key(1) + cv2.imshow(self.window_title, annotated_frame) + cv2.waitKey(1) def main( diff --git a/examples/time_in_zone/ultralytics_stream_example.py b/examples/time_in_zone/ultralytics_stream_example.py index 962509df5c..8236cf4c1a 100644 --- a/examples/time_in_zone/ultralytics_stream_example.py +++ b/examples/time_in_zone/ultralytics_stream_example.py @@ -1,5 +1,6 @@ from __future__ import annotations +import cv2 import numpy as np from inference import InferencePipeline from inference.core.interfaces.camera.entities import VideoFrame @@ -30,7 +31,7 @@ def __init__(self, zone_configuration_path: str, classes: list[int]): ) for polygon in self.polygons ] - self.window = sv.TkImageWindow("Processed Video") + self.window_title = "Processed Video" def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: self.fps_monitor.tick() @@ -72,8 +73,8 @@ def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: labels=labels, custom_color_lookup=custom_color_lookup, ) - self.window.show(annotated_frame) - self.window.wait_key(1) + cv2.imshow(self.window_title, annotated_frame) + cv2.waitKey(1) def main( From edec4850336332b93ec1968de6dc038032b6faf5 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:04:25 +0200 Subject: [PATCH 10/32] docs(image_window): clarify API differences from cv2 and correct exception docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'Drop-in replacement' with 'Functional replacement' — wait_key returns str not int, mouse callback signature differs from cv2, only left-button events captured - Add 'Differences from cv2' section documenting all three breaking behavioural differences for migrating callers - Correct documented failure mode for headless environments: raises AttributeError (PIL.ImageTk missing) not TclError when python3-tk absent; distinguish that from display-unavailable case (TclError) --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/supervision/utils/image_window.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 2e521ca96a..8c678f093d 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -14,11 +14,24 @@ class TkImageWindow: """Desktop image window backed by stdlib tkinter + pillow. - Drop-in replacement for `cv2.imshow` / `cv2.waitKey` that works under + Functional replacement for `cv2.imshow` / `cv2.waitKey` that works under `opencv-python-headless`. Requires tkinter (stdlib) and pillow (already a - supervision dependency). On headless servers with no display, instantiation - succeeds but `show()` / `wait_key()` will raise `tkinter.TclError` when - the window is first created. + supervision dependency). Instantiation always succeeds; `show()` raises + `AttributeError` (module 'PIL' has no attribute 'ImageTk') on environments + where `python3-tk` is absent, and raises `tkinter.TclError` on headless + servers where a display is unavailable. + + Differences from cv2: + - `wait_key()` returns a tkinter keysym `str` (e.g. ``"q"``, + ``"Return"``, ``"Escape"``) or ``None``, not an ``int``. Code + relying on ``key == ord("q")`` or ``key & 0xFF == 27`` must be + updated to ``key == "q"`` or ``key == "Escape"``. + - Mouse callback signature is ``(x: int, y: int, event_type: str)`` + where ``event_type`` is ``"down"``, ``"up"``, or ``"move"``. This + differs from cv2's ``(event: int, x, y, flags, param)`` — existing + cv2 mouse callbacks are not compatible without modification. + - Only left-button events are captured. Right-button clicks, scroll + events, and modifier-key flags (Ctrl, Shift) have no equivalent. Attributes: title: Window title bar text. From e1b59da55cff25be211fd131c866a7d7c54920c8 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:04:44 +0200 Subject: [PATCH 11/32] docs(video): add webcam ownership pattern to get_video_frames_generator PR description promised a webcam/VideoCapture note on this function but the docstring was left without guidance. Add a Note section showing the cv2.VideoCapture pattern with explicit release in a finally block for callers wanting live camera access. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/supervision/utils/video.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/supervision/utils/video.py b/src/supervision/utils/video.py index 6b31c7a0be..dcab0e56c1 100644 --- a/src/supervision/utils/video.py +++ b/src/supervision/utils/video.py @@ -270,6 +270,25 @@ def get_video_frames_generator( A generator that yields the frames of the video. + Note: + For live camera streams, use `cv2.VideoCapture` with an integer device + index directly. `get_video_frames_generator` is designed for file-based + sources; `cv2.VideoCapture` must be released by the caller when done: + + ```python + import cv2 + + cap = cv2.VideoCapture(0) # 0 = default webcam + try: + while cap.isOpened(): + success, frame = cap.read() + if not success: + break + ... # process frame + finally: + cap.release() + ``` + Examples: ```python import supervision as sv From f8b6bd4a2ad6174a641797a0abf65b1e8a6b224e Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:20 +0200 Subject: [PATCH 12/32] docs: add TkImageWindow to mkdocs navigation TkImageWindow is public API (exported in __all__) but had no docs page. Add docs/utils/image_window.md and wire it into mkdocs.yml under Utils. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/utils/image_window.md | 12 ++++++++++++ mkdocs.yml | 1 + 2 files changed, 13 insertions(+) create mode 100644 docs/utils/image_window.md diff --git a/docs/utils/image_window.md b/docs/utils/image_window.md new file mode 100644 index 0000000000..8623567ad7 --- /dev/null +++ b/docs/utils/image_window.md @@ -0,0 +1,12 @@ +--- +comments: true +status: new +--- + +# Image Window + + + +:::supervision.utils.image_window.TkImageWindow diff --git a/mkdocs.yml b/mkdocs.yml index c5f10c90ae..3101ee4406 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,7 @@ nav: - Utils: - Video: utils/video.md - Image: utils/image.md + - Image Window: utils/image_window.md - Iterables: utils/iterables.md - Notebook: utils/notebook.md - File: utils/file.md From 440839efad30a2e1590a6678a82f8e350d0b847b Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:23:35 +0200 Subject: [PATCH 13/32] fix(detection): add type args to _merge_obb_corners parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bare `list[np.ndarray]` with `list[npt.NDArray[np.floating]]` in _merge_obb_corners signature — fixes mypy missing-type-args error introduced by #2312 OBB NMM merge --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../time_in_zone/inference_stream_example.py | 122 ------------ .../time_in_zone/rfdetr_stream_example.py | 183 ------------------ .../ultralytics_stream_example.py | 131 ------------- src/supervision/detection/core.py | 4 +- 4 files changed, 3 insertions(+), 437 deletions(-) delete mode 100644 examples/time_in_zone/inference_stream_example.py delete mode 100644 examples/time_in_zone/rfdetr_stream_example.py delete mode 100644 examples/time_in_zone/ultralytics_stream_example.py diff --git a/examples/time_in_zone/inference_stream_example.py b/examples/time_in_zone/inference_stream_example.py deleted file mode 100644 index 1d42c259e4..0000000000 --- a/examples/time_in_zone/inference_stream_example.py +++ /dev/null @@ -1,122 +0,0 @@ -import cv2 -import numpy as np -from inference import InferencePipeline -from inference.core.interfaces.camera.entities import VideoFrame -from utils.general import find_in_list, load_zones_config -from utils.timers import ClockBasedTimer - -import supervision as sv - -COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) -COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) -LABEL_ANNOTATOR = sv.LabelAnnotator( - color=COLORS, text_color=sv.Color.from_hex("#000000") -) - - -class CustomSink: - def __init__(self, zone_configuration_path: str, classes: list[int]): - self.classes = classes - self.tracker = sv.ByteTrack(minimum_matching_threshold=0.5) - self.fps_monitor = sv.FPSMonitor() - self.polygons = load_zones_config(file_path=zone_configuration_path) - self.timers = [ClockBasedTimer() for _ in self.polygons] - self.zones = [ - sv.PolygonZone( - polygon=polygon, - triggering_anchors=(sv.Position.CENTER,), - ) - for polygon in self.polygons - ] - self.window_title = "Processed Video" - - def on_prediction(self, result: dict, frame: VideoFrame) -> None: - self.fps_monitor.tick() - fps = self.fps_monitor.fps - - detections = sv.Detections.from_inference(result) - detections = detections[find_in_list(detections.class_id, self.classes)] - detections = self.tracker.update_with_detections(detections) - - annotated_frame = frame.image.copy() - annotated_frame = sv.draw_text( - scene=annotated_frame, - text=f"{fps:.1f}", - text_anchor=sv.Point(40, 30), - background_color=sv.Color.from_hex("#A351FB"), - text_color=sv.Color.from_hex("#000000"), - ) - - for idx, zone in enumerate(self.zones): - annotated_frame = sv.draw_polygon( - scene=annotated_frame, polygon=zone.polygon, color=COLORS.by_idx(idx) - ) - - detections_in_zone = detections[zone.trigger(detections)] - time_in_zone = self.timers[idx].tick(detections_in_zone) - custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) - - annotated_frame = COLOR_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - custom_color_lookup=custom_color_lookup, - ) - labels = [ - f"#{tracker_id} {int(time // 60):02d}:{int(time % 60):02d}" - for tracker_id, time in zip(detections_in_zone.tracker_id, time_in_zone) - ] - annotated_frame = LABEL_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - labels=labels, - custom_color_lookup=custom_color_lookup, - ) - cv2.imshow(self.window_title, annotated_frame) - cv2.waitKey(1) - - -def main( - zone_configuration_path: str, - rtsp_url: str, - model_id: str = "rfdetr-medium", - confidence_threshold: float = 0.3, - iou_threshold: float = 0.7, - classes: list[int] = [], - roboflow_api_key: str = "", -) -> None: - """ - Calculating detections dwell time in zones, using RTSP stream. - - Args: - zone_configuration_path: Path to the zone configuration JSON file - rtsp_url: Complete RTSP URL for the video stream - model_id: Roboflow model ID - confidence_threshold: Confidence level for detections (0 to 1) - iou_threshold: IOU threshold for non-max suppression - classes: List of class IDs to track. If empty, all classes are tracked - roboflow_api_key: Roboflow API key for accessing private models - """ - sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) - - pipeline = InferencePipeline.init( - model_id=model_id, - video_reference=rtsp_url, - on_prediction=sink.on_prediction, - confidence=confidence_threshold, - iou_threshold=iou_threshold, - api_key=roboflow_api_key, - ) - - pipeline.start() - - try: - pipeline.join() - except KeyboardInterrupt: - pipeline.terminate() - - -if __name__ == "__main__": - from jsonargparse import auto_cli, set_parsing_settings - - set_parsing_settings(parse_optionals_as_positionals=True) - auto_cli(main, as_positional=False) diff --git a/examples/time_in_zone/rfdetr_stream_example.py b/examples/time_in_zone/rfdetr_stream_example.py deleted file mode 100644 index 19f11bec21..0000000000 --- a/examples/time_in_zone/rfdetr_stream_example.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import annotations - -from enum import Enum - -import cv2 -import numpy as np -from inference import InferencePipeline -from inference.core.interfaces.camera.entities import VideoFrame -from rfdetr import RFDETRBase, RFDETRLarge, RFDETRMedium, RFDETRNano, RFDETRSmall -from utils.general import find_in_list, load_zones_config -from utils.timers import ClockBasedTimer - -import supervision as sv - - -class ModelSize(Enum): - NANO = "nano" - SMALL = "small" - MEDIUM = "medium" - BASE = "base" - LARGE = "large" - - @classmethod - def list(cls): - return [c.value for c in cls] - - @classmethod - def from_value(cls, value: ModelSize | str) -> ModelSize: - if isinstance(value, cls): - return value - if isinstance(value, str): - value = value.lower() - try: - return cls(value) - except ValueError as exc: - raise ValueError( - f"Invalid model size '{value}'. Must be one of {cls.list()}." - ) from exc - raise ValueError( - f"Invalid value type '{type(value)}'. Expected str or ModelSize." - ) - - -def load_model(checkpoint: ModelSize | str, device: str, resolution: int): - checkpoint = ModelSize.from_value(checkpoint) - if checkpoint == ModelSize.NANO: - return RFDETRNano(device=device, resolution=resolution) - if checkpoint == ModelSize.SMALL: - return RFDETRSmall(device=device, resolution=resolution) - if checkpoint == ModelSize.MEDIUM: - return RFDETRMedium(device=device, resolution=resolution) - if checkpoint == ModelSize.BASE: - return RFDETRBase(device=device, resolution=resolution) - if checkpoint == ModelSize.LARGE: - return RFDETRLarge(device=device, resolution=resolution) - raise RuntimeError("Unhandled checkpoint type.") - - -def adjust_resolution(checkpoint: ModelSize | str, resolution: int) -> int: - checkpoint = ModelSize.from_value(checkpoint) - divisor = ( - 32 if checkpoint in {ModelSize.NANO, ModelSize.SMALL, ModelSize.MEDIUM} else 56 - ) - remainder = resolution % divisor - if remainder == 0: - return resolution - lower = resolution - remainder - upper = lower + divisor - return lower if resolution - lower < upper - resolution else upper - - -COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) -COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) -LABEL_ANNOTATOR = sv.LabelAnnotator( - color=COLORS, text_color=sv.Color.from_hex("#000000") -) - - -class CustomSink: - def __init__(self, zone_configuration_path: str, classes: list[int]): - self.classes = classes - self.tracker = sv.ByteTrack(minimum_matching_threshold=0.8) - self.fps_monitor = sv.FPSMonitor() - self.polygons = load_zones_config(file_path=zone_configuration_path) - self.timers = [ClockBasedTimer() for _ in self.polygons] - self.zones = [ - sv.PolygonZone( - polygon=polygon, - triggering_anchors=(sv.Position.CENTER,), - ) - for polygon in self.polygons - ] - self.window_title = "Processed Video" - - def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: - self.fps_monitor.tick() - fps = self.fps_monitor.fps - detections = detections[find_in_list(detections.class_id, self.classes)] - detections = self.tracker.update_with_detections(detections) - annotated_frame = frame.image.copy() - annotated_frame = sv.draw_text( - scene=annotated_frame, - text=f"{fps:.1f}", - text_anchor=sv.Point(40, 30), - background_color=sv.Color.from_hex("#A351FB"), - text_color=sv.Color.from_hex("#000000"), - ) - for idx, zone in enumerate(self.zones): - annotated_frame = sv.draw_polygon( - scene=annotated_frame, - polygon=zone.polygon, - color=COLORS.by_idx(idx), - ) - detections_in_zone = detections[zone.trigger(detections)] - time_in_zone = self.timers[idx].tick(detections_in_zone) - custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) - annotated_frame = COLOR_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - custom_color_lookup=custom_color_lookup, - ) - labels = [ - f"#{tracker_id} {int(t // 60):02d}:{int(t % 60):02d}" - for tracker_id, t in zip(detections_in_zone.tracker_id, time_in_zone) - ] - annotated_frame = LABEL_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - labels=labels, - custom_color_lookup=custom_color_lookup, - ) - cv2.imshow(self.window_title, annotated_frame) - cv2.waitKey(1) - - -def main( - rtsp_url: str, - zone_configuration_path: str, - resolution: int, - model_size: str = "small", - device: str = "cpu", - confidence_threshold: float = 0.3, - iou_threshold: float = 0.7, - classes: list[int] = [], -) -> None: - """ - Calculating detections dwell time in zones using an RTSP stream. - - Args: - rtsp_url: Complete RTSP URL for the video stream - zone_configuration_path: Path to the zone configuration JSON file - resolution: Input resolution for the model - model_size: RF-DETR model size ('nano', 'small', 'medium', 'base' or 'large') - device: Computation device ('cpu', 'mps' or 'cuda') - confidence_threshold: Confidence level for detections (0 to 1) - iou_threshold: IOU threshold for non-max suppression - classes: List of class IDs to track. If empty, all classes are tracked - """ - resolution = adjust_resolution(checkpoint=model_size, resolution=resolution) - model = load_model(checkpoint=model_size, device=device, resolution=resolution) - - def inference_callback(frames: list[VideoFrame]) -> list[sv.Detections]: - dets = model.predict(frames[0].image, threshold=confidence_threshold) - return [dets.with_nms(threshold=iou_threshold)] - - sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) - pipeline = InferencePipeline.init_with_custom_logic( - video_reference=rtsp_url, - on_video_frame=inference_callback, - on_prediction=sink.on_prediction, - ) - pipeline.start() - try: - pipeline.join() - except KeyboardInterrupt: - pipeline.terminate() - - -if __name__ == "__main__": - from jsonargparse import auto_cli, set_parsing_settings - - set_parsing_settings(parse_optionals_as_positionals=True) - auto_cli(main, as_positional=False) diff --git a/examples/time_in_zone/ultralytics_stream_example.py b/examples/time_in_zone/ultralytics_stream_example.py deleted file mode 100644 index 8236cf4c1a..0000000000 --- a/examples/time_in_zone/ultralytics_stream_example.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import cv2 -import numpy as np -from inference import InferencePipeline -from inference.core.interfaces.camera.entities import VideoFrame -from ultralytics import YOLO -from utils.general import find_in_list, load_zones_config -from utils.timers import ClockBasedTimer - -import supervision as sv - -COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) -COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) -LABEL_ANNOTATOR = sv.LabelAnnotator( - color=COLORS, text_color=sv.Color.from_hex("#000000") -) - - -class CustomSink: - def __init__(self, zone_configuration_path: str, classes: list[int]): - self.classes = classes - self.tracker = sv.ByteTrack(minimum_matching_threshold=0.8) - self.fps_monitor = sv.FPSMonitor() - self.polygons = load_zones_config(file_path=zone_configuration_path) - self.timers = [ClockBasedTimer() for _ in self.polygons] - self.zones = [ - sv.PolygonZone( - polygon=polygon, - triggering_anchors=(sv.Position.CENTER,), - ) - for polygon in self.polygons - ] - self.window_title = "Processed Video" - - def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: - self.fps_monitor.tick() - fps = self.fps_monitor.fps - - detections = detections[find_in_list(detections.class_id, self.classes)] - detections = self.tracker.update_with_detections(detections) - - annotated_frame = frame.image.copy() - annotated_frame = sv.draw_text( - scene=annotated_frame, - text=f"{fps:.1f}", - text_anchor=sv.Point(40, 30), - background_color=sv.Color.from_hex("#A351FB"), - text_color=sv.Color.from_hex("#000000"), - ) - - for idx, zone in enumerate(self.zones): - annotated_frame = sv.draw_polygon( - scene=annotated_frame, polygon=zone.polygon, color=COLORS.by_idx(idx) - ) - - detections_in_zone = detections[zone.trigger(detections)] - time_in_zone = self.timers[idx].tick(detections_in_zone) - custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) - - annotated_frame = COLOR_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - custom_color_lookup=custom_color_lookup, - ) - labels = [ - f"#{tracker_id} {int(time // 60):02d}:{int(time % 60):02d}" - for tracker_id, time in zip(detections_in_zone.tracker_id, time_in_zone) - ] - annotated_frame = LABEL_ANNOTATOR.annotate( - scene=annotated_frame, - detections=detections_in_zone, - labels=labels, - custom_color_lookup=custom_color_lookup, - ) - cv2.imshow(self.window_title, annotated_frame) - cv2.waitKey(1) - - -def main( - zone_configuration_path: str, - rtsp_url: str, - weights: str = "yolov8s.pt", - device: str = "cpu", - confidence_threshold: float = 0.3, - iou_threshold: float = 0.7, - classes: list[int] = [], -) -> None: - """ - Calculating detections dwell time in zones, using RTSP stream. - - Args: - zone_configuration_path: Path to the zone configuration JSON file - rtsp_url: Complete RTSP URL for the video stream - weights: Path to the model weights file - device: Computation device ('cpu', 'mps' or 'cuda') - confidence_threshold: Confidence level for detections (0 to 1) - iou_threshold: IOU threshold for non-max suppression - classes: List of class IDs to track. If empty, all classes are tracked - """ - model = YOLO(weights) - - def inference_callback(frames: list[VideoFrame]) -> list[sv.Detections]: - results = model( - frames[0].image, verbose=False, conf=confidence_threshold, device=device - )[0] - return [ - sv.Detections.from_ultralytics(results).with_nms(threshold=iou_threshold) - ] - - sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) - - pipeline = InferencePipeline.init_with_custom_logic( - video_reference=rtsp_url, - on_video_frame=inference_callback, - on_prediction=sink.on_prediction, - ) - - pipeline.start() - - try: - pipeline.join() - except KeyboardInterrupt: - pipeline.terminate() - - -if __name__ == "__main__": - from jsonargparse import auto_cli, set_parsing_settings - - set_parsing_settings(parse_optionals_as_positionals=True) - auto_cli(main, as_positional=False) diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 4f38f1e594..7832808fee 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -2632,7 +2632,9 @@ def with_nmm( return Detections.merge(result) -def _merge_obb_corners(corners_list: list[np.ndarray]) -> npt.NDArray[np.floating]: +def _merge_obb_corners( + corners_list: list[npt.NDArray[np.floating]], +) -> npt.NDArray[np.floating]: """Merge multiple OBB corner arrays using winner-angle projection. The first entry in *corners_list* is the winner. Its orientation angle From 64382e9cfd35f1854718c911054e1fee65116584 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:23:44 +0200 Subject: [PATCH 14/32] fix(utils): correct TkImageWindow docstring, key-queue, ghost-window, race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [resolve #4] fix docstring: show() raises ModuleNotFoundError (not AttributeError) when python3-tk absent; add apt-get/brew install hint - [resolve #5] clear _key_queue in _reset_window_refs() so stale keypresses from a previous session don't fire after close()+show() - [resolve #6] wait_key() returns None immediately when _root is None and queue is empty — matches cv2.waitKey() contract (no ghost window) - [resolve #9] re-check _key_queue before wait_variable in _wait_for_key_or_close() to close the _on_key arrival race window --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/supervision/utils/image_window.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 8c678f093d..ae94748a7e 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -17,9 +17,11 @@ class TkImageWindow: Functional replacement for `cv2.imshow` / `cv2.waitKey` that works under `opencv-python-headless`. Requires tkinter (stdlib) and pillow (already a supervision dependency). Instantiation always succeeds; `show()` raises - `AttributeError` (module 'PIL' has no attribute 'ImageTk') on environments - where `python3-tk` is absent, and raises `tkinter.TclError` on headless - servers where a display is unavailable. + `ModuleNotFoundError` (No module named '_tkinter') on environments where + `python3-tk` is absent — install with ``sudo apt-get install python3-tk`` + (Debian/Ubuntu) or ``brew install python-tk`` (macOS with Homebrew/pyenv) + — and raises `tkinter.TclError` on headless servers where a display is + unavailable. Differences from cv2: - `wait_key()` returns a tkinter keysym `str` (e.g. ``"q"``, @@ -115,6 +117,8 @@ def wait_key(self, delay_ms: int = 0) -> str | None: The tkinter keysym string (e.g. ``"q"``, ``"Return"``, ``"Escape"``) or ``None`` if the timeout elapsed without a key event. """ + if self._root is None and not self._key_queue: + return None self._ensure_window() if self._key_queue: return self._key_queue.pop(0) @@ -196,6 +200,8 @@ def _signal_wait(self) -> None: def _wait_for_key_or_close(self) -> None: if self._root is None or self._key_event is None: return + if self._key_queue: + return try: self._root.wait_variable(self._key_event) except Exception: @@ -214,6 +220,7 @@ def _reset_window_refs(self) -> None: self._label = None self._photo = None self._key_event = None + self._key_queue.clear() # ------------------------------------------------------------------ From c58b9c41879a7811e93b76b680b3aa4f180ffc17 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:24:36 +0200 Subject: [PATCH 15/32] docs(changelog): add TkImageWindow entry, fix pip co-install wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [resolve #3] add Added: sv.TkImageWindow entry with key differences from cv2 (wait_key return type, mouse signature, left-button-only) - [resolve #2] replace 'resolver conflict' with accurate 'silent co-install with filesystem collision' — pip installs both wheels silently; whichever ran last wins; advise explicit uninstall - [resolve #8] add python3-tk system package install instructions (apt-get / brew) in TkImageWindow entry --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/changelog.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 9165fc9691..12acbebc17 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,12 @@ date_modified: 2026-06-15 ### UnReleased +- Added: [`sv.TkImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: + - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. + - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. + - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. + - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install python-tk` on macOS with Homebrew/pyenv. + - Changed: `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. @@ -18,7 +24,11 @@ date_modified: 2026-06-15 ``` Both wheel families share the `cv2` namespace; keep only one installed at a time. - **Co-installation conflict:** packages that pin `opencv-python` (e.g. `ultralytics`, `inference-sdk`) cannot be installed alongside `supervision` without a resolver conflict. If you depend on both, pin `opencv-python` explicitly in your environment and the resolver will prefer it over `opencv-python-headless`. + **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. The safe fix is to explicitly remove the headless wheel afterward: + ```bash + pip uninstall -y opencv-python-headless + pip install opencv-python + ``` - Fixed [#2322](https://github.com/roboflow/supervision/pull/2322): COCO export now preserves all polygon parts for multi-component masks. Previously, only the first polygon was written when a non-crowd mask had disjoint segments; all parts are now included. From 37d0ab17d473e09aa50eab18ee233529b3ca1540 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:24:43 +0200 Subject: [PATCH 16/32] test(utils): add edge-case tests, parametrize TkImageWindow error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [resolve #7] add test_wait_key_returns_none_when_no_window — tests fix #6 (early return when no window and queue empty) - add test_wait_key_returns_none_when_closed_mid_wait — tests interaction of fix #5 + fix #9 (close mid-wait clears queue, wait_key returns None) - add test_close_clears_key_queue — tests fix #5 (_key_queue cleared on close) - collapse two structurally-identical show() error-path tests into one parametrized test (PT006-compliant tuple first arg) --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- tests/utils/test_image_window.py | 59 ++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 1d09e798c7..0e927ebac3 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -55,17 +55,28 @@ def test_invalid_shape_raises(self, shape): class TestTkImageWindowShow: - def test_show_raises_type_error_for_non_uint8(self): - """show() rejects arrays whose dtype is not uint8.""" - window = TkImageWindow("test") - with pytest.raises(TypeError, match="uint8"): - window.show(np.zeros((10, 10, 3), dtype=np.float32)) - - def test_show_raises_value_error_for_bad_shape(self): - """show() propagates ValueError for unsupported array shapes.""" + @pytest.mark.parametrize( + ("array", "exc_type", "match"), + [ + pytest.param( + np.zeros((10, 10, 3), dtype=np.float32), + TypeError, + "uint8", + id="float32-dtype-raises-type-error", + ), + pytest.param( + np.zeros((10, 10, 2), dtype=np.uint8), + ValueError, + "Expected shape", + id="2-channel-raises-value-error", + ), + ], + ) + def test_show_raises_for_invalid_input(self, array, exc_type, match): + """show() rejects arrays with invalid dtype or shape.""" window = TkImageWindow("test") - with pytest.raises(ValueError, match="Expected shape"): - window.show(np.zeros((10, 10, 2), dtype=np.uint8)) + with pytest.raises(exc_type, match=match): + window.show(array) def test_show_creates_window_and_updates(self): """show() creates a Tk window, sets the PhotoImage, and calls update.""" @@ -93,6 +104,26 @@ def test_show_creates_window_and_updates(self): class TestTkImageWindowWaitKey: + def test_wait_key_returns_none_when_no_window(self): + """wait_key() returns None immediately when no window exists.""" + window = TkImageWindow() + assert window.wait_key(delay_ms=0) is None + assert window.wait_key(delay_ms=1) is None + + def test_wait_key_returns_none_when_closed_mid_wait(self): + """wait_key(0) returns None when close() fires during blocking wait.""" + window = TkImageWindow() + mock_root = MagicMock() + mock_event = MagicMock() + mock_root.wait_variable.side_effect = lambda _: window.close() + window._root = mock_root + window._key_event = mock_event + + result = window.wait_key(delay_ms=0) + + assert result is None + assert window._root is None + def test_wait_key_returns_queued_key_immediately(self): """wait_key() returns the first queued keysym without calling update.""" window = TkImageWindow() @@ -173,6 +204,14 @@ def test_close_is_idempotent(self): window.close() # no window created window.close() # second call must not raise + def test_close_clears_key_queue(self): + """close() discards stale keys so they don't fire on next open.""" + window = TkImageWindow() + window._root = MagicMock() + window._key_queue = ["q", "Escape"] + window.close() + assert window._key_queue == [] + def test_window_exists_returns_false_for_destroyed_root(self): """Destroyed Tk roots are not reused after a window-manager close.""" window = TkImageWindow() From d5745f138587ab74224f173605e783f0282a48e7 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:27:58 +0200 Subject: [PATCH 17/32] feat(utils): resize TkImageWindow scales image, not crops - Add _on_configure binding that rescales stored image to new window dims - Add _update_display() to separate frame rendering from show() plumbing - Add _fit_image() helper: aspect-ratio fit or free-form stretch - Expose keep_aspect_ratio param on TkImageWindow (default True) - Add 16 tests: TestFitImage, TestTkImageWindowUpdateDisplay, TestTkImageWindowOnConfigure --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/supervision/utils/image_window.py | 60 ++++++-- tests/utils/test_image_window.py | 194 +++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index ae94748a7e..38fbd82d77 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -35,8 +35,11 @@ class TkImageWindow: - Only left-button events are captured. Right-button clicks, scroll events, and modifier-key flags (Ctrl, Shift) have no equivalent. - Attributes: + Args: title: Window title bar text. + keep_aspect_ratio: If ``True`` (default), frames are scaled to fit + inside the window while preserving aspect ratio (letterboxed). If + ``False``, frames are stretched to fill the window exactly. Examples: ```python @@ -64,14 +67,20 @@ class TkImageWindow: ``` """ - def __init__(self, title: str = "supervision") -> None: + def __init__( + self, title: str = "supervision", keep_aspect_ratio: bool = True + ) -> None: self.title = title + self.keep_aspect_ratio = keep_aspect_ratio self._mouse_callback: MouseCallback | None = None self._root: Any = None self._label: Any = None self._photo: Any = None self._key_event: Any = None self._key_queue: list[str] = [] + self._pil_image: Image.Image | None = None + self._win_w: int = 0 + self._win_h: int = 0 # ------------------------------------------------------------------ # Public API @@ -80,6 +89,9 @@ def __init__(self, title: str = "supervision") -> None: def show(self, image: npt.NDArray[np.uint8]) -> None: """Display a BGR, grayscale, or BGRA frame in the window. + The image is scaled to fill the current window dimensions while + preserving aspect ratio. Resizing the window rescales live. + Args: image: uint8 numpy array. Accepted shapes: - ``(H, W)`` — grayscale @@ -96,12 +108,9 @@ def show(self, image: npt.NDArray[np.uint8]) -> None: f"image must be uint8, got {image.dtype}. " "Convert with image.astype(np.uint8) before calling show()." ) - pil_image = _bgr_to_pil(image) + self._pil_image = _bgr_to_pil(image) self._ensure_window() - from PIL import ImageTk - - self._photo = ImageTk.PhotoImage(pil_image) - self._label.configure(image=self._photo) + self._update_display() self._root.update_idletasks() self._root.update() @@ -175,14 +184,35 @@ def _ensure_window(self) -> None: self._root = tk.Tk() self._root.title(self.title) self._label = tk.Label(self._root) - self._label.pack() + self._label.pack(expand=True, fill="both") self._key_event = tk.IntVar(master=self._root, value=0) self._root.bind("", self._on_key) + self._root.bind("", self._on_configure) self._root.protocol("WM_DELETE_WINDOW", self.close) self._label.bind("", lambda e: self._on_mouse(e, "down")) self._label.bind("", lambda e: self._on_mouse(e, "up")) self._label.bind("", lambda e: self._on_mouse(e, "move")) + def _on_configure(self, event: Any) -> None: + if event.widget is not self._root: + return + w, h = event.width, event.height + if w > 1 and h > 1 and (w, h) != (self._win_w, self._win_h): + self._win_w, self._win_h = w, h + if self._pil_image is not None: + self._update_display() + + def _update_display(self) -> None: + if self._pil_image is None or self._label is None: + return + from PIL import ImageTk + + img = self._pil_image + if self._win_w > 1 and self._win_h > 1: + img = _fit_image(img, self._win_w, self._win_h, self.keep_aspect_ratio) + self._photo = ImageTk.PhotoImage(img) + self._label.configure(image=self._photo) + def _on_key(self, event: Any) -> None: self._key_queue.append(event.keysym) self._signal_wait() @@ -228,6 +258,20 @@ def _reset_window_refs(self) -> None: # ------------------------------------------------------------------ +def _fit_image( + image: Image.Image, width: int, height: int, keep_aspect_ratio: bool = True +) -> Image.Image: + iw, ih = image.size + if keep_aspect_ratio: + scale = min(width / iw, height / ih) + new_w, new_h = max(1, round(iw * scale)), max(1, round(ih * scale)) + else: + new_w, new_h = width, height + if (new_w, new_h) == (iw, ih): + return image + return image.resize((new_w, new_h), Image.Resampling.LANCZOS) + + def _bgr_to_pil(image: npt.NDArray[np.uint8]) -> Image.Image: if image.ndim == 2: return Image.fromarray(np.ascontiguousarray(image)) diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 0e927ebac3..6d439402a8 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -4,8 +4,9 @@ import numpy as np import pytest +from PIL import Image -from supervision.utils.image_window import TkImageWindow, _bgr_to_pil +from supervision.utils.image_window import TkImageWindow, _bgr_to_pil, _fit_image class TestBgrToPil: @@ -54,6 +55,50 @@ def test_invalid_shape_raises(self, shape): _bgr_to_pil(arr) +class TestFitImage: + def test_scale_down_width_limited(self): + """Width-constrained image is scaled so width fits exactly.""" + img = Image.new("RGB", (200, 100)) + result = _fit_image(img, 100, 200) + assert result.size == (100, 50) + + def test_scale_down_height_limited(self): + """Height-constrained image is scaled so height fits exactly.""" + img = Image.new("RGB", (100, 200)) + result = _fit_image(img, 200, 100) + assert result.size == (50, 100) + + def test_scale_up(self): + """Image smaller than the window is scaled up to fill it.""" + img = Image.new("RGB", (50, 50)) + result = _fit_image(img, 200, 200) + assert result.size == (200, 200) + + def test_same_size_returns_original(self): + """Image already matching the window is returned unchanged.""" + img = Image.new("RGB", (100, 100)) + result = _fit_image(img, 100, 100) + assert result is img + + def test_aspect_ratio_preserved_by_default(self): + """Non-square image keeps its aspect ratio when scaled.""" + img = Image.new("RGB", (400, 200)) + result = _fit_image(img, 100, 100) + assert result.size == (100, 50) + + def test_free_form_stretches_to_exact_size(self): + """keep_aspect_ratio=False stretches the image to the exact window size.""" + img = Image.new("RGB", (400, 200)) + result = _fit_image(img, 100, 100, keep_aspect_ratio=False) + assert result.size == (100, 100) + + def test_free_form_same_size_returns_original(self): + """keep_aspect_ratio=False with matching size returns the original.""" + img = Image.new("RGB", (100, 100)) + result = _fit_image(img, 100, 100, keep_aspect_ratio=False) + assert result is img + + class TestTkImageWindowShow: @pytest.mark.parametrize( ("array", "exc_type", "match"), @@ -103,6 +148,153 @@ def test_show_creates_window_and_updates(self): mock_root.update.assert_called_once() +class TestTkImageWindowUpdateDisplay: + def test_no_op_without_pil_image(self): + """_update_display() is a no-op when no image has been shown yet.""" + window = TkImageWindow() + window._label = MagicMock() + window._update_display() + window._label.configure.assert_not_called() + + def test_no_op_without_label(self): + """_update_display() does not raise when the window has no label.""" + window = TkImageWindow() + window._pil_image = Image.new("RGB", (10, 10)) + window._update_display() # must not raise + + def test_uses_native_size_when_window_size_unknown(self): + """Native resolution is used when _win_w/_win_h are still 0.""" + window = TkImageWindow() + window._pil_image = Image.new("RGB", (80, 60)) + mock_label = MagicMock() + window._label = mock_label + fake_imagetk = MagicMock() + + with patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}): + window._update_display() + + displayed = fake_imagetk.PhotoImage.call_args[0][0] + assert displayed.size == (80, 60) + + def test_scales_image_to_window_size(self): + """Image is rescaled to fit _win_w x _win_h when dimensions are known.""" + window = TkImageWindow() + window._pil_image = Image.new("RGB", (400, 200)) + window._win_w = 200 + window._win_h = 200 + window._label = MagicMock() + fake_imagetk = MagicMock() + + with patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}): + window._update_display() + + # 400x200 into 200x200, width-constrained, gives 200x100 + displayed = fake_imagetk.PhotoImage.call_args[0][0] + assert displayed.size == (200, 100) + + def test_stretches_image_when_keep_aspect_ratio_false(self): + """Image is stretched to fill window when keep_aspect_ratio=False.""" + window = TkImageWindow(keep_aspect_ratio=False) + window._pil_image = Image.new("RGB", (400, 200)) + window._win_w = 200 + window._win_h = 200 + window._label = MagicMock() + fake_imagetk = MagicMock() + + with patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}): + window._update_display() + + displayed = fake_imagetk.PhotoImage.call_args[0][0] + assert displayed.size == (200, 200) + + +class TestTkImageWindowOnConfigure: + def test_non_root_widget_is_ignored(self): + """ events from child widgets do not update dimensions.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + event = MagicMock() + event.widget = MagicMock() # different object, not root + event.width = 200 + event.height = 100 + + with patch.object(window, "_update_display") as mock_update: + window._on_configure(event) + + assert window._win_w == 0 + assert window._win_h == 0 + mock_update.assert_not_called() + + def test_same_size_event_is_ignored(self): + """ with unchanged dimensions does not trigger a redraw.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + window._win_w = 200 + window._win_h = 100 + event = MagicMock() + event.widget = mock_root + event.width = 200 + event.height = 100 + + with patch.object(window, "_update_display") as mock_update: + window._on_configure(event) + + mock_update.assert_not_called() + + def test_degenerate_size_is_ignored(self): + """ with width or height <= 1 (pre-geometry) is skipped.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + event = MagicMock() + event.widget = mock_root + event.width = 1 + event.height = 100 + + with patch.object(window, "_update_display") as mock_update: + window._on_configure(event) + + assert window._win_w == 0 + mock_update.assert_not_called() + + def test_new_size_updates_dimensions_and_redraws(self): + """ with new dimensions updates _win_w/_win_h and redraws.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + window._pil_image = Image.new("RGB", (100, 100)) + event = MagicMock() + event.widget = mock_root + event.width = 320 + event.height = 240 + + with patch.object(window, "_update_display") as mock_update: + window._on_configure(event) + + assert window._win_w == 320 + assert window._win_h == 240 + mock_update.assert_called_once() + + def test_new_size_without_image_skips_redraw(self): + """ with new dimensions but no image updates dims only.""" + window = TkImageWindow() + mock_root = MagicMock() + window._root = mock_root + event = MagicMock() + event.widget = mock_root + event.width = 320 + event.height = 240 + + with patch.object(window, "_update_display") as mock_update: + window._on_configure(event) + + assert window._win_w == 320 + assert window._win_h == 240 + mock_update.assert_not_called() + + class TestTkImageWindowWaitKey: def test_wait_key_returns_none_when_no_window(self): """wait_key() returns None immediately when no window exists.""" From 4227ad9b5bf5dbcc08f388784801905fcb81abd8 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:41:31 +0200 Subject: [PATCH 18/32] revert accidental delete --- .../time_in_zone/inference_stream_example.py | 121 ++++++++++++ .../time_in_zone/rfdetr_stream_example.py | 184 ++++++++++++++++++ .../ultralytics_stream_example.py | 128 ++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 examples/time_in_zone/inference_stream_example.py create mode 100644 examples/time_in_zone/rfdetr_stream_example.py create mode 100644 examples/time_in_zone/ultralytics_stream_example.py diff --git a/examples/time_in_zone/inference_stream_example.py b/examples/time_in_zone/inference_stream_example.py new file mode 100644 index 0000000000..e88a55fc04 --- /dev/null +++ b/examples/time_in_zone/inference_stream_example.py @@ -0,0 +1,121 @@ +import cv2 +import numpy as np +from inference import InferencePipeline +from inference.core.interfaces.camera.entities import VideoFrame +from utils.general import find_in_list, load_zones_config +from utils.timers import ClockBasedTimer + +import supervision as sv + +COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) +COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) +LABEL_ANNOTATOR = sv.LabelAnnotator( + color=COLORS, text_color=sv.Color.from_hex("#000000") +) + + +class CustomSink: + def __init__(self, zone_configuration_path: str, classes: list[int]) -> None: + self.classes = classes + self.tracker = sv.ByteTrack(minimum_matching_threshold=0.5) + self.fps_monitor = sv.FPSMonitor() + self.polygons = load_zones_config(file_path=zone_configuration_path) + self.timers = [ClockBasedTimer() for _ in self.polygons] + self.zones = [ + sv.PolygonZone( + polygon=polygon, + triggering_anchors=(sv.Position.CENTER,), + ) + for polygon in self.polygons + ] + + def on_prediction(self, result: dict, frame: VideoFrame) -> None: + self.fps_monitor.tick() + fps = self.fps_monitor.fps + + detections = sv.Detections.from_inference(result) + detections = detections[find_in_list(detections.class_id, self.classes)] + detections = self.tracker.update_with_detections(detections) + + annotated_frame = frame.image.copy() + annotated_frame = sv.draw_text( + scene=annotated_frame, + text=f"{fps:.1f}", + text_anchor=sv.Point(40, 30), + background_color=sv.Color.from_hex("#A351FB"), + text_color=sv.Color.from_hex("#000000"), + ) + + for idx, zone in enumerate(self.zones): + annotated_frame = sv.draw_polygon( + scene=annotated_frame, polygon=zone.polygon, color=COLORS.by_idx(idx) + ) + + detections_in_zone = detections[zone.trigger(detections)] + time_in_zone = self.timers[idx].tick(detections_in_zone) + custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) + + annotated_frame = COLOR_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + custom_color_lookup=custom_color_lookup, + ) + labels = [ + f"#{tracker_id} {int(time // 60):02d}:{int(time % 60):02d}" + for tracker_id, time in zip(detections_in_zone.tracker_id, time_in_zone) + ] + annotated_frame = LABEL_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + labels=labels, + custom_color_lookup=custom_color_lookup, + ) + cv2.imshow("Processed Video", annotated_frame) + cv2.waitKey(1) + + +def main( + zone_configuration_path: str, + rtsp_url: str, + model_id: str = "rfdetr-medium", + confidence_threshold: float = 0.3, + iou_threshold: float = 0.7, + classes: list[int] = [], + roboflow_api_key: str = "", +) -> None: + """ + Calculating detections dwell time in zones, using RTSP stream. + + Args: + zone_configuration_path: Path to the zone configuration JSON file + rtsp_url: Complete RTSP URL for the video stream + model_id: Roboflow model ID + confidence_threshold: Confidence level for detections (0 to 1) + iou_threshold: IOU threshold for non-max suppression + classes: List of class IDs to track. If empty, all classes are tracked + roboflow_api_key: Roboflow API key for accessing private models + """ + sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) + + pipeline = InferencePipeline.init( + model_id=model_id, + video_reference=rtsp_url, + on_prediction=sink.on_prediction, + confidence=confidence_threshold, + iou_threshold=iou_threshold, + api_key=roboflow_api_key, + ) + + pipeline.start() + + try: + pipeline.join() + except KeyboardInterrupt: + pipeline.terminate() + + +if __name__ == "__main__": + from jsonargparse import auto_cli, set_parsing_settings + + set_parsing_settings(parse_optionals_as_positionals=True) + auto_cli(main, as_positional=False) diff --git a/examples/time_in_zone/rfdetr_stream_example.py b/examples/time_in_zone/rfdetr_stream_example.py new file mode 100644 index 0000000000..678faaf90d --- /dev/null +++ b/examples/time_in_zone/rfdetr_stream_example.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from enum import Enum + +import cv2 +import numpy as np +from inference import InferencePipeline +from inference.core.interfaces.camera.entities import VideoFrame +from rfdetr import RFDETRBase, RFDETRLarge, RFDETRMedium, RFDETRNano, RFDETRSmall +from utils.general import find_in_list, load_zones_config +from utils.timers import ClockBasedTimer + +import supervision as sv + + +class ModelSize(Enum): + NANO = "nano" + SMALL = "small" + MEDIUM = "medium" + BASE = "base" + LARGE = "large" + + @classmethod + def list(cls) -> list[str]: + return [c.value for c in cls] + + @classmethod + def from_value(cls, value: ModelSize | str) -> ModelSize: + if isinstance(value, cls): + return value + if isinstance(value, str): + value = value.lower() + try: + return cls(value) + except ValueError as exc: + raise ValueError( + f"Invalid model size '{value}'. Must be one of {cls.list()}." + ) from exc + raise ValueError( + f"Invalid value type '{type(value)}'. Expected str or ModelSize." + ) + + +def load_model( + checkpoint: ModelSize | str, device: str, resolution: int +) -> RFDETRBase | RFDETRLarge | RFDETRMedium | RFDETRNano | RFDETRSmall: + checkpoint = ModelSize.from_value(checkpoint) + if checkpoint == ModelSize.NANO: + return RFDETRNano(device=device, resolution=resolution) + if checkpoint == ModelSize.SMALL: + return RFDETRSmall(device=device, resolution=resolution) + if checkpoint == ModelSize.MEDIUM: + return RFDETRMedium(device=device, resolution=resolution) + if checkpoint == ModelSize.BASE: + return RFDETRBase(device=device, resolution=resolution) + if checkpoint == ModelSize.LARGE: + return RFDETRLarge(device=device, resolution=resolution) + raise RuntimeError("Unhandled checkpoint type.") + + +def adjust_resolution(checkpoint: ModelSize | str, resolution: int) -> int: + checkpoint = ModelSize.from_value(checkpoint) + divisor = ( + 32 if checkpoint in {ModelSize.NANO, ModelSize.SMALL, ModelSize.MEDIUM} else 56 + ) + remainder = resolution % divisor + if remainder == 0: + return resolution + lower = resolution - remainder + upper = lower + divisor + return lower if resolution - lower < upper - resolution else upper + + +COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) +COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) +LABEL_ANNOTATOR = sv.LabelAnnotator( + color=COLORS, text_color=sv.Color.from_hex("#000000") +) + + +class CustomSink: + def __init__(self, zone_configuration_path: str, classes: list[int]) -> None: + self.classes = classes + self.tracker = sv.ByteTrack(minimum_matching_threshold=0.8) + self.fps_monitor = sv.FPSMonitor() + self.polygons = load_zones_config(file_path=zone_configuration_path) + self.timers = [ClockBasedTimer() for _ in self.polygons] + self.zones = [ + sv.PolygonZone( + polygon=polygon, + triggering_anchors=(sv.Position.CENTER,), + ) + for polygon in self.polygons + ] + + def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: + self.fps_monitor.tick() + fps = self.fps_monitor.fps + detections = detections[find_in_list(detections.class_id, self.classes)] + detections = self.tracker.update_with_detections(detections) + annotated_frame = frame.image.copy() + annotated_frame = sv.draw_text( + scene=annotated_frame, + text=f"{fps:.1f}", + text_anchor=sv.Point(40, 30), + background_color=sv.Color.from_hex("#A351FB"), + text_color=sv.Color.from_hex("#000000"), + ) + for idx, zone in enumerate(self.zones): + annotated_frame = sv.draw_polygon( + scene=annotated_frame, + polygon=zone.polygon, + color=COLORS.by_idx(idx), + ) + detections_in_zone = detections[zone.trigger(detections)] + time_in_zone = self.timers[idx].tick(detections_in_zone) + custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) + annotated_frame = COLOR_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + custom_color_lookup=custom_color_lookup, + ) + labels = [ + f"#{tracker_id} {int(t // 60):02d}:{int(t % 60):02d}" + for tracker_id, t in zip(detections_in_zone.tracker_id, time_in_zone) + ] + annotated_frame = LABEL_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + labels=labels, + custom_color_lookup=custom_color_lookup, + ) + cv2.imshow("Processed Video", annotated_frame) + cv2.waitKey(1) + + +def main( + rtsp_url: str, + zone_configuration_path: str, + resolution: int, + model_size: str = "small", + device: str = "cpu", + confidence_threshold: float = 0.3, + iou_threshold: float = 0.7, + classes: list[int] = [], +) -> None: + """ + Calculating detections dwell time in zones using an RTSP stream. + + Args: + rtsp_url: Complete RTSP URL for the video stream + zone_configuration_path: Path to the zone configuration JSON file + resolution: Input resolution for the model + model_size: RF-DETR model size ('nano', 'small', 'medium', 'base' or 'large') + device: Computation device ('cpu', 'mps' or 'cuda') + confidence_threshold: Confidence level for detections (0 to 1) + iou_threshold: IOU threshold for non-max suppression + classes: List of class IDs to track. If empty, all classes are tracked + """ + resolution = adjust_resolution(checkpoint=model_size, resolution=resolution) + model = load_model(checkpoint=model_size, device=device, resolution=resolution) + + def inference_callback(frames: list[VideoFrame]) -> list[sv.Detections]: + dets = model.predict(frames[0].image, threshold=confidence_threshold) + return [dets.with_nms(threshold=iou_threshold)] + + sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) + pipeline = InferencePipeline.init_with_custom_logic( + video_reference=rtsp_url, + on_video_frame=inference_callback, + on_prediction=sink.on_prediction, + ) + pipeline.start() + try: + pipeline.join() + except KeyboardInterrupt: + pipeline.terminate() + + +if __name__ == "__main__": + from jsonargparse import auto_cli, set_parsing_settings + + set_parsing_settings(parse_optionals_as_positionals=True) + auto_cli(main, as_positional=False) diff --git a/examples/time_in_zone/ultralytics_stream_example.py b/examples/time_in_zone/ultralytics_stream_example.py new file mode 100644 index 0000000000..ca44fcfc37 --- /dev/null +++ b/examples/time_in_zone/ultralytics_stream_example.py @@ -0,0 +1,128 @@ +import cv2 +import numpy as np +from inference import InferencePipeline +from inference.core.interfaces.camera.entities import VideoFrame +from ultralytics import YOLO +from utils.general import find_in_list, load_zones_config +from utils.timers import ClockBasedTimer + +import supervision as sv + +COLORS = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"]) +COLOR_ANNOTATOR = sv.ColorAnnotator(color=COLORS) +LABEL_ANNOTATOR = sv.LabelAnnotator( + color=COLORS, text_color=sv.Color.from_hex("#000000") +) + + +class CustomSink: + def __init__(self, zone_configuration_path: str, classes: list[int]) -> None: + self.classes = classes + self.tracker = sv.ByteTrack(minimum_matching_threshold=0.8) + self.fps_monitor = sv.FPSMonitor() + self.polygons = load_zones_config(file_path=zone_configuration_path) + self.timers = [ClockBasedTimer() for _ in self.polygons] + self.zones = [ + sv.PolygonZone( + polygon=polygon, + triggering_anchors=(sv.Position.CENTER,), + ) + for polygon in self.polygons + ] + + def on_prediction(self, detections: sv.Detections, frame: VideoFrame) -> None: + self.fps_monitor.tick() + fps = self.fps_monitor.fps + + detections = detections[find_in_list(detections.class_id, self.classes)] + detections = self.tracker.update_with_detections(detections) + + annotated_frame = frame.image.copy() + annotated_frame = sv.draw_text( + scene=annotated_frame, + text=f"{fps:.1f}", + text_anchor=sv.Point(40, 30), + background_color=sv.Color.from_hex("#A351FB"), + text_color=sv.Color.from_hex("#000000"), + ) + + for idx, zone in enumerate(self.zones): + annotated_frame = sv.draw_polygon( + scene=annotated_frame, polygon=zone.polygon, color=COLORS.by_idx(idx) + ) + + detections_in_zone = detections[zone.trigger(detections)] + time_in_zone = self.timers[idx].tick(detections_in_zone) + custom_color_lookup = np.full(detections_in_zone.class_id.shape, idx) + + annotated_frame = COLOR_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + custom_color_lookup=custom_color_lookup, + ) + labels = [ + f"#{tracker_id} {int(time // 60):02d}:{int(time % 60):02d}" + for tracker_id, time in zip(detections_in_zone.tracker_id, time_in_zone) + ] + annotated_frame = LABEL_ANNOTATOR.annotate( + scene=annotated_frame, + detections=detections_in_zone, + labels=labels, + custom_color_lookup=custom_color_lookup, + ) + cv2.imshow("Processed Video", annotated_frame) + cv2.waitKey(1) + + +def main( + zone_configuration_path: str, + rtsp_url: str, + weights: str = "yolov8s.pt", + device: str = "cpu", + confidence_threshold: float = 0.3, + iou_threshold: float = 0.7, + classes: list[int] = [], +) -> None: + """ + Calculating detections dwell time in zones, using RTSP stream. + + Args: + zone_configuration_path: Path to the zone configuration JSON file + rtsp_url: Complete RTSP URL for the video stream + weights: Path to the model weights file + device: Computation device ('cpu', 'mps' or 'cuda') + confidence_threshold: Confidence level for detections (0 to 1) + iou_threshold: IOU threshold for non-max suppression + classes: List of class IDs to track. If empty, all classes are tracked + """ + model = YOLO(weights) + + def inference_callback(frames: list[VideoFrame]) -> list[sv.Detections]: + results = model( + frames[0].image, verbose=False, conf=confidence_threshold, device=device + )[0] + return [ + sv.Detections.from_ultralytics(results).with_nms(threshold=iou_threshold) + ] + + sink = CustomSink(zone_configuration_path=zone_configuration_path, classes=classes) + + pipeline = InferencePipeline.init_with_custom_logic( + video_reference=rtsp_url, + on_video_frame=inference_callback, + on_prediction=sink.on_prediction, + ) + + pipeline.start() + + try: + pipeline.join() + except KeyboardInterrupt: + pipeline.terminate() + + +if __name__ == "__main__": + from jsonargparse import auto_cli, set_parsing_settings + + set_parsing_settings(parse_optionals_as_positionals=True) + auto_cli(main, as_positional=False) From 022f722cd16cf7995208fed9f56dcb1c2e87553c Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:34:59 +0200 Subject: [PATCH 19/32] refactor(rename): TkImageWindow -> ImageWindow across examples/docs/exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [resolve group] PR #2320 — item 2 --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- docs/utils/image_window.md | 4 ++-- examples/count_people_in_zone/inference_example.py | 2 +- examples/count_people_in_zone/ultralytics_example.py | 2 +- examples/speed_estimation/inference_example.py | 2 +- examples/speed_estimation/ultralytics_example.py | 2 +- examples/speed_estimation/yolo_nas_example.py | 2 +- examples/time_in_zone/inference_file_example.py | 2 +- examples/time_in_zone/inference_naive_stream_example.py | 2 +- examples/time_in_zone/rfdetr_file_example.py | 2 +- examples/time_in_zone/rfdetr_naive_stream_example.py | 2 +- examples/time_in_zone/scripts/draw_zones.py | 6 +++--- examples/time_in_zone/ultralytics_file_example.py | 2 +- examples/time_in_zone/ultralytics_naive_stream_example.py | 2 +- examples/traffic_analysis/inference_example.py | 2 +- examples/traffic_analysis/ultralytics_example.py | 2 +- src/supervision/__init__.py | 4 ++-- 16 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/utils/image_window.md b/docs/utils/image_window.md index 8623567ad7..577bc394a8 100644 --- a/docs/utils/image_window.md +++ b/docs/utils/image_window.md @@ -6,7 +6,7 @@ status: new # Image Window -:::supervision.utils.image_window.TkImageWindow +:::supervision.utils.image_window.ImageWindow diff --git a/examples/count_people_in_zone/inference_example.py b/examples/count_people_in_zone/inference_example.py index 05352010c6..152a5b4f9e 100644 --- a/examples/count_people_in_zone/inference_example.py +++ b/examples/count_people_in_zone/inference_example.py @@ -178,7 +178,7 @@ def main( ) sink.write_frame(annotated_frame) else: - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in tqdm(frames_generator, total=video_info.total_frames): detections = detect(frame, model, confidence_threshold, iou_threshold) annotated_frame = annotate( diff --git a/examples/count_people_in_zone/ultralytics_example.py b/examples/count_people_in_zone/ultralytics_example.py index 4e1af79812..cda151d715 100644 --- a/examples/count_people_in_zone/ultralytics_example.py +++ b/examples/count_people_in_zone/ultralytics_example.py @@ -166,7 +166,7 @@ def main( ) sink.write_frame(annotated_frame) else: - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in tqdm(frames_generator, total=video_info.total_frames): detections = detect(frame, model, confidence_threshold, iou_threshold) annotated_frame = annotate( diff --git a/examples/speed_estimation/inference_example.py b/examples/speed_estimation/inference_example.py index f21dad3d80..e6d788e29b 100644 --- a/examples/speed_estimation/inference_example.py +++ b/examples/speed_estimation/inference_example.py @@ -96,7 +96,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: - window = sv.TkImageWindow("frame") + window = sv.ImageWindow("frame") for frame in frame_generator: results = model.infer( frame, confidence=confidence_threshold, iou=iou_threshold diff --git a/examples/speed_estimation/ultralytics_example.py b/examples/speed_estimation/ultralytics_example.py index d716c46b37..1051b095c2 100644 --- a/examples/speed_estimation/ultralytics_example.py +++ b/examples/speed_estimation/ultralytics_example.py @@ -82,7 +82,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: - window = sv.TkImageWindow("frame") + window = sv.ImageWindow("frame") for frame in frame_generator: result = model(frame, conf=confidence_threshold, iou=iou_threshold)[0] detections = sv.Detections.from_ultralytics(result) diff --git a/examples/speed_estimation/yolo_nas_example.py b/examples/speed_estimation/yolo_nas_example.py index 50ffd5a9ff..caaa7a1c50 100644 --- a/examples/speed_estimation/yolo_nas_example.py +++ b/examples/speed_estimation/yolo_nas_example.py @@ -83,7 +83,7 @@ def main( coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps))) with sv.VideoSink(target_video_path, video_info) as sink: - window = sv.TkImageWindow("frame") + window = sv.ImageWindow("frame") for frame in frame_generator: result = model.predict(frame, conf=confidence_threshold, iou=iou_threshold)[ 0 diff --git a/examples/time_in_zone/inference_file_example.py b/examples/time_in_zone/inference_file_example.py index 275df59d21..8f6167ace4 100644 --- a/examples/time_in_zone/inference_file_example.py +++ b/examples/time_in_zone/inference_file_example.py @@ -48,7 +48,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: results = model.infer( frame, confidence=confidence_threshold, iou_threshold=iou_threshold diff --git a/examples/time_in_zone/inference_naive_stream_example.py b/examples/time_in_zone/inference_naive_stream_example.py index b186bbf724..1cfeb1aabf 100644 --- a/examples/time_in_zone/inference_naive_stream_example.py +++ b/examples/time_in_zone/inference_naive_stream_example.py @@ -48,7 +48,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps diff --git a/examples/time_in_zone/rfdetr_file_example.py b/examples/time_in_zone/rfdetr_file_example.py index 36323d2511..a0783297f2 100644 --- a/examples/time_in_zone/rfdetr_file_example.py +++ b/examples/time_in_zone/rfdetr_file_example.py @@ -127,7 +127,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: detections = model.predict(frame, threshold=confidence_threshold) detections = detections[find_in_list(detections.class_id, classes)] diff --git a/examples/time_in_zone/rfdetr_naive_stream_example.py b/examples/time_in_zone/rfdetr_naive_stream_example.py index a99146678f..2b6321fb87 100644 --- a/examples/time_in_zone/rfdetr_naive_stream_example.py +++ b/examples/time_in_zone/rfdetr_naive_stream_example.py @@ -127,7 +127,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py index 3e68d0df23..918bfb1868 100644 --- a/examples/time_in_zone/scripts/draw_zones.py +++ b/examples/time_in_zone/scripts/draw_zones.py @@ -42,7 +42,7 @@ def mouse_event(x: int, y: int, event_type: str) -> None: def redraw( - image: np.ndarray, original_image: np.ndarray, window: sv.TkImageWindow + image: np.ndarray, original_image: np.ndarray, window: sv.ImageWindow ) -> None: global POLYGONS, current_mouse_position image[:] = original_image.copy() @@ -82,7 +82,7 @@ def redraw( def close_and_finalize_polygon( - image: np.ndarray, original_image: np.ndarray, window: sv.TkImageWindow + image: np.ndarray, original_image: np.ndarray, window: sv.ImageWindow ) -> None: if len(POLYGONS[-1]) > 2: cv2.line( @@ -142,7 +142,7 @@ def main(source_path: str, zone_configuration_path: str) -> None: return image = original_image.copy() - window = sv.TkImageWindow(WINDOW_NAME) + window = sv.ImageWindow(WINDOW_NAME) window.set_mouse_callback(mouse_event) window.show(image) diff --git a/examples/time_in_zone/ultralytics_file_example.py b/examples/time_in_zone/ultralytics_file_example.py index dcb8e87b71..f581179b36 100644 --- a/examples/time_in_zone/ultralytics_file_example.py +++ b/examples/time_in_zone/ultralytics_file_example.py @@ -48,7 +48,7 @@ def main( ] timers = [FPSBasedTimer(video_info.fps) for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: results = model( frame, diff --git a/examples/time_in_zone/ultralytics_naive_stream_example.py b/examples/time_in_zone/ultralytics_naive_stream_example.py index cffd4d431f..3bf04b05bc 100644 --- a/examples/time_in_zone/ultralytics_naive_stream_example.py +++ b/examples/time_in_zone/ultralytics_naive_stream_example.py @@ -48,7 +48,7 @@ def main( ] timers = [ClockBasedTimer() for _ in zones] - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in frames_generator: fps_monitor.tick() fps = fps_monitor.fps diff --git a/examples/traffic_analysis/inference_example.py b/examples/traffic_analysis/inference_example.py index 74b883f8c4..ae5ab561e9 100644 --- a/examples/traffic_analysis/inference_example.py +++ b/examples/traffic_analysis/inference_example.py @@ -111,7 +111,7 @@ def process_video(self) -> None: annotated_frame = self.process_frame(frame) sink.write_frame(annotated_frame) else: - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) window.show(annotated_frame) diff --git a/examples/traffic_analysis/ultralytics_example.py b/examples/traffic_analysis/ultralytics_example.py index a68c955d4f..059e06bd3c 100644 --- a/examples/traffic_analysis/ultralytics_example.py +++ b/examples/traffic_analysis/ultralytics_example.py @@ -108,7 +108,7 @@ def process_video(self) -> None: annotated_frame = self.process_frame(frame) sink.write_frame(annotated_frame) else: - window = sv.TkImageWindow("Processed Video") + window = sv.ImageWindow("Processed Video") for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) window.show(annotated_frame) diff --git a/src/supervision/__init__.py b/src/supervision/__init__.py index 5788151b21..fbe75c0d31 100644 --- a/src/supervision/__init__.py +++ b/src/supervision/__init__.py @@ -150,7 +150,7 @@ scale_image, tint_image, ) -from supervision.utils.image_window import TkImageWindow +from supervision.utils.image_window import ImageWindow from supervision.utils.notebook import plot_image, plot_images_grid from supervision.utils.video import ( FPSMonitor, @@ -195,6 +195,7 @@ "HeatMapAnnotator", "IconAnnotator", "ImageSink", + "ImageWindow", "InferenceSlicer", "JSONSink", "KeyPoints", @@ -217,7 +218,6 @@ "Rect", "RichLabelAnnotator", "RoundBoxAnnotator", - "TkImageWindow", "TraceAnnotator", "TriangleAnnotator", "VertexAnnotator", From 97fd91bcee7f91d692aeead06dfbb7179007288a Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:38:02 +0200 Subject: [PATCH 20/32] fix(image_window): correct mouse coords after resize; reuse cv2_to_pillow; drop banner comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: _on_mouse forwarded raw label-widget event.x/event.y unscaled while _fit_image scales+letterboxes the displayed frame, so mouse coordinates drifted from image-pixel space after any window resize (user-confirmed). Fix stores display scale/offset at render time and inverts the transform in _on_mouse, clamped to the original image bounds. Also extends sv.cv2_to_pillow to support grayscale and BGRA inputs (it previously only handled 3-channel BGR) and makes _bgr_to_pil delegate to it instead of duplicating the conversion logic. Drops four decorative section-separator comment blocks per reviewer request. [resolve group] PR #2320 — items 1, 2, 5, 8 --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- src/supervision/utils/conversion.py | 22 +++-- src/supervision/utils/image_window.py | 90 +++++++++++++------- tests/utils/test_conversion.py | 57 +++++++++++++ tests/utils/test_image_window.py | 114 ++++++++++++++++---------- 4 files changed, 206 insertions(+), 77 deletions(-) diff --git a/src/supervision/utils/conversion.py b/src/supervision/utils/conversion.py index b16d92e1e5..c20db0c644 100644 --- a/src/supervision/utils/conversion.py +++ b/src/supervision/utils/conversion.py @@ -196,15 +196,21 @@ def pillow_to_cv2(image: Image.Image) -> npt.NDArray[np.uint8]: def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image: """ - Converts OpenCV image into Pillow image, handling BGR -> RGB - conversion. + Converts an OpenCV image into a Pillow image, reordering channels from + OpenCV's BGR(A) convention to Pillow's RGB(A). Args: - image: OpenCV image (in BGR format). + image: OpenCV image. Accepted shapes: + - `(H, W)` — grayscale, passed through unchanged. + - `(H, W, 3)` — BGR, converted to RGB. + - `(H, W, 4)` — BGRA, converted to RGBA. Returns: Input image converted to Pillow format. + Raises: + ValueError: If `image` is not 2-D or 3-D with 3 or 4 channels. + Examples: ```pycon >>> import numpy as np @@ -219,5 +225,11 @@ def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image: ``` """ - rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - return Image.fromarray(rgb_image) + if image.ndim == 2: + return Image.fromarray(np.ascontiguousarray(image)) + if image.ndim == 3 and image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb_image) + if image.ndim == 3 and image.shape[2] == 4: + return Image.fromarray(np.ascontiguousarray(image[..., [2, 1, 0, 3]])) + raise ValueError(f"Expected shape (H,W), (H,W,3), or (H,W,4), got {image.shape}.") diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 38fbd82d77..99076de627 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -8,10 +8,12 @@ import numpy.typing as npt from PIL import Image +from supervision.utils.conversion import cv2_to_pillow + MouseCallback = Callable[[int, int, str], None] -class TkImageWindow: +class ImageWindow: """Desktop image window backed by stdlib tkinter + pillow. Functional replacement for `cv2.imshow` / `cv2.waitKey` that works under @@ -45,7 +47,7 @@ class TkImageWindow: ```python import supervision as sv - window = sv.TkImageWindow("preview") + window = sv.ImageWindow("preview") for frame in sv.get_video_frames_generator(source_path="video.mp4"): annotated = ... # annotate frame window.show(annotated) @@ -59,7 +61,7 @@ class TkImageWindow: ```python import supervision as sv - with sv.TkImageWindow("preview") as window: + with sv.ImageWindow("preview") as window: for frame in sv.get_video_frames_generator(source_path="video.mp4"): window.show(frame) if window.wait_key(delay_ms=1) == "q": @@ -81,10 +83,10 @@ def __init__( self._pil_image: Image.Image | None = None self._win_w: int = 0 self._win_h: int = 0 - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ + self._display_scale_x: float = 1.0 + self._display_scale_y: float = 1.0 + self._display_offset_x: float = 0.0 + self._display_offset_y: float = 0.0 def show(self, image: npt.NDArray[np.uint8]) -> None: """Display a BGR, grayscale, or BGRA frame in the window. @@ -161,20 +163,12 @@ def close(self) -> None: root.destroy() self._reset_window_refs() - # ------------------------------------------------------------------ - # Context manager - # ------------------------------------------------------------------ - - def __enter__(self) -> TkImageWindow: + def __enter__(self) -> ImageWindow: return self def __exit__(self, *_: object) -> None: self.close() - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - def _ensure_window(self) -> None: if self._window_exists(): return @@ -210,16 +204,57 @@ def _update_display(self) -> None: img = self._pil_image if self._win_w > 1 and self._win_h > 1: img = _fit_image(img, self._win_w, self._win_h, self.keep_aspect_ratio) + self._update_display_transform(img) self._photo = ImageTk.PhotoImage(img) self._label.configure(image=self._photo) + def _update_display_transform(self, fitted: Image.Image) -> None: + """Record the scale and letterbox offset from the last display pass. + + The fitted image is drawn centered inside the label, so mouse events on + the label carry display-space coordinates. Storing the per-axis scale + and centering offset lets `_on_mouse` invert the transform back to + original image-pixel coordinates. Falls back to an identity transform + while the window geometry is still unknown. + """ + if self._pil_image is None: + return + orig_w, orig_h = self._pil_image.size + fit_w, fit_h = fitted.size + win_w = self._win_w if self._win_w > 1 else fit_w + win_h = self._win_h if self._win_h > 1 else fit_h + self._display_scale_x = fit_w / orig_w if orig_w else 1.0 + self._display_scale_y = fit_h / orig_h if orig_h else 1.0 + self._display_offset_x = max(0.0, (win_w - fit_w) / 2) + self._display_offset_y = max(0.0, (win_h - fit_h) / 2) + def _on_key(self, event: Any) -> None: self._key_queue.append(event.keysym) self._signal_wait() def _on_mouse(self, event: Any, event_type: str) -> None: - if self._mouse_callback is not None: - self._mouse_callback(event.x, event.y, event_type) + if self._mouse_callback is None: + return + x, y = self._to_image_coords(event.x, event.y) + self._mouse_callback(x, y, event_type) + + def _to_image_coords(self, event_x: int, event_y: int) -> tuple[int, int]: + """Map label-space event coordinates to original image pixels. + + Inverts the scale and letterbox offset recorded by `_update_display` + so callbacks receive coordinates in the source image's pixel space, + regardless of how the window has been resized. Results are rounded to + the nearest pixel and clamped inside the image bounds. When no image + has been shown, the raw event coordinates are returned unchanged. + """ + if self._pil_image is None: + return event_x, event_y + orig_w, orig_h = self._pil_image.size + image_x = round((event_x - self._display_offset_x) / self._display_scale_x) + image_y = round((event_y - self._display_offset_y) / self._display_scale_y) + clamped_x = min(max(image_x, 0), orig_w - 1) + clamped_y = min(max(image_y, 0), orig_h - 1) + return clamped_x, clamped_y def _signal_wait(self) -> None: if self._key_event is None: @@ -253,11 +288,6 @@ def _reset_window_refs(self) -> None: self._key_queue.clear() -# ------------------------------------------------------------------ -# Module-level helper -# ------------------------------------------------------------------ - - def _fit_image( image: Image.Image, width: int, height: int, keep_aspect_ratio: bool = True ) -> Image.Image: @@ -273,10 +303,10 @@ def _fit_image( def _bgr_to_pil(image: npt.NDArray[np.uint8]) -> Image.Image: - if image.ndim == 2: - return Image.fromarray(np.ascontiguousarray(image)) - if image.ndim == 3 and image.shape[2] == 3: - return Image.fromarray(np.ascontiguousarray(image[..., ::-1])) - if image.ndim == 3 and image.shape[2] == 4: - return Image.fromarray(np.ascontiguousarray(image[..., [2, 1, 0, 3]])) - raise ValueError(f"Expected shape (H,W), (H,W,3), or (H,W,4), got {image.shape}.") + """Convert a BGR, grayscale, or BGRA OpenCV array to a Pillow image. + + Thin wrapper delegating to `sv.cv2_to_pillow`, which reorders channels + from OpenCV's BGR(A) convention to Pillow's RGB(A) and passes grayscale + arrays through unchanged. + """ + return cv2_to_pillow(image) diff --git a/tests/utils/test_conversion.py b/tests/utils/test_conversion.py index f0588cc7cb..44510ec303 100644 --- a/tests/utils/test_conversion.py +++ b/tests/utils/test_conversion.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from PIL import Image, ImageChops from supervision.utils.conversion import ( @@ -96,6 +97,62 @@ def test_cv2_to_pillow( ) +def test_cv2_to_pillow_bgr_reorders_channels_to_rgb() -> None: + """A BGR array is converted to an RGB-mode image with swapped channels.""" + # given + image = np.zeros((2, 2, 3), dtype=np.uint8) + image[:, :, 0] = 10 # B + image[:, :, 1] = 20 # G + image[:, :, 2] = 30 # R + + # when + result = cv2_to_pillow(image) + + # then + assert result.mode == "RGB" + assert result.getpixel((0, 0)) == (30, 20, 10) + + +def test_cv2_to_pillow_grayscale_passes_through() -> None: + """A 2-D grayscale array becomes an L-mode image of the same size.""" + # given + image = np.zeros((4, 5), dtype=np.uint8) + + # when + result = cv2_to_pillow(image) + + # then + assert result.mode == "L" + assert result.size == (5, 4) + + +def test_cv2_to_pillow_bgra_reorders_channels_to_rgba() -> None: + """A BGRA array is converted to an RGBA-mode image with swapped channels.""" + # given + image = np.zeros((2, 2, 4), dtype=np.uint8) + image[:, :, 0] = 10 # B + image[:, :, 1] = 20 # G + image[:, :, 2] = 30 # R + image[:, :, 3] = 255 # A + + # when + result = cv2_to_pillow(image) + + # then + assert result.mode == "RGBA" + assert result.getpixel((0, 0)) == (30, 20, 10, 255) + + +def test_cv2_to_pillow_invalid_shape_raises() -> None: + """An unsupported channel count raises ValueError.""" + # given + image = np.zeros((2, 2, 2), dtype=np.uint8) + + # when / then + with pytest.raises(ValueError, match="Expected shape"): + cv2_to_pillow(image) + + def test_pillow_to_cv2( empty_cv2_image: np.ndarray, empty_pillow_image: Image.Image ) -> None: diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 6d439402a8..f9c0c6e315 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -1,4 +1,4 @@ -"""Tests for TkImageWindow and _bgr_to_pil helper.""" +"""Tests for ImageWindow and _bgr_to_pil helper.""" from unittest.mock import MagicMock, patch @@ -6,7 +6,7 @@ import pytest from PIL import Image -from supervision.utils.image_window import TkImageWindow, _bgr_to_pil, _fit_image +from supervision.utils.image_window import ImageWindow, _bgr_to_pil, _fit_image class TestBgrToPil: @@ -99,7 +99,7 @@ def test_free_form_same_size_returns_original(self): assert result is img -class TestTkImageWindowShow: +class TestImageWindowShow: @pytest.mark.parametrize( ("array", "exc_type", "match"), [ @@ -119,13 +119,13 @@ class TestTkImageWindowShow: ) def test_show_raises_for_invalid_input(self, array, exc_type, match): """show() rejects arrays with invalid dtype or shape.""" - window = TkImageWindow("test") + window = ImageWindow("test") with pytest.raises(exc_type, match=match): window.show(array) def test_show_creates_window_and_updates(self): """show() creates a Tk window, sets the PhotoImage, and calls update.""" - window = TkImageWindow("preview") + window = ImageWindow("preview") mock_root = MagicMock() mock_label = MagicMock() mock_photo = MagicMock() @@ -133,7 +133,7 @@ def test_show_creates_window_and_updates(self): fake_imagetk.PhotoImage.return_value = mock_photo with ( - patch("supervision.utils.image_window.TkImageWindow._ensure_window"), + patch("supervision.utils.image_window.ImageWindow._ensure_window"), patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}), ): window._root = mock_root @@ -148,23 +148,23 @@ def test_show_creates_window_and_updates(self): mock_root.update.assert_called_once() -class TestTkImageWindowUpdateDisplay: +class TestImageWindowUpdateDisplay: def test_no_op_without_pil_image(self): """_update_display() is a no-op when no image has been shown yet.""" - window = TkImageWindow() + window = ImageWindow() window._label = MagicMock() window._update_display() window._label.configure.assert_not_called() def test_no_op_without_label(self): """_update_display() does not raise when the window has no label.""" - window = TkImageWindow() + window = ImageWindow() window._pil_image = Image.new("RGB", (10, 10)) window._update_display() # must not raise def test_uses_native_size_when_window_size_unknown(self): """Native resolution is used when _win_w/_win_h are still 0.""" - window = TkImageWindow() + window = ImageWindow() window._pil_image = Image.new("RGB", (80, 60)) mock_label = MagicMock() window._label = mock_label @@ -178,7 +178,7 @@ def test_uses_native_size_when_window_size_unknown(self): def test_scales_image_to_window_size(self): """Image is rescaled to fit _win_w x _win_h when dimensions are known.""" - window = TkImageWindow() + window = ImageWindow() window._pil_image = Image.new("RGB", (400, 200)) window._win_w = 200 window._win_h = 200 @@ -194,7 +194,7 @@ def test_scales_image_to_window_size(self): def test_stretches_image_when_keep_aspect_ratio_false(self): """Image is stretched to fill window when keep_aspect_ratio=False.""" - window = TkImageWindow(keep_aspect_ratio=False) + window = ImageWindow(keep_aspect_ratio=False) window._pil_image = Image.new("RGB", (400, 200)) window._win_w = 200 window._win_h = 200 @@ -208,10 +208,10 @@ def test_stretches_image_when_keep_aspect_ratio_false(self): assert displayed.size == (200, 200) -class TestTkImageWindowOnConfigure: +class TestImageWindowOnConfigure: def test_non_root_widget_is_ignored(self): """ events from child widgets do not update dimensions.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root event = MagicMock() @@ -228,7 +228,7 @@ def test_non_root_widget_is_ignored(self): def test_same_size_event_is_ignored(self): """ with unchanged dimensions does not trigger a redraw.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root window._win_w = 200 @@ -245,7 +245,7 @@ def test_same_size_event_is_ignored(self): def test_degenerate_size_is_ignored(self): """ with width or height <= 1 (pre-geometry) is skipped.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root event = MagicMock() @@ -261,7 +261,7 @@ def test_degenerate_size_is_ignored(self): def test_new_size_updates_dimensions_and_redraws(self): """ with new dimensions updates _win_w/_win_h and redraws.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root window._pil_image = Image.new("RGB", (100, 100)) @@ -279,7 +279,7 @@ def test_new_size_updates_dimensions_and_redraws(self): def test_new_size_without_image_skips_redraw(self): """ with new dimensions but no image updates dims only.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root event = MagicMock() @@ -295,16 +295,16 @@ def test_new_size_without_image_skips_redraw(self): mock_update.assert_not_called() -class TestTkImageWindowWaitKey: +class TestImageWindowWaitKey: def test_wait_key_returns_none_when_no_window(self): """wait_key() returns None immediately when no window exists.""" - window = TkImageWindow() + window = ImageWindow() assert window.wait_key(delay_ms=0) is None assert window.wait_key(delay_ms=1) is None def test_wait_key_returns_none_when_closed_mid_wait(self): """wait_key(0) returns None when close() fires during blocking wait.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() mock_event = MagicMock() mock_root.wait_variable.side_effect = lambda _: window.close() @@ -318,7 +318,7 @@ def test_wait_key_returns_none_when_closed_mid_wait(self): def test_wait_key_returns_queued_key_immediately(self): """wait_key() returns the first queued keysym without calling update.""" - window = TkImageWindow() + window = ImageWindow() window._root = MagicMock() window._key_queue = ["q", "Escape"] result = window.wait_key(delay_ms=1) @@ -327,7 +327,7 @@ def test_wait_key_returns_queued_key_immediately(self): def test_wait_key_blocks_with_tk_event_loop(self): """Blocking wait_key() uses Tk wait_variable instead of update polling.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() mock_event = MagicMock() mock_root.wait_variable.side_effect = lambda _: window._key_queue.append("q") @@ -342,7 +342,7 @@ def test_wait_key_blocks_with_tk_event_loop(self): def test_wait_key_returns_none_on_timeout(self): """wait_key() returns None when no key arrives before the deadline.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() mock_event = MagicMock() mock_root.after.return_value = "timeout-id" @@ -358,10 +358,10 @@ def test_wait_key_returns_none_on_timeout(self): mock_root.update.assert_not_called() -class TestTkImageWindowClose: +class TestImageWindowClose: def test_close_destroys_root(self): """close() calls destroy() on the Tk root and nulls internal refs.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() window._root = mock_root window._label = MagicMock() @@ -376,7 +376,7 @@ def test_close_destroys_root(self): def test_close_signals_waiters_and_clears_key_event(self): """close() wakes wait_key() callers and clears stale Tk references.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() mock_event = MagicMock() mock_event.get.return_value = 0 @@ -392,13 +392,13 @@ def test_close_signals_waiters_and_clears_key_event(self): def test_close_is_idempotent(self): """close() on an already-closed window does not raise.""" - window = TkImageWindow() + window = ImageWindow() window.close() # no window created window.close() # second call must not raise def test_close_clears_key_queue(self): """close() discards stale keys so they don't fire on next open.""" - window = TkImageWindow() + window = ImageWindow() window._root = MagicMock() window._key_queue = ["q", "Escape"] window.close() @@ -406,7 +406,7 @@ def test_close_clears_key_queue(self): def test_window_exists_returns_false_for_destroyed_root(self): """Destroyed Tk roots are not reused after a window-manager close.""" - window = TkImageWindow() + window = ImageWindow() mock_root = MagicMock() mock_root.winfo_exists.return_value = 0 window._root = mock_root @@ -414,41 +414,41 @@ def test_window_exists_returns_false_for_destroyed_root(self): assert window._window_exists() is False -class TestTkImageWindowContextManager: +class TestImageWindowContextManager: def test_context_manager_closes_on_exit(self): """The with-statement calls close() when the block exits normally.""" - with patch.object(TkImageWindow, "close") as mock_close: - with TkImageWindow("ctx") as w: - assert isinstance(w, TkImageWindow) + with patch.object(ImageWindow, "close") as mock_close: + with ImageWindow("ctx") as w: + assert isinstance(w, ImageWindow) mock_close.assert_called_once() def test_context_manager_closes_on_exception(self): """close() is called even when the body raises.""" - with patch.object(TkImageWindow, "close") as mock_close: + with patch.object(ImageWindow, "close") as mock_close: with pytest.raises(RuntimeError): - with TkImageWindow("ctx"): + with ImageWindow("ctx"): raise RuntimeError("boom") mock_close.assert_called_once() -class TestTkImageWindowMouseCallback: +class TestImageWindowMouseCallback: def test_set_mouse_callback_stores_callable(self): """set_mouse_callback() stores the callable for later use.""" - window = TkImageWindow() + window = ImageWindow() cb = MagicMock() window.set_mouse_callback(cb) assert window._mouse_callback is cb def test_set_mouse_callback_none_removes_callback(self): """Passing None removes a previously set callback.""" - window = TkImageWindow() + window = ImageWindow() window.set_mouse_callback(MagicMock()) window.set_mouse_callback(None) assert window._mouse_callback is None - def test_on_mouse_calls_callback_with_coords(self): - """_on_mouse() forwards (x, y, event_type) to the registered callback.""" - window = TkImageWindow() + def test_on_mouse_forwards_raw_coords_without_image(self): + """_on_mouse() forwards raw coordinates when no image has been shown.""" + window = ImageWindow() cb = MagicMock() window._mouse_callback = cb event = MagicMock() @@ -456,3 +456,33 @@ def test_on_mouse_calls_callback_with_coords(self): event.y = 10 window._on_mouse(event, "down") cb.assert_called_once_with(5, 10, "down") + + def test_on_mouse_maps_scaled_coords_to_image_pixels(self): + """Event coordinates are unscaled back to original image pixels.""" + window = ImageWindow() + window._pil_image = Image.new("RGB", (400, 200)) + window._win_w = 200 + window._win_h = 200 + window._update_display_transform(Image.new("RGB", (200, 100))) + cb = MagicMock() + window._mouse_callback = cb + event = MagicMock() + event.x = 100 # display space -> 200 in original + event.y = 25 # letterboxed by (200 - 100) / 2 = 50 -> 0 in original + window._on_mouse(event, "move") + cb.assert_called_once_with(200, 0, "move") + + def test_on_mouse_clamps_coords_into_image_bounds(self): + """Coordinates outside the displayed image are clamped to its bounds.""" + window = ImageWindow() + window._pil_image = Image.new("RGB", (400, 200)) + window._win_w = 200 + window._win_h = 200 + window._update_display_transform(Image.new("RGB", (200, 100))) + cb = MagicMock() + window._mouse_callback = cb + event = MagicMock() + event.x = 500 # far past the right edge + event.y = 0 # inside the top letterbox band + window._on_mouse(event, "down") + cb.assert_called_once_with(399, 0, "down") From d196a21ba62fc216809e9f5af35856894cc572fd Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:38:10 +0200 Subject: [PATCH 21/32] docs(changelog): rename mention, consolidate duplicate text, use warning admonition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the TkImageWindow -> ImageWindow rename mention. Deduplicates the repeated pip uninstall/install snippet under "How to restore GUI support" and "Co-installation warning" into a single cross-referenced block, and reframes the opencv-python-headless breaking-change note as a mkdocs !!! warning admonition instead of a regular bullet so it is not mistaken for a routine change. [resolve group] PR #2320 — items 2, 6, 7 --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- docs/changelog.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index df22c213e2..5095b98640 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -65,28 +65,26 @@ date_modified: 2026-07-08 - Added [#2027](https://github.com/roboflow/supervision/issues/2027): [`sv.InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now accepts an open rasterio-style dataset in addition to in-memory images. Each tile is read lazily via a windowed read instead of loading the whole image, enabling tiled inference on multi-GB aerial/drone GeoTIFFs without running out of memory. Detection is duck-typed, so `rasterio` stays an optional dependency installable via `pip install "supervision[geotiff]"` and the core library imports no rasterio symbols. A geographic (non-projected) CRS raises `ValueError`. -- Added: [`sv.TkImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: +- Added: [`sv.ImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install python-tk` on macOS with Homebrew/pyenv. -- Changed: `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. +!!! warning "Breaking change" + `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. - **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. + **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. - **How to restore GUI support:** replace the headless wheel with the full one: - ```bash - pip uninstall -y opencv-python-headless - pip install opencv-python - ``` - Both wheel families share the `cv2` namespace; keep only one installed at a time. + **How to restore GUI support:** replace the headless wheel with the full one: + ```bash + pip uninstall -y opencv-python-headless + pip install opencv-python + ``` + Both wheel families share the `cv2` namespace; keep only one installed at a time. + + **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. Run the same commands shown above to remove the headless wheel afterward. - **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. The safe fix is to explicitly remove the headless wheel afterward: - ```bash - pip uninstall -y opencv-python-headless - pip install opencv-python - ``` - Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`. - Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned. From 17ce271a3977373f96457884ba0d71edce806f29 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:57:23 +0200 Subject: [PATCH 22/32] lint: auto-fix violations after resolve cycle --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- examples/count_people_in_zone/inference_example.py | 2 +- examples/count_people_in_zone/ultralytics_example.py | 2 +- examples/speed_estimation/inference_example.py | 2 +- examples/speed_estimation/ultralytics_example.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/count_people_in_zone/inference_example.py b/examples/count_people_in_zone/inference_example.py index 152a5b4f9e..48f4f4bff4 100644 --- a/examples/count_people_in_zone/inference_example.py +++ b/examples/count_people_in_zone/inference_example.py @@ -116,7 +116,7 @@ def annotate( """ annotated_frame = frame.copy() for zone, zone_annotator, box_annotator in zip( - zones, zone_annotators, box_annotators + zones, zone_annotators, box_annotators, strict=True ): detections_in_zone = detections[zone.trigger(detections=detections)] annotated_frame = zone_annotator.annotate(scene=annotated_frame) diff --git a/examples/count_people_in_zone/ultralytics_example.py b/examples/count_people_in_zone/ultralytics_example.py index cda151d715..9c967b2149 100644 --- a/examples/count_people_in_zone/ultralytics_example.py +++ b/examples/count_people_in_zone/ultralytics_example.py @@ -115,7 +115,7 @@ def annotate( """ annotated_frame = frame.copy() for zone, zone_annotator, box_annotator in zip( - zones, zone_annotators, box_annotators + zones, zone_annotators, box_annotators, strict=True ): detections_in_zone = detections[zone.trigger(detections=detections)] annotated_frame = zone_annotator.annotate(scene=annotated_frame) diff --git a/examples/speed_estimation/inference_example.py b/examples/speed_estimation/inference_example.py index e6d788e29b..f962b44260 100644 --- a/examples/speed_estimation/inference_example.py +++ b/examples/speed_estimation/inference_example.py @@ -110,7 +110,7 @@ def main( ) points = view_transformer.transform_points(points=points).astype(int) - for tracker_id, [_, y] in zip(detections.tracker_id, points): + for tracker_id, [_, y] in zip(detections.tracker_id, points, strict=True): coordinates[tracker_id].append(y) labels = [] diff --git a/examples/speed_estimation/ultralytics_example.py b/examples/speed_estimation/ultralytics_example.py index 1051b095c2..a90eddae0d 100644 --- a/examples/speed_estimation/ultralytics_example.py +++ b/examples/speed_estimation/ultralytics_example.py @@ -94,7 +94,7 @@ def main( ) points = view_transformer.transform_points(points=points).astype(int) - for tracker_id, [_, y] in zip(detections.tracker_id, points): + for tracker_id, [_, y] in zip(detections.tracker_id, points, strict=True): coordinates[tracker_id].append(y) labels = [] From 527779cbe229f25cdbadf2e102994074428b685c Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:03:54 +0200 Subject: [PATCH 23/32] fix(changelog): move ImageWindow entry out of already-published 0.29.1 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.29.1 tag is already released; filing this PR's changelog entries there falsely implied the feature shipped in a past release. Moved the ImageWindow "Added" bullet and the opencv-python-headless breaking-change warning into the Unreleased section instead. Also adds test coverage flagged by the QA gate: an end-to-end resize-then-click path (_on_configure -> _on_mouse) and mouse-coordinate mapping under keep_aspect_ratio=False (stretched mode), both previously untested. [resolve] PR #2320 — Step 9 QA gate fixes --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- docs/changelog.md | 39 ++++++++++++++++---------------- tests/utils/test_image_window.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5095b98640..52316c8912 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,20 @@ date_modified: 2026-07-08 - `sv.JSONSink` now emits native JSON types for numeric and boolean data fields instead of stringified values. Fields previously serialized as `"True"`/`"False"`, `"1"`/`"0.85"`, or `"400.0"` are now `true`/`false`, `1`/`0.85`, `400.0`. Downstream consumers that compare field values as strings (e.g. `row["score"] == "1"`) or use strict string-typed schema validators must be updated. `sv.CSVSink` remains textual, but its custom-data slicing now matches `sv.JSONSink`: NumPy arrays, lists, and tuples are sliced per row only when their length matches the detection count; mismatched-length values are broadcast unchanged ([#2400](https://github.com/roboflow/supervision/pull/2400)). - `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)). +!!! warning "Breaking change" + `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. + + **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. + + **How to restore GUI support:** replace the headless wheel with the full one: + ```bash + pip uninstall -y opencv-python-headless + pip install opencv-python + ``` + Both wheel families share the `cv2` namespace; keep only one installed at a time. + + **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. Run the same commands shown above to remove the headless wheel afterward. + ### Fixed - `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged. - Legacy COCO prediction loading in `sv.EvaluationDataset.load_predictions` now raises `ValueError` for image ids absent from the ground-truth COCO set instead of relying on a bare `assert`, so the check is no longer silently skipped under `python -O`. @@ -46,6 +60,11 @@ date_modified: 2026-07-08 - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added +- Added: [`sv.ImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: + - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. + - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. + - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. + - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install python-tk` on macOS with Homebrew/pyenv. - `KeyPoints.merge` — combine a list of `KeyPoints` objects into one, mirroring `Detections.merge`. Empty inputs are ignored; all non-empty inputs must share the same number of keypoints per skeleton. Completes the merge-then-suppress workflow introduced by `KeyPoints.with_nms` ([#2412](https://github.com/roboflow/supervision/pull/2412)) - `BaseAnnotator.requires_mask` — class-level `bool` flag on all annotators; `True` for `MaskAnnotator`, `PolygonAnnotator`, and `HaloAnnotator`; `False` for all others. Integrations can inspect this before materializing expensive mask payloads ([#2370](https://github.com/roboflow/supervision/pull/2370)) - `CompactMask.from_coco_rle` — efficient COCO RLE ingestion into crop-scoped compact mask format without materializing dense `(N, H, W)` arrays ([#2367](https://github.com/roboflow/supervision/pull/2367)) @@ -65,26 +84,6 @@ date_modified: 2026-07-08 - Added [#2027](https://github.com/roboflow/supervision/issues/2027): [`sv.InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now accepts an open rasterio-style dataset in addition to in-memory images. Each tile is read lazily via a windowed read instead of loading the whole image, enabling tiled inference on multi-GB aerial/drone GeoTIFFs without running out of memory. Detection is duck-typed, so `rasterio` stays an optional dependency installable via `pip install "supervision[geotiff]"` and the core library imports no rasterio symbols. A geographic (non-projected) CRS raises `ValueError`. -- Added: [`sv.ImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: - - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. - - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. - - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. - - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install python-tk` on macOS with Homebrew/pyenv. - -!!! warning "Breaking change" - `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. - - **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. - - **How to restore GUI support:** replace the headless wheel with the full one: - ```bash - pip uninstall -y opencv-python-headless - pip install opencv-python - ``` - Both wheel families share the `cv2` namespace; keep only one installed at a time. - - **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. Run the same commands shown above to remove the headless wheel afterward. - - Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`. - Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned. diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index f9c0c6e315..6c969ccfce 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -486,3 +486,42 @@ def test_on_mouse_clamps_coords_into_image_bounds(self): event.y = 0 # inside the top letterbox band window._on_mouse(event, "down") cb.assert_called_once_with(399, 0, "down") + + def test_on_mouse_after_configure_resize_maps_correct_coords(self): + """End-to-end: a resize followed by a click maps to image pixels.""" + window = ImageWindow() + window._pil_image = Image.new("RGB", (400, 200)) + window._label = MagicMock() + mock_root = MagicMock() + window._root = mock_root + cb = MagicMock() + window._mouse_callback = cb + configure_event = MagicMock() + configure_event.widget = mock_root + configure_event.width = 200 + configure_event.height = 200 + fake_imagetk = MagicMock() + + with patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}): + window._on_configure(configure_event) # triggers _update_display internally + + mouse_event = MagicMock() + mouse_event.x = 100 # display space -> 200 in original + mouse_event.y = 25 # letterboxed by (200 - 100) / 2 = 50 -> 0 in original + window._on_mouse(mouse_event, "down") + cb.assert_called_once_with(200, 0, "down") + + def test_on_mouse_maps_coords_when_stretched(self): + """Event coords unscale correctly when keep_aspect_ratio=False (stretched).""" + window = ImageWindow(keep_aspect_ratio=False) + window._pil_image = Image.new("RGB", (400, 200)) + window._win_w = 100 + window._win_h = 100 + window._update_display_transform(Image.new("RGB", (100, 100))) + cb = MagicMock() + window._mouse_callback = cb + event = MagicMock() + event.x = 50 # stretched display space -> 200 in original width + event.y = 50 # stretched display space -> 100 in original height + window._on_mouse(event, "up") + cb.assert_called_once_with(200, 100, "up") From c654b2529107704c1b894f3d5f9511e7e32f2d3d Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:50:10 +0200 Subject: [PATCH 24/32] fix(utils): add ImageWindow.is_open and update examples - Add ImageWindow.is_open so display loops can stop cleanly when the Tk window is closed.\n\n- Update examples, docs, and FAQ guidance to use the new window-state check and clarify OpenCV wheel selection.\n\n- Tighten grayscale crop tests and cap opencv-python-headless below 5 for compatibility. --- Co-authored-by: Codex --- docs/changelog.md | 4 ++-- docs/faq.md | 13 +++++++++++++ examples/count_people_in_zone/inference_example.py | 3 ++- .../count_people_in_zone/ultralytics_example.py | 3 ++- examples/speed_estimation/inference_example.py | 3 ++- examples/speed_estimation/ultralytics_example.py | 3 ++- examples/speed_estimation/yolo_nas_example.py | 3 ++- examples/time_in_zone/README.md | 2 ++ examples/time_in_zone/inference_file_example.py | 3 ++- .../time_in_zone/inference_naive_stream_example.py | 3 ++- examples/time_in_zone/rfdetr_file_example.py | 3 ++- .../time_in_zone/rfdetr_naive_stream_example.py | 3 ++- examples/time_in_zone/scripts/draw_zones.py | 2 ++ examples/time_in_zone/ultralytics_file_example.py | 3 ++- .../ultralytics_naive_stream_example.py | 3 ++- examples/traffic_analysis/inference_example.py | 3 ++- examples/traffic_analysis/ultralytics_example.py | 3 ++- pyproject.toml | 2 +- src/supervision/utils/image_window.py | 7 ++++++- tests/utils/test_image.py | 3 +-- tests/utils/test_image_window.py | 13 +++++++++++++ uv.lock | 2 +- 22 files changed, 67 insertions(+), 20 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 52316c8912..6b52c3b77b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -60,11 +60,11 @@ date_modified: 2026-07-08 - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added -- Added: [`sv.ImageWindow`](https://supervision.roboflow.com/latest/utils/image_window/) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: +- Added: [`sv.ImageWindow`](utils/image_window.md/#supervision.utils.image_window.ImageWindow) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. - - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install python-tk` on macOS with Homebrew/pyenv. + - Requires `python3-tk` (not pip-installable): `sudo apt-get install python3-tk` on Debian/Ubuntu, `brew install tcl-tk` on macOS with Homebrew/pyenv. - `KeyPoints.merge` — combine a list of `KeyPoints` objects into one, mirroring `Detections.merge`. Empty inputs are ignored; all non-empty inputs must share the same number of keypoints per skeleton. Completes the merge-then-suppress workflow introduced by `KeyPoints.with_nms` ([#2412](https://github.com/roboflow/supervision/pull/2412)) - `BaseAnnotator.requires_mask` — class-level `bool` flag on all annotators; `True` for `MaskAnnotator`, `PolygonAnnotator`, and `HaloAnnotator`; `False` for all others. Integrations can inspect this before materializing expensive mask payloads ([#2370](https://github.com/roboflow/supervision/pull/2370)) - `CompactMask.from_coco_rle` — efficient COCO RLE ingestion into crop-scoped compact mask format without materializing dense `(N, H, W)` arrays ([#2367](https://github.com/roboflow/supervision/pull/2367)) diff --git a/docs/faq.md b/docs/faq.md index 8f4d8ab211..4b7d946772 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -64,6 +64,19 @@ pip install opencv-python Both wheel families share the `cv2` namespace; keep only one installed at a time. +If another dependency such as `ultralytics` or `inference-sdk` requires `opencv-python`, pip can install both wheel distributions even though they write to the same `cv2` namespace. Choose the wheel that matches your use case, remove the other one, and reinstall it explicitly if needed: + +```bash +pip uninstall -y opencv-python opencv-python-headless +pip install opencv-python # GUI support +# or: pip install opencv-python-headless # headless support +``` + +The full-wheel option is a manual override of supervision's declared headless +dependency. A later dependency update may reinstall the headless wheel, so use +a dedicated environment or verify the installed OpenCV distributions after +updating packages. Never keep both wheel distributions installed together. + ## How do I process frames from a webcam with supervision? Supervision does not support live camera capture. Manage the capture device yourself with `cv2.VideoCapture` (requires `opencv-python`) and pass individual frames to supervision annotators: diff --git a/examples/count_people_in_zone/inference_example.py b/examples/count_people_in_zone/inference_example.py index 48f4f4bff4..2b36cd55a2 100644 --- a/examples/count_people_in_zone/inference_example.py +++ b/examples/count_people_in_zone/inference_example.py @@ -189,7 +189,8 @@ def main( detections=detections, ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/count_people_in_zone/ultralytics_example.py b/examples/count_people_in_zone/ultralytics_example.py index 9c967b2149..c9dc6276b6 100644 --- a/examples/count_people_in_zone/ultralytics_example.py +++ b/examples/count_people_in_zone/ultralytics_example.py @@ -177,7 +177,8 @@ def main( detections=detections, ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/speed_estimation/inference_example.py b/examples/speed_estimation/inference_example.py index f962b44260..0d99e2ef06 100644 --- a/examples/speed_estimation/inference_example.py +++ b/examples/speed_estimation/inference_example.py @@ -138,7 +138,8 @@ def main( sink.write_frame(annotated_frame) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/speed_estimation/ultralytics_example.py b/examples/speed_estimation/ultralytics_example.py index a90eddae0d..7ebae7d4a1 100644 --- a/examples/speed_estimation/ultralytics_example.py +++ b/examples/speed_estimation/ultralytics_example.py @@ -122,7 +122,8 @@ def main( sink.write_frame(annotated_frame) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/speed_estimation/yolo_nas_example.py b/examples/speed_estimation/yolo_nas_example.py index caaa7a1c50..b64a3eebb2 100644 --- a/examples/speed_estimation/yolo_nas_example.py +++ b/examples/speed_estimation/yolo_nas_example.py @@ -125,7 +125,8 @@ def main( sink.write_frame(annotated_frame) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/README.md b/examples/time_in_zone/README.md index d27e3dd674..24ee1ee793 100644 --- a/examples/time_in_zone/README.md +++ b/examples/time_in_zone/README.md @@ -30,6 +30,8 @@ https://github.com/roboflow/supervision/assets/26109316/d051cc8a-dd15-41d4-aa36- uv pip install -r requirements.txt ``` +The three RTSP `*_stream_example.py` scripts display frames from an `InferencePipeline` callback running on a worker thread, so they use OpenCV HighGUI instead of `sv.ImageWindow`. Install `opencv-python` and keep only one OpenCV wheel installed to run those scripts. The file and naive-stream examples use `sv.ImageWindow` with the default headless dependency. + ## 🛠 scripts ### `download_from_youtube` diff --git a/examples/time_in_zone/inference_file_example.py b/examples/time_in_zone/inference_file_example.py index 8f6167ace4..20c30b5aa4 100644 --- a/examples/time_in_zone/inference_file_example.py +++ b/examples/time_in_zone/inference_file_example.py @@ -85,7 +85,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/inference_naive_stream_example.py b/examples/time_in_zone/inference_naive_stream_example.py index 1cfeb1aabf..bee37a9c31 100644 --- a/examples/time_in_zone/inference_naive_stream_example.py +++ b/examples/time_in_zone/inference_naive_stream_example.py @@ -95,7 +95,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/rfdetr_file_example.py b/examples/time_in_zone/rfdetr_file_example.py index a0783297f2..8b1886cdf6 100644 --- a/examples/time_in_zone/rfdetr_file_example.py +++ b/examples/time_in_zone/rfdetr_file_example.py @@ -162,7 +162,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/rfdetr_naive_stream_example.py b/examples/time_in_zone/rfdetr_naive_stream_example.py index 2b6321fb87..f6e370a07b 100644 --- a/examples/time_in_zone/rfdetr_naive_stream_example.py +++ b/examples/time_in_zone/rfdetr_naive_stream_example.py @@ -172,7 +172,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py index 918bfb1868..d7d87dc0e2 100644 --- a/examples/time_in_zone/scripts/draw_zones.py +++ b/examples/time_in_zone/scripts/draw_zones.py @@ -148,6 +148,8 @@ def main(source_path: str, zone_configuration_path: str) -> None: while True: key = window.wait_key(1) + if not window.is_open: + break if key in KEY_ENTER: close_and_finalize_polygon(image, original_image, window) elif key == KEY_ESCAPE: diff --git a/examples/time_in_zone/ultralytics_file_example.py b/examples/time_in_zone/ultralytics_file_example.py index f581179b36..e70bde8866 100644 --- a/examples/time_in_zone/ultralytics_file_example.py +++ b/examples/time_in_zone/ultralytics_file_example.py @@ -89,7 +89,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/time_in_zone/ultralytics_naive_stream_example.py b/examples/time_in_zone/ultralytics_naive_stream_example.py index 3bf04b05bc..e2a24bf9d3 100644 --- a/examples/time_in_zone/ultralytics_naive_stream_example.py +++ b/examples/time_in_zone/ultralytics_naive_stream_example.py @@ -99,7 +99,8 @@ def main( ) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/traffic_analysis/inference_example.py b/examples/traffic_analysis/inference_example.py index ae5ab561e9..9eef4fa880 100644 --- a/examples/traffic_analysis/inference_example.py +++ b/examples/traffic_analysis/inference_example.py @@ -115,7 +115,8 @@ def process_video(self) -> None: for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/examples/traffic_analysis/ultralytics_example.py b/examples/traffic_analysis/ultralytics_example.py index 059e06bd3c..98ea384943 100644 --- a/examples/traffic_analysis/ultralytics_example.py +++ b/examples/traffic_analysis/ultralytics_example.py @@ -112,7 +112,8 @@ def process_video(self) -> None: for frame in tqdm(frame_generator, total=self.video_info.total_frames): annotated_frame = self.process_frame(frame) window.show(annotated_frame) - if window.wait_key(1) == "q": + key = window.wait_key(1) + if not window.is_open or key == "q": break window.close() diff --git a/pyproject.toml b/pyproject.toml index 00ff547792..e61b3f9371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "defusedxml>=0.7.1", "matplotlib>=3.6", "numpy>=1.21.2", - "opencv-python-headless>=4.5.5.64", + "opencv-python-headless>=4.5.5.64,<5", "pillow>=9.4", "pydeprecate>=0.9,<0.11", "pyyaml>=5.3", diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 99076de627..e6d5821ee1 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -21,7 +21,7 @@ class ImageWindow: supervision dependency). Instantiation always succeeds; `show()` raises `ModuleNotFoundError` (No module named '_tkinter') on environments where `python3-tk` is absent — install with ``sudo apt-get install python3-tk`` - (Debian/Ubuntu) or ``brew install python-tk`` (macOS with Homebrew/pyenv) + (Debian/Ubuntu) or ``brew install tcl-tk`` (macOS with Homebrew/pyenv) — and raises `tkinter.TclError` on headless servers where a display is unavailable. @@ -154,6 +154,11 @@ def set_mouse_callback(self, callback: MouseCallback | None) -> None: """ self._mouse_callback = callback + @property + def is_open(self) -> bool: + """Return whether the underlying Tk window currently exists.""" + return self._window_exists() + def close(self) -> None: """Destroy the window and release its resources.""" if self._root is not None: diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index 9b3ea53084..ea57433f55 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -261,11 +261,10 @@ def test_crop_image_clips_out_of_bounds_coordinates() -> None: image_pil = Image.fromarray(image_np) xyxy = (-2, -1, 3, 3) expected = image_np[0:3, 0:3] - expected_pil = np.repeat(expected[:, :, None], 3, axis=2) np.testing.assert_array_equal(crop_image(image=image_np, xyxy=xyxy), expected) np.testing.assert_array_equal( - np.asarray(crop_image(image=image_pil, xyxy=xyxy)), expected_pil + np.asarray(crop_image(image=image_pil, xyxy=xyxy)), expected ) diff --git a/tests/utils/test_image_window.py b/tests/utils/test_image_window.py index 6c969ccfce..50c4489c42 100644 --- a/tests/utils/test_image_window.py +++ b/tests/utils/test_image_window.py @@ -359,6 +359,19 @@ def test_wait_key_returns_none_on_timeout(self): class TestImageWindowClose: + def test_is_open_reports_window_state(self): + """is_open reflects whether the underlying Tk root still exists.""" + window = ImageWindow() + assert window.is_open is False + + mock_root = MagicMock() + mock_root.winfo_exists.return_value = 1 + window._root = mock_root + assert window.is_open is True + + mock_root.winfo_exists.return_value = 0 + assert window.is_open is False + def test_close_destroys_root(self): """close() calls destroy() on the Tk root and nulls internal refs.""" window = ImageWindow() diff --git a/uv.lock b/uv.lock index 98856fdeaa..3afbbcee82 100644 --- a/uv.lock +++ b/uv.lock @@ -3427,7 +3427,7 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "matplotlib", specifier = ">=3.6" }, { name = "numpy", specifier = ">=1.21.2" }, - { name = "opencv-python-headless", specifier = ">=4.5.5.64" }, + { name = "opencv-python-headless", specifier = ">=4.5.5.64,<5" }, { name = "pandas", marker = "extra == 'metrics'", specifier = ">=2" }, { name = "pillow", specifier = ">=9.4" }, { name = "pydeprecate", specifier = ">=0.9,<0.11" }, From c9ed0c27f8caafe5f5d20c58145199b6422d1f99 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:51:05 +0000 Subject: [PATCH 25/32] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/faq.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 4b7d946772..106e708d59 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -72,10 +72,7 @@ pip install opencv-python # GUI support # or: pip install opencv-python-headless # headless support ``` -The full-wheel option is a manual override of supervision's declared headless -dependency. A later dependency update may reinstall the headless wheel, so use -a dedicated environment or verify the installed OpenCV distributions after -updating packages. Never keep both wheel distributions installed together. +The full-wheel option is a manual override of supervision's declared headless dependency. A later dependency update may reinstall the headless wheel, so use a dedicated environment or verify the installed OpenCV distributions after updating packages. Never keep both wheel distributions installed together. ## How do I process frames from a webcam with supervision? From 9cd8c60af1b38f64a7b2d8327c10fc94f717c6e3 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:08:21 +0200 Subject: [PATCH 26/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/supervision/utils/image_window.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index e6d5821ee1..089eaa0ea1 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -91,9 +91,8 @@ def __init__( def show(self, image: npt.NDArray[np.uint8]) -> None: """Display a BGR, grayscale, or BGRA frame in the window. - The image is scaled to fill the current window dimensions while - preserving aspect ratio. Resizing the window rescales live. - + The image is scaled to fit the current window dimensions. Aspect ratio is + preserved unless `keep_aspect_ratio=False`. Resizing the window rescales live. Args: image: uint8 numpy array. Accepted shapes: - ``(H, W)`` — grayscale From bff69b4d3efae04b42003e563744d77ba0f2a216 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:08:34 +0200 Subject: [PATCH 27/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/supervision/utils/image_window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/supervision/utils/image_window.py b/src/supervision/utils/image_window.py index 089eaa0ea1..796b018fcf 100644 --- a/src/supervision/utils/image_window.py +++ b/src/supervision/utils/image_window.py @@ -290,6 +290,13 @@ def _reset_window_refs(self) -> None: self._photo = None self._key_event = None self._key_queue.clear() + self._win_w = 0 + self._win_h = 0 + self._display_scale_x = 1.0 + self._display_scale_y = 1.0 + self._display_offset_x = 0.0 + self._display_offset_y = 0.0 + self._pil_image = None def _fit_image( From cfd1f69a466e740d1b49b8614b70fa14efe1e0bf Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:26:53 +0200 Subject: [PATCH 28/32] Apply suggestions from code review Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e61b3f9371..aa1429c366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "defusedxml>=0.7.1", "matplotlib>=3.6", "numpy>=1.21.2", - "opencv-python-headless>=4.5.5.64,<5", + "opencv-python>=4.5.5.64,<5", "pillow>=9.4", "pydeprecate>=0.9,<0.11", "pyyaml>=5.3", From d6996fb73280d5934516b2e489ced8f0c7b992ac Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:25:57 +0200 Subject: [PATCH 29/32] docs: correct premature opencv-headless claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/changelog.md and docs/faq.md described opencv-python-headless as already the declared dependency ("supervision now depends on..."), and docs/faq.md added a full FAQ entry on restoring GUI support from headless — but pyproject.toml still pins opencv-python (the switch was reverted in cfd1f69a and dropped from this PR's scope). - Replace the false "Breaking change" block in changelog.md with a forward-looking "Planned: OpenCV dependency change" note, and drop the "under opencv-python-headless" framing from the sv.ImageWindow Added entry. - Remove the "why does cv2.imshow stop working" FAQ entry — its premise doesn't hold and won't until a future PR performs the actual dependency change. - Fix examples/time_in_zone/README.md's "with the default headless dependency" line to match the unchanged opencv-python pin. Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/changelog.md | 20 +++++--------------- docs/faq.md | 21 --------------------- examples/time_in_zone/README.md | 2 +- 3 files changed, 6 insertions(+), 37 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 6b2dd53fbc..42ca29d8d8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,24 +13,14 @@ date_modified: 2026-07-08 Users on Python 3.9 should upgrade their environment before updating supervision. +!!! warning "Planned: OpenCV dependency change" + + A future release will stop declaring an OpenCV package as a supervision dependency; users who need `cv2` directly will install their own OpenCV distribution (`opencv-python` or `opencv-python-headless`), and supervision's public API will keep working either way. This release ships `sv.ImageWindow` (see **Added**, below) as a forward-compatible replacement for `cv2.imshow`/`cv2.waitKey`/`cv2.namedWindow`, so display code can migrate ahead of that change. No breaking change yet — `cv2.imshow` and friends keep working unchanged as long as `opencv-python` is installed. + ### Breaking Changes - `sv.JSONSink` now emits native JSON types for numeric and boolean data fields instead of stringified values. Fields previously serialized as `"True"`/`"False"`, `"1"`/`"0.85"`, or `"400.0"` are now `true`/`false`, `1`/`0.85`, `400.0`. Downstream consumers that compare field values as strings (e.g. `row["score"] == "1"`) or use strict string-typed schema validators must be updated. `sv.CSVSink` remains textual, but its custom-data slicing now matches `sv.JSONSink`: NumPy arrays, lists, and tuples are sliced per row only when their length matches the detection count; mismatched-length values are broadcast unchanged ([#2400](https://github.com/roboflow/supervision/pull/2400)). - `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)). -!!! warning "Breaking change" - `supervision` now depends on `opencv-python-headless` instead of `opencv-python`. The headless wheel provides the same `cv2` API except for desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`, and mouse/keyboard callbacks), which are not available in headless builds. All non-GUI `cv2` behaviour — drawing, text metrics, contour hierarchy, video I/O, affine transforms, image I/O — is unchanged. - - **Who is affected:** users who called `cv2.imshow`, `cv2.waitKey`, or `cv2.namedWindow` in their own scripts alongside `import supervision`, relying on supervision's transitive `opencv-python` dependency to provide those symbols. - - **How to restore GUI support:** replace the headless wheel with the full one: - ```bash - pip uninstall -y opencv-python-headless - pip install opencv-python - ``` - Both wheel families share the `cv2` namespace; keep only one installed at a time. - - **Co-installation warning:** `opencv-python` and `opencv-python-headless` share the `cv2` namespace and install conflicting files to the same path. pip does not detect this — both wheels install silently, but the resulting `cv2` behavior is non-deterministic depending on install order. If you also install packages that bring in `opencv-python` (e.g. `ultralytics`, `inference-sdk`), you may end up with both wheels. Run the same commands shown above to remove the headless wheel afterward. - ### Fixed - `sv.hex_to_rgba` now rejects multiple leading `#` characters instead of silently normalizing them, matching `sv.is_valid_hex` and the documented single optional prefix. - `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged. @@ -61,7 +51,7 @@ date_modified: 2026-07-08 - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added -- Added: [`sv.ImageWindow`](utils/image_window.md/#supervision.utils.image_window.ImageWindow) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey` under `opencv-python-headless`. Key differences from cv2: +- Added: [`sv.ImageWindow`](utils/image_window.md/#supervision.utils.image_window.ImageWindow) — tkinter + Pillow desktop window that replaces `cv2.imshow` / `cv2.waitKey`, usable regardless of which OpenCV wheel (or none) is installed. Key differences from cv2: - `wait_key()` returns a tkinter keysym `str` (e.g. `"q"`, `"Escape"`) or `None`, not an `int` — update `key == ord("q")` to `key == "q"`. - Mouse callback signature is `(x: int, y: int, event_type: str)` where `event_type` is `"down"`, `"up"`, or `"move"` — incompatible with cv2's `(event, x, y, flags, param)`. - Only left-button events are captured; scroll, right-button, and modifier flags have no equivalent. diff --git a/docs/faq.md b/docs/faq.md index 106e708d59..561a34062a 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -53,27 +53,6 @@ Install `supervision[metrics]`, then use `supervision.metrics.mean_average_preci Yes. Supervision is free and open source under the MIT license. -## Why does cv2.imshow stop working after installing supervision? - -Supervision depends on `opencv-python-headless`, which does not include desktop GUI functions (`cv2.imshow`, `cv2.waitKey`, `cv2.namedWindow`). To restore them, replace the headless wheel with the full one: - -``` -pip uninstall -y opencv-python-headless -pip install opencv-python -``` - -Both wheel families share the `cv2` namespace; keep only one installed at a time. - -If another dependency such as `ultralytics` or `inference-sdk` requires `opencv-python`, pip can install both wheel distributions even though they write to the same `cv2` namespace. Choose the wheel that matches your use case, remove the other one, and reinstall it explicitly if needed: - -```bash -pip uninstall -y opencv-python opencv-python-headless -pip install opencv-python # GUI support -# or: pip install opencv-python-headless # headless support -``` - -The full-wheel option is a manual override of supervision's declared headless dependency. A later dependency update may reinstall the headless wheel, so use a dedicated environment or verify the installed OpenCV distributions after updating packages. Never keep both wheel distributions installed together. - ## How do I process frames from a webcam with supervision? Supervision does not support live camera capture. Manage the capture device yourself with `cv2.VideoCapture` (requires `opencv-python`) and pass individual frames to supervision annotators: diff --git a/examples/time_in_zone/README.md b/examples/time_in_zone/README.md index 24ee1ee793..4c6a07a4fd 100644 --- a/examples/time_in_zone/README.md +++ b/examples/time_in_zone/README.md @@ -30,7 +30,7 @@ https://github.com/roboflow/supervision/assets/26109316/d051cc8a-dd15-41d4-aa36- uv pip install -r requirements.txt ``` -The three RTSP `*_stream_example.py` scripts display frames from an `InferencePipeline` callback running on a worker thread, so they use OpenCV HighGUI instead of `sv.ImageWindow`. Install `opencv-python` and keep only one OpenCV wheel installed to run those scripts. The file and naive-stream examples use `sv.ImageWindow` with the default headless dependency. +The three RTSP `*_stream_example.py` scripts display frames from an `InferencePipeline` callback running on a worker thread, so they use OpenCV HighGUI instead of `sv.ImageWindow`. Install `opencv-python` and keep only one OpenCV wheel installed to run those scripts. The file and naive-stream examples use `sv.ImageWindow`, which works regardless of which OpenCV wheel (or none) is installed. ## 🛠 scripts From 317900f078b354375a1a4e861a8a91936b6528eb Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:57:08 +0200 Subject: [PATCH 30/32] docs(changelog): drop premature OpenCV-dependency-change note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Planned: OpenCV dependency change" admonition announced a future dependency removal with no scheduled PR behind it — this PR doesn't touch pyproject.toml's opencv-python pin, and the change it referenced belongs to a separate, not-yet-started multi-PR effort. A changelog records shipped changes, not unscheduled roadmap items. Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/changelog.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 42ca29d8d8..268c53ccb5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,10 +13,6 @@ date_modified: 2026-07-08 Users on Python 3.9 should upgrade their environment before updating supervision. -!!! warning "Planned: OpenCV dependency change" - - A future release will stop declaring an OpenCV package as a supervision dependency; users who need `cv2` directly will install their own OpenCV distribution (`opencv-python` or `opencv-python-headless`), and supervision's public API will keep working either way. This release ships `sv.ImageWindow` (see **Added**, below) as a forward-compatible replacement for `cv2.imshow`/`cv2.waitKey`/`cv2.namedWindow`, so display code can migrate ahead of that change. No breaking change yet — `cv2.imshow` and friends keep working unchanged as long as `opencv-python` is installed. - ### Breaking Changes - `sv.JSONSink` now emits native JSON types for numeric and boolean data fields instead of stringified values. Fields previously serialized as `"True"`/`"False"`, `"1"`/`"0.85"`, or `"400.0"` are now `true`/`false`, `1`/`0.85`, `400.0`. Downstream consumers that compare field values as strings (e.g. `row["score"] == "1"`) or use strict string-typed schema validators must be updated. `sv.CSVSink` remains textual, but its custom-data slicing now matches `sv.JSONSink`: NumPy arrays, lists, and tuples are sliced per row only when their length matches the detection count; mismatched-length values are broadcast unchanged ([#2400](https://github.com/roboflow/supervision/pull/2400)). - `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)). From 51f665d79d3231307af30eb4adbceb2aff9e2edd Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:25:38 +0200 Subject: [PATCH 31/32] docs: fix wrong opencv-python-only claim for cv2.VideoCapture in FAQ cv2.VideoCapture lives in OpenCV's videoio module, not highgui, so it works under opencv-python-headless too. Verified empirically in a clean opencv-python-headless-only venv. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- docs/faq.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 561a34062a..3c1a579ed2 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -55,10 +55,10 @@ Yes. Supervision is free and open source under the MIT license. ## How do I process frames from a webcam with supervision? -Supervision does not support live camera capture. Manage the capture device yourself with `cv2.VideoCapture` (requires `opencv-python`) and pass individual frames to supervision annotators: +Supervision does not support live camera capture. Manage the capture device yourself with `cv2.VideoCapture`, which works regardless of which OpenCV wheel (`opencv-python` or `opencv-python-headless`) is installed, and pass individual frames to supervision annotators: ```python -import cv2 # requires: pip install opencv-python +import cv2 # requires: pip install opencv-python (or opencv-python-headless) import supervision as sv cap = cv2.VideoCapture(0) From e903d685d8d7ca653e988981bcd3b5e5a093ac61 Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:31:49 +0200 Subject: [PATCH 32/32] replace opencv-python-headless with opencv-python as dependency in uv.lock --- uv.lock | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index e7dfcdf329..e40f69008c 100644 --- a/uv.lock +++ b/uv.lock @@ -2214,22 +2214,21 @@ wheels = [ ] [[package]] -name = "opencv-python-headless" -version = "4.13.0.92" +name = "opencv-python" +version = "4.11.0.86" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, - { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, - { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, + { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, + { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, ] [[package]] @@ -3375,7 +3374,7 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opencv-python-headless" }, + { name = "opencv-python" }, { name = "pillow" }, { name = "pydeprecate" }, { name = "pyyaml" }, @@ -3427,7 +3426,7 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "matplotlib", specifier = ">=3.6" }, { name = "numpy", specifier = ">=1.21.2" }, - { name = "opencv-python-headless", specifier = ">=4.5.5.64,<5" }, + { name = "opencv-python", specifier = ">=4.5.5.64" }, { name = "pandas", marker = "extra == 'metrics'", specifier = ">=2" }, { name = "pillow", specifier = ">=9.4" }, { name = "pydeprecate", specifier = ">=0.9,<0.11" },