diff --git a/docs/changelog.md b/docs/changelog.md
index f4515da906..268c53ccb5 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -47,6 +47,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`](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.
+ - 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 7c17d7bfe2..3c1a579ed2 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -53,6 +53,27 @@ Install `supervision[metrics]`, then use `supervision.metrics.mean_average_preci
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`, 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 (or opencv-python-headless)
+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/docs/utils/image_window.md b/docs/utils/image_window.md
new file mode 100644
index 0000000000..577bc394a8
--- /dev/null
+++ b/docs/utils/image_window.md
@@ -0,0 +1,12 @@
+---
+comments: true
+status: new
+---
+
+# Image Window
+
+
+
+:::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 2b21126671..2b36cd55a2 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
@@ -117,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)
@@ -179,6 +178,7 @@ def main(
)
sink.write_frame(annotated_frame)
else:
+ 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(
@@ -188,11 +188,12 @@ 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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 6caf3c237e..c9dc6276b6 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
@@ -116,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)
@@ -167,6 +166,7 @@ def main(
)
sink.write_frame(annotated_frame)
else:
+ 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(
@@ -176,11 +176,12 @@ 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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 21ec95b2a6..0d99e2ef06 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.ImageWindow("frame")
for frame in frame_generator:
results = model.infer(
frame, confidence=confidence_threshold, iou=iou_threshold
@@ -109,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 = []
@@ -136,10 +137,11 @@ def main(
)
sink.write_frame(annotated_frame)
- cv2.imshow("frame", annotated_frame)
- if cv2.waitKey(1) & 0xFF == ord("q"):
+ window.show(annotated_frame)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 3678a55462..7ebae7d4a1 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.ImageWindow("frame")
for frame in frame_generator:
result = model(frame, conf=confidence_threshold, iou=iou_threshold)[0]
detections = sv.Detections.from_ultralytics(result)
@@ -93,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 = []
@@ -120,10 +121,11 @@ def main(
)
sink.write_frame(annotated_frame)
- cv2.imshow("frame", annotated_frame)
- if cv2.waitKey(1) & 0xFF == ord("q"):
+ window.show(annotated_frame)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 0100e38cb6..b64a3eebb2 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.ImageWindow("frame")
for frame in frame_generator:
result = model.predict(frame, conf=confidence_threshold, iou=iou_threshold)[
0
@@ -123,10 +124,11 @@ def main(
)
sink.write_frame(annotated_frame)
- cv2.imshow("frame", annotated_frame)
- if cv2.waitKey(1) & 0xFF == ord("q"):
+ window.show(annotated_frame)
+ key = window.wait_key(1)
+ if not window.is_open or key == "q":
break
- cv2.destroyAllWindows()
+ window.close()
if __name__ == "__main__":
diff --git a/examples/time_in_zone/README.md b/examples/time_in_zone/README.md
index d27e3dd674..4c6a07a4fd 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`, which works regardless of which OpenCV wheel (or none) is installed.
+
## 🛠 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 59f3b4c0bb..20c30b5aa4 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.ImageWindow("Processed Video")
for frame in frames_generator:
results = model.infer(
frame, confidence=confidence_threshold, iou_threshold=iou_threshold
@@ -84,10 +84,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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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..bee37a9c31 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.ImageWindow("Processed Video")
for frame in frames_generator:
fps_monitor.tick()
fps = fps_monitor.fps
@@ -94,10 +94,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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "q":
break
- cv2.destroyAllWindows()
+ window.close()
if __name__ == "__main__":
diff --git a/examples/time_in_zone/rfdetr_file_example.py b/examples/time_in_zone/rfdetr_file_example.py
index 2c9e4aa6e0..8b1886cdf6 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
@@ -128,6 +127,7 @@ def main(
]
timers = [FPSBasedTimer(video_info.fps) for _ in zones]
+ 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)]
@@ -161,10 +161,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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 b517277474..f6e370a07b 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
@@ -128,6 +127,7 @@ def main(
]
timers = [ClockBasedTimer() for _ in zones]
+ window = sv.ImageWindow("Processed Video")
for frame in frames_generator:
fps_monitor.tick()
fps = fps_monitor.fps
@@ -171,11 +171,12 @@ 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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "q":
break
- cv2.destroyAllWindows()
+ window.close()
if __name__ == "__main__":
diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py
index a6bea0b2bb..d7d87dc0e2 100644
--- a/examples/time_in_zone/scripts/draw_zones.py
+++ b/examples/time_in_zone/scripts/draw_zones.py
@@ -1,6 +1,5 @@
import json
import os
-from typing import Any
import cv2
import numpy as np
@@ -8,11 +7,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", "KP_Enter"}
+KEY_ESCAPE = "Escape"
+KEY_QUIT = "q"
+KEY_SAVE = "s"
THICKNESS = 2
COLORS = sv.ColorPalette.DEFAULT
@@ -35,15 +33,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.ImageWindow
+) -> None:
global POLYGONS, current_mouse_position
image[:] = original_image.copy()
for idx, polygon in enumerate(POLYGONS):
@@ -78,10 +78,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.ImageWindow
+) -> None:
if len(POLYGONS[-1]) > 2:
cv2.line(
img=image,
@@ -93,7 +95,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,16 @@ 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.ImageWindow(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 not window.is_open:
+ break
+ if key in KEY_ENTER:
+ close_and_finalize_polygon(image, original_image, window)
elif key == KEY_ESCAPE:
POLYGONS[-1] = []
current_mouse_position = None
@@ -154,11 +159,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..e70bde8866 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.ImageWindow("Processed Video")
for frame in frames_generator:
results = model(
frame,
@@ -88,10 +88,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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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..e2a24bf9d3 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.ImageWindow("Processed Video")
for frame in frames_generator:
fps_monitor.tick()
fps = fps_monitor.fps
@@ -98,10 +98,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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "q":
break
- cv2.destroyAllWindows()
+ window.close()
if __name__ == "__main__":
diff --git a/examples/traffic_analysis/inference_example.py b/examples/traffic_analysis/inference_example.py
index 8bd139418e..9eef4fa880 100644
--- a/examples/traffic_analysis/inference_example.py
+++ b/examples/traffic_analysis/inference_example.py
@@ -1,7 +1,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
@@ -112,12 +111,14 @@ def process_video(self) -> None:
annotated_frame = self.process_frame(frame)
sink.write_frame(annotated_frame)
else:
+ window = sv.ImageWindow("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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "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 03b8d776ed..98ea384943 100644
--- a/examples/traffic_analysis/ultralytics_example.py
+++ b/examples/traffic_analysis/ultralytics_example.py
@@ -1,6 +1,5 @@
from collections.abc import Iterable
-import cv2
import numpy as np
from tqdm import tqdm
from ultralytics import YOLO
@@ -109,12 +108,14 @@ def process_video(self) -> None:
annotated_frame = self.process_frame(frame)
sink.write_frame(annotated_frame)
else:
+ window = sv.ImageWindow("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)
+ key = window.wait_key(1)
+ if not window.is_open or key == "q":
break
- cv2.destroyAllWindows()
+ window.close()
def annotate_frame(
self, frame: np.ndarray, detections: sv.Detections
diff --git a/mkdocs.yml b/mkdocs.yml
index df8e2f6f55..eb8e41cf7c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,6 +80,7 @@ nav:
- Conversion: utils/conversion.md
- 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
diff --git a/pyproject.toml b/pyproject.toml
index 4972264801..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>=4.5.5.64",
+ "opencv-python>=4.5.5.64,<5",
"pillow>=9.4",
"pydeprecate>=0.9,<0.11",
"pyyaml>=5.3",
diff --git a/src/supervision/__init__.py b/src/supervision/__init__.py
index a3f2999914..fbe75c0d31 100644
--- a/src/supervision/__init__.py
+++ b/src/supervision/__init__.py
@@ -150,6 +150,7 @@
scale_image,
tint_image,
)
+from supervision.utils.image_window import ImageWindow
from supervision.utils.notebook import plot_image, plot_images_grid
from supervision.utils.video import (
FPSMonitor,
@@ -194,6 +195,7 @@
"HeatMapAnnotator",
"IconAnnotator",
"ImageSink",
+ "ImageWindow",
"InferenceSlicer",
"JSONSink",
"KeyPoints",
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
new file mode 100644
index 0000000000..796b018fcf
--- /dev/null
+++ b/src/supervision/utils/image_window.py
@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from contextlib import suppress
+from typing import Any
+
+import numpy as np
+import numpy.typing as npt
+from PIL import Image
+
+from supervision.utils.conversion import cv2_to_pillow
+
+MouseCallback = Callable[[int, int, str], None]
+
+
+class ImageWindow:
+ """Desktop image window backed by stdlib tkinter + pillow.
+
+ 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
+ `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 tcl-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"``,
+ ``"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.
+
+ 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
+ import supervision as sv
+
+ window = sv.ImageWindow("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.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":
+ break
+ ```
+ """
+
+ 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
+ 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.
+
+ 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
+ - ``(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()."
+ )
+ self._pil_image = _bgr_to_pil(image)
+ self._ensure_window()
+ self._update_display()
+ 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.
+ """
+ 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)
+ root = self._root
+ if delay_ms <= 0:
+ self._wait_for_key_or_close()
+ else:
+ 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:
+ """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
+
+ @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:
+ root = self._root
+ self._signal_wait()
+ with suppress(Exception):
+ root.destroy()
+ self._reset_window_refs()
+
+ def __enter__(self) -> ImageWindow:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+ def _ensure_window(self) -> 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(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._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 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:
+ 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
+ if self._key_queue:
+ 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
+ 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(
+ 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:
+ """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/src/supervision/utils/video.py b/src/supervision/utils/video.py
index fa76d9d28c..37b3dd9ace 100644
--- a/src/supervision/utils/video.py
+++ b/src/supervision/utils/video.py
@@ -58,6 +58,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}")
@@ -272,8 +288,23 @@ def get_video_frames_generator(
frames of the video.
Note:
- The underlying `cv2.VideoCapture` is always released when the generator
- is exhausted or closed, even if the consumer breaks out of iteration early.
+ 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
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.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
new file mode 100644
index 0000000000..50c4489c42
--- /dev/null
+++ b/tests/utils/test_image_window.py
@@ -0,0 +1,540 @@
+"""Tests for ImageWindow and _bgr_to_pil helper."""
+
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from supervision.utils.image_window import ImageWindow, _bgr_to_pil, _fit_image
+
+
+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 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 TestImageWindowShow:
+ @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 = 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 = ImageWindow("preview")
+ 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.ImageWindow._ensure_window"),
+ patch.dict("sys.modules", {"PIL.ImageTk": fake_imagetk}),
+ ):
+ window._root = mock_root
+ window._label = mock_label
+
+ frame = np.zeros((4, 4, 3), dtype=np.uint8)
+ window.show(frame)
+
+ 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()
+
+
+class TestImageWindowUpdateDisplay:
+ def test_no_op_without_pil_image(self):
+ """_update_display() is a no-op when no image has been shown yet."""
+ 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 = 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 = ImageWindow()
+ 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 = ImageWindow()
+ 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 = ImageWindow(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 TestImageWindowOnConfigure:
+ def test_non_root_widget_is_ignored(self):
+ """ events from child widgets do not update dimensions."""
+ window = ImageWindow()
+ 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 = ImageWindow()
+ 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 = ImageWindow()
+ 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 = ImageWindow()
+ 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 = ImageWindow()
+ 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 TestImageWindowWaitKey:
+ def test_wait_key_returns_none_when_no_window(self):
+ """wait_key() returns None immediately when no window exists."""
+ 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 = ImageWindow()
+ 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 = ImageWindow()
+ 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_blocks_with_tk_event_loop(self):
+ """Blocking wait_key() uses Tk wait_variable instead of update polling."""
+ window = ImageWindow()
+ 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 = ImageWindow()
+ mock_root = MagicMock()
+ mock_event = MagicMock()
+ mock_root.after.return_value = "timeout-id"
+ window._root = mock_root
+ window._key_event = mock_event
+
+ result = window.wait_key(delay_ms=1)
+
+ assert result is None
+ 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 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()
+ 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_signals_waiters_and_clears_key_event(self):
+ """close() wakes wait_key() callers and clears stale Tk references."""
+ window = ImageWindow()
+ 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 = 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 = ImageWindow()
+ 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 = ImageWindow()
+ mock_root = MagicMock()
+ mock_root.winfo_exists.return_value = 0
+ window._root = mock_root
+
+ assert window._window_exists() is False
+
+
+class TestImageWindowContextManager:
+ def test_context_manager_closes_on_exit(self):
+ """The with-statement calls close() when the block exits normally."""
+ 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(ImageWindow, "close") as mock_close:
+ with pytest.raises(RuntimeError):
+ with ImageWindow("ctx"):
+ raise RuntimeError("boom")
+ mock_close.assert_called_once()
+
+
+class TestImageWindowMouseCallback:
+ def test_set_mouse_callback_stores_callable(self):
+ """set_mouse_callback() stores the callable for later use."""
+ 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 = ImageWindow()
+ window.set_mouse_callback(MagicMock())
+ window.set_mouse_callback(None)
+ assert window._mouse_callback is None
+
+ 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()
+ event.x = 5
+ 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")
+
+ 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")