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 + +
+

ImageWindow

+
+ +:::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")