cvflair 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cvflair/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ cvflair -- theme-based visualisation for computer vision detections.
3
+
4
+ The package draws nothing itself: it configures ``supervision`` annotators and
5
+ wraps the camera loop, so the everyday case fits in three lines::
6
+
7
+ from cvflair import Camera
8
+
9
+ cam = Camera(source=0, theme="neon")
10
+ for frame in cam.stream():
11
+ cam.show(frame)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .camera import Camera, CameraError
17
+ from .models import Detector, UltralyticsDetector, load_ultralytics, resolve_detector
18
+ from .themes import Theme, available_themes, get_theme
19
+
20
+ __version__ = "0.2.0"
21
+
22
+ __all__ = [
23
+ "Camera",
24
+ "CameraError",
25
+ "Detector",
26
+ "Theme",
27
+ "UltralyticsDetector",
28
+ "available_themes",
29
+ "get_theme",
30
+ "load_ultralytics",
31
+ "resolve_detector",
32
+ "__version__",
33
+ ]
cvflair/camera.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ Webcam capture on a background thread, with a one-slot latest-frame queue.
3
+
4
+ The reader thread never blocks on the consumer: before publishing a new frame
5
+ it drops the pending one, so ``read()`` always returns the most recent frame
6
+ and latency does not build up when annotation is slower than the camera.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import queue
12
+ import threading
13
+ from collections.abc import Callable, Iterator, Sequence
14
+ from typing import Any, overload
15
+
16
+ import cv2
17
+ import numpy as np
18
+ import supervision as sv
19
+
20
+ from .models import ModelLike, resolve_detector
21
+ from .themes import Theme, get_theme
22
+
23
+ __all__ = ["Camera", "CameraError"]
24
+
25
+ QUIT_KEYS = (ord("q"), ord("Q"), 27) # 27 == ESC
26
+
27
+
28
+ class CameraError(RuntimeError):
29
+ """The capture device could not be opened, or died while streaming."""
30
+
31
+
32
+ class Camera:
33
+ """
34
+ A threaded video source with a theme attached.
35
+
36
+ ::
37
+
38
+ cam = Camera(source=0, theme="neon")
39
+ for frame in cam.stream():
40
+ cam.show(frame, detections)
41
+
42
+ ``stream()`` starts the reader thread on first use and releases the device
43
+ when the generator ends, so the loop above needs no explicit teardown.
44
+ Pressing ``q`` or ``ESC`` in the preview window ends the stream.
45
+
46
+ Any object with OpenCV's ``VideoCapture`` interface can be injected through
47
+ ``capture_factory``; that is how the test suite runs without a camera.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ source: int | str = 0,
53
+ theme: str | Theme = "minimal",
54
+ *,
55
+ width: int | None = None,
56
+ height: int | None = None,
57
+ fps: int | None = None,
58
+ window_name: str = "cvflair",
59
+ capture_factory: Callable[[Any], Any] = cv2.VideoCapture,
60
+ ) -> None:
61
+ self.source = source
62
+ self.window_name = window_name
63
+ self.width = width
64
+ self.height = height
65
+ self.fps = fps
66
+
67
+ self._theme = get_theme(theme)
68
+ self._capture_factory = capture_factory
69
+ self._capture: Any | None = None
70
+ self._thread: threading.Thread | None = None
71
+ self._queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=1)
72
+ self._stop_event = threading.Event()
73
+ self._window_open = False
74
+ self._frames_read = 0
75
+ self._frames_dropped = 0
76
+
77
+ # -- configuration ---------------------------------------------------
78
+
79
+ @property
80
+ def theme(self) -> Theme:
81
+ return self._theme
82
+
83
+ @theme.setter
84
+ def theme(self, theme: str | Theme) -> None:
85
+ self._theme = get_theme(theme)
86
+
87
+ @property
88
+ def is_running(self) -> bool:
89
+ return self._thread is not None and self._thread.is_alive()
90
+
91
+ @property
92
+ def frames_read(self) -> int:
93
+ """Frames pulled off the device since :meth:`start`."""
94
+ return self._frames_read
95
+
96
+ @property
97
+ def frames_dropped(self) -> int:
98
+ """Frames discarded because the consumer was still busy."""
99
+ return self._frames_dropped
100
+
101
+ # -- lifecycle -------------------------------------------------------
102
+
103
+ def start(self) -> Camera:
104
+ """Open the device and start the reader thread. Safe to call twice."""
105
+ if self.is_running:
106
+ return self
107
+
108
+ capture = self._capture_factory(self.source)
109
+ if not capture.isOpened():
110
+ capture.release()
111
+ raise CameraError(
112
+ f"Could not open video source {self.source!r}. "
113
+ "Check that the device index is right and no other application is using it."
114
+ )
115
+ self._apply_properties(capture)
116
+
117
+ self._capture = capture
118
+ self._stop_event.clear()
119
+ self._thread = threading.Thread(
120
+ target=self._read_loop, name=f"cvflair-reader-{self.source}", daemon=True
121
+ )
122
+ self._thread.start()
123
+ return self
124
+
125
+ def _apply_properties(self, capture: Any) -> None:
126
+ if self.width is not None:
127
+ capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
128
+ if self.height is not None:
129
+ capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
130
+ if self.fps is not None:
131
+ capture.set(cv2.CAP_PROP_FPS, self.fps)
132
+
133
+ def _read_loop(self) -> None:
134
+ capture = self._capture
135
+ while not self._stop_event.is_set():
136
+ ok, frame = capture.read()
137
+ if not ok or frame is None:
138
+ break # end of file, or the device went away
139
+ self._frames_read += 1
140
+ self._publish(frame)
141
+ self._stop_event.set()
142
+
143
+ def _publish(self, frame: np.ndarray) -> None:
144
+ """Put ``frame`` in the one-slot queue, discarding whatever was there."""
145
+ try:
146
+ self._queue.get_nowait()
147
+ self._frames_dropped += 1
148
+ except queue.Empty:
149
+ pass
150
+ try:
151
+ self._queue.put_nowait(frame)
152
+ except queue.Full: # pragma: no cover - single producer, cannot happen
153
+ self._frames_dropped += 1
154
+
155
+ def close(self) -> None:
156
+ """Stop the thread, release the device, close the preview window."""
157
+ self._stop_event.set()
158
+ if self._thread is not None:
159
+ self._thread.join(timeout=2.0)
160
+ self._thread = None
161
+ if self._capture is not None:
162
+ self._capture.release()
163
+ self._capture = None
164
+ if self._window_open:
165
+ cv2.destroyWindow(self.window_name)
166
+ self._window_open = False
167
+
168
+ def __enter__(self) -> Camera:
169
+ return self.start()
170
+
171
+ def __exit__(self, *exc_info: object) -> None:
172
+ self.close()
173
+
174
+ def __repr__(self) -> str:
175
+ state = "running" if self.is_running else "stopped"
176
+ return f"Camera(source={self.source!r}, theme={self._theme.name!r}, {state})"
177
+
178
+ # -- reading ---------------------------------------------------------
179
+
180
+ def read(self, timeout: float = 5.0) -> np.ndarray | None:
181
+ """
182
+ Return the latest frame, or ``None`` if the source ended or stalled.
183
+
184
+ A frame is never returned twice: the queue slot is consumed here.
185
+ """
186
+ try:
187
+ return self._queue.get(timeout=timeout)
188
+ except queue.Empty:
189
+ return None
190
+
191
+ @overload
192
+ def stream(self, timeout: float = ..., *, model: None = ...) -> Iterator[np.ndarray]: ...
193
+
194
+ @overload
195
+ def stream(
196
+ self, timeout: float = ..., *, model: ModelLike
197
+ ) -> Iterator[tuple[np.ndarray, sv.Detections]]: ...
198
+
199
+ def stream(self, timeout: float = 5.0, *, model: ModelLike | None = None):
200
+ """
201
+ Yield frames until the source ends, ``q``/``ESC`` is pressed, or no
202
+ frame arrives within ``timeout`` seconds. Releases the device on exit.
203
+
204
+ Without ``model`` the frames come through bare. With one -- a weights
205
+ path, an Ultralytics model, or any callable returning ``sv.Detections``
206
+ -- each item is a ``(frame, detections)`` pair::
207
+
208
+ for frame, detections in cam.stream(model="yolov8n.pt"):
209
+ cam.show(frame, detections)
210
+
211
+ Inference runs in this loop, not in the reader thread: while a frame is
212
+ being processed the reader keeps replacing the queued frame, so the next
213
+ iteration gets the newest one rather than a backlog.
214
+ """
215
+ detector = resolve_detector(model)
216
+ self.start()
217
+ try:
218
+ while not self._stop_event.is_set():
219
+ frame = self.read(timeout=timeout)
220
+ if frame is None:
221
+ break
222
+ yield frame if detector is None else (frame, detector(frame))
223
+ finally:
224
+ self.close()
225
+
226
+ # -- output ----------------------------------------------------------
227
+
228
+ def annotate(
229
+ self,
230
+ frame: np.ndarray,
231
+ detections: sv.Detections | None = None,
232
+ labels: Sequence[str] | None = None,
233
+ ) -> np.ndarray:
234
+ """Apply the active theme in place. Useful when not using :meth:`show`."""
235
+ if detections is None:
236
+ return frame
237
+ return self._theme.annotate(frame, detections, labels=labels)
238
+
239
+ def show(
240
+ self,
241
+ frame: np.ndarray,
242
+ detections: sv.Detections | None = None,
243
+ labels: Sequence[str] | None = None,
244
+ *,
245
+ wait: int = 1,
246
+ ) -> bool:
247
+ """
248
+ Annotate and display ``frame``.
249
+
250
+ Returns ``False`` once the user asks to quit (``q``, ``ESC``, or the
251
+ window's close button), which also ends an active :meth:`stream`.
252
+ """
253
+ self.annotate(frame, detections, labels=labels)
254
+ cv2.imshow(self.window_name, frame)
255
+ self._window_open = True
256
+
257
+ if (cv2.waitKey(wait) & 0xFF) in QUIT_KEYS or self._window_closed():
258
+ self._stop_event.set()
259
+ return False
260
+ return True
261
+
262
+ def _window_closed(self) -> bool:
263
+ try:
264
+ return cv2.getWindowProperty(self.window_name, cv2.WND_PROP_VISIBLE) < 1
265
+ except cv2.error: # pragma: no cover - headless build without HighGUI
266
+ return False
cvflair/models.py ADDED
@@ -0,0 +1,140 @@
1
+ """
2
+ Optional model binding.
3
+
4
+ cvflair ships no model and depends on none. This module only turns whatever was
5
+ passed as ``model`` into a callable that maps a frame to ``sv.Detections``:
6
+
7
+ * a weights path (``"yolov8n.pt"``) -- loaded through Ultralytics, which is an
8
+ extra (``pip install "cvflair[yolo]"``), never a hard dependency;
9
+ * an already constructed Ultralytics model -- its results are converted;
10
+ * any callable returning ``sv.Detections`` -- used as it is.
11
+
12
+ Keeping Ultralytics out of the dependency list is deliberate: it is AGPL-3.0,
13
+ and pulling it in would push that licence onto every cvflair user.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any, Protocol, runtime_checkable
21
+
22
+ import numpy as np
23
+ import supervision as sv
24
+
25
+ __all__ = [
26
+ "Detector",
27
+ "ModelLike",
28
+ "UltralyticsDetector",
29
+ "load_ultralytics",
30
+ "resolve_detector",
31
+ ]
32
+
33
+ #: Weight file suffixes Ultralytics can load.
34
+ WEIGHT_SUFFIXES = frozenset({".pt", ".onnx", ".engine", ".torchscript", ".mlpackage"})
35
+
36
+
37
+ @runtime_checkable
38
+ class Detector(Protocol):
39
+ """Anything that turns one frame into detections."""
40
+
41
+ def __call__(self, frame: np.ndarray) -> sv.Detections: ...
42
+
43
+
44
+ ModelLike = str | Path | Detector | Any
45
+
46
+
47
+ class UltralyticsDetector:
48
+ """
49
+ Runs an Ultralytics model and converts its output to ``sv.Detections``.
50
+
51
+ ``predict_kwargs`` is forwarded to every call (``conf``, ``iou``, ``device``,
52
+ ``classes`` ...). ``verbose`` defaults to ``False`` so the capture loop is
53
+ not drowned in per-frame logging.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ model: Any,
59
+ *,
60
+ convert: Callable[[Any], sv.Detections] | None = None,
61
+ **predict_kwargs: Any,
62
+ ) -> None:
63
+ self.model = model
64
+ self.predict_kwargs = {"verbose": False, **predict_kwargs}
65
+ self._convert = convert or sv.Detections.from_ultralytics
66
+
67
+ def __call__(self, frame: np.ndarray) -> sv.Detections:
68
+ results = self.model(frame, **self.predict_kwargs)
69
+ return self._convert(results[0])
70
+
71
+ def __repr__(self) -> str:
72
+ return f"UltralyticsDetector({type(self.model).__name__})"
73
+
74
+
75
+ class _CallableDetector:
76
+ """Wraps a user callable so a wrong return type fails with a clear message."""
77
+
78
+ def __init__(self, function: Callable[[np.ndarray], sv.Detections]) -> None:
79
+ self.function = function
80
+
81
+ def __call__(self, frame: np.ndarray) -> sv.Detections:
82
+ detections = self.function(frame)
83
+ if not isinstance(detections, sv.Detections):
84
+ raise TypeError(
85
+ f"model returned {type(detections).__name__}, expected supervision.Detections. "
86
+ "Convert the model output first, e.g. sv.Detections.from_ultralytics(result)."
87
+ )
88
+ return detections
89
+
90
+ def __repr__(self) -> str:
91
+ name = getattr(self.function, "__name__", type(self.function).__name__)
92
+ return f"_CallableDetector({name})"
93
+
94
+
95
+ def load_ultralytics(weights: str | Path, **predict_kwargs: Any) -> UltralyticsDetector:
96
+ """Load Ultralytics weights. Raises ``ImportError`` when the extra is missing."""
97
+ try:
98
+ from ultralytics import YOLO
99
+ except ImportError as exc:
100
+ raise ImportError(
101
+ f"Loading {str(weights)!r} needs the ultralytics package: "
102
+ 'pip install "cvflair[yolo]". It is an optional extra because Ultralytics '
103
+ "is AGPL-3.0 licensed; cvflair itself stays MIT."
104
+ ) from exc
105
+ return UltralyticsDetector(YOLO(str(weights)), **predict_kwargs)
106
+
107
+
108
+ def _is_ultralytics_model(model: Any) -> bool:
109
+ return type(model).__module__.split(".")[0] == "ultralytics"
110
+
111
+
112
+ def resolve_detector(model: ModelLike | None) -> Detector | None:
113
+ """
114
+ Turn ``model`` into a detector callable, or ``None`` when nothing was given.
115
+
116
+ Weights are loaded here, so calling this is what triggers the (slow) model
117
+ load -- ``Camera.stream()`` does it on the first iteration, not before.
118
+ """
119
+ if model is None:
120
+ return None
121
+
122
+ if isinstance(model, (str, Path)):
123
+ suffix = Path(model).suffix.lower()
124
+ if suffix not in WEIGHT_SUFFIXES:
125
+ raise ValueError(
126
+ f"Unrecognised weights file {str(model)!r}. "
127
+ f"Expected one of: {', '.join(sorted(WEIGHT_SUFFIXES))}."
128
+ )
129
+ return load_ultralytics(model)
130
+
131
+ if _is_ultralytics_model(model):
132
+ return UltralyticsDetector(model)
133
+
134
+ if callable(model):
135
+ return _CallableDetector(model)
136
+
137
+ raise TypeError(
138
+ f"model must be a weights path, an Ultralytics model, or a callable returning "
139
+ f"supervision.Detections; got {type(model).__name__}."
140
+ )
cvflair/py.typed ADDED
File without changes
cvflair/themes.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ Visual themes -- a thin configuration layer over ``supervision`` annotators.
3
+
4
+ A :class:`Theme` holds nothing but presentation settings plus the annotator
5
+ instances built from them. Annotators are created once, when the theme is
6
+ constructed, and reused for every frame; rebuilding them inside the capture
7
+ loop is the most common avoidable cost in this kind of pipeline.
8
+
9
+ No drawing maths lives here. Every pixel is still produced by ``supervision``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Literal
17
+
18
+ import numpy as np
19
+ import supervision as sv
20
+
21
+ __all__ = ["Theme", "get_theme", "available_themes", "BoxStyle"]
22
+
23
+ BoxStyle = Literal["box", "round", "corner"]
24
+
25
+
26
+ def _dim(color: sv.Color, factor: float) -> sv.Color:
27
+ """Return a darker copy of ``color`` (used for the neon halo pass)."""
28
+ return sv.Color(
29
+ r=int(color.r * factor),
30
+ g=int(color.g * factor),
31
+ b=int(color.b * factor),
32
+ )
33
+
34
+
35
+ def _dim_palette(palette: sv.ColorPalette, factor: float) -> sv.ColorPalette:
36
+ return sv.ColorPalette([_dim(color, factor) for color in palette.colors])
37
+
38
+
39
+ @dataclass
40
+ class Theme:
41
+ """
42
+ A named bundle of annotator settings.
43
+
44
+ The defaults describe a plain white box with a plain label; the shipped
45
+ presets (see :func:`get_theme`) are just different field values, which is
46
+ also how a user-defined theme is written::
47
+
48
+ Theme(name="my-theme", palette=sv.ColorPalette.from_hex(["#39FF14"]),
49
+ box_style="round", thickness=3)
50
+
51
+ Attributes are read at construction time only. Changing a field afterwards
52
+ does not rebuild the annotators -- construct a new theme instead.
53
+ """
54
+
55
+ name: str = "custom"
56
+ palette: sv.ColorPalette = field(default_factory=lambda: sv.ColorPalette.DEFAULT)
57
+ box_style: BoxStyle = "box"
58
+ thickness: int = 2
59
+ #: Corner rounding for ``box_style="round"``, in (0, 1].
60
+ roundness: float = 0.5
61
+ #: Corner arm length in pixels for ``box_style="corner"``.
62
+ corner_length: int = 20
63
+ #: Draw a dimmed, thicker box behind the main one to fake a glow.
64
+ glow: bool = False
65
+ glow_thickness: int = 5
66
+ glow_dim: float = 0.45
67
+ labels: bool = True
68
+ text_color: sv.Color = field(default_factory=lambda: sv.Color.BLACK)
69
+ text_scale: float = 0.5
70
+ text_thickness: int = 1
71
+ text_padding: int = 6
72
+ label_radius: int = 0
73
+ color_lookup: sv.ColorLookup = sv.ColorLookup.CLASS
74
+
75
+ def __post_init__(self) -> None:
76
+ if self.box_style not in ("box", "round", "corner"):
77
+ raise ValueError(
78
+ f"Unknown box_style {self.box_style!r}. Use 'box', 'round' or 'corner'."
79
+ )
80
+ if self.thickness < 1:
81
+ raise ValueError(f"thickness must be >= 1, got {self.thickness}.")
82
+ if not 0.0 < self.roundness <= 1.0:
83
+ raise ValueError(f"roundness must be in (0, 1], got {self.roundness}.")
84
+
85
+ self._glow_annotator = (
86
+ self._build_box_annotator(
87
+ palette=_dim_palette(self.palette, self.glow_dim),
88
+ thickness=self.thickness + self.glow_thickness,
89
+ )
90
+ if self.glow
91
+ else None
92
+ )
93
+ self._box_annotator = self._build_box_annotator(
94
+ palette=self.palette,
95
+ thickness=self.thickness,
96
+ )
97
+ self._label_annotator = (
98
+ sv.LabelAnnotator(
99
+ color=self.palette,
100
+ text_color=self.text_color,
101
+ text_scale=self.text_scale,
102
+ text_thickness=self.text_thickness,
103
+ text_padding=self.text_padding,
104
+ border_radius=self.label_radius,
105
+ color_lookup=self.color_lookup,
106
+ )
107
+ if self.labels
108
+ else None
109
+ )
110
+
111
+ def _build_box_annotator(self, palette: sv.ColorPalette, thickness: int):
112
+ if self.box_style == "round":
113
+ return sv.RoundBoxAnnotator(
114
+ color=palette,
115
+ thickness=thickness,
116
+ roundness=self.roundness,
117
+ color_lookup=self.color_lookup,
118
+ )
119
+ if self.box_style == "corner":
120
+ return sv.BoxCornerAnnotator(
121
+ color=palette,
122
+ thickness=thickness,
123
+ corner_length=self.corner_length,
124
+ color_lookup=self.color_lookup,
125
+ )
126
+ return sv.BoxAnnotator(
127
+ color=palette,
128
+ thickness=thickness,
129
+ color_lookup=self.color_lookup,
130
+ )
131
+
132
+ def annotate(
133
+ self,
134
+ scene: np.ndarray,
135
+ detections: sv.Detections,
136
+ labels: Sequence[str] | None = None,
137
+ ) -> np.ndarray:
138
+ """
139
+ Draw ``detections`` onto ``scene`` in place and return the same array.
140
+
141
+ ``labels`` is passed straight through to ``supervision``: when it is
142
+ ``None`` the label text falls back to the detections' ``class_name``
143
+ data field, then to the class id.
144
+ """
145
+ if len(detections) == 0:
146
+ return scene
147
+ if self._glow_annotator is not None:
148
+ self._glow_annotator.annotate(scene=scene, detections=detections)
149
+ self._box_annotator.annotate(scene=scene, detections=detections)
150
+ if self._label_annotator is not None:
151
+ self._label_annotator.annotate(scene=scene, detections=detections, labels=labels)
152
+ return scene
153
+
154
+
155
+ def _minimal() -> Theme:
156
+ """Thin single-colour box, plain label. Reads well in a screen recording."""
157
+ return Theme(
158
+ name="minimal",
159
+ palette=sv.ColorPalette([sv.Color.WHITE]),
160
+ box_style="box",
161
+ thickness=1,
162
+ text_color=sv.Color.BLACK,
163
+ text_scale=0.45,
164
+ text_padding=4,
165
+ )
166
+
167
+
168
+ def _neon() -> Theme:
169
+ """Saturated per-class colours, rounded box, dimmed halo behind it."""
170
+ return Theme(
171
+ name="neon",
172
+ palette=sv.ColorPalette.from_hex(
173
+ ["#39FF14", "#FF00E5", "#00E5FF", "#FFE600", "#FF2D55", "#7B5CFF"]
174
+ ),
175
+ box_style="round",
176
+ thickness=3,
177
+ roundness=0.6,
178
+ glow=True,
179
+ glow_thickness=6,
180
+ glow_dim=0.4,
181
+ text_color=sv.Color.BLACK,
182
+ text_scale=0.5,
183
+ text_padding=8,
184
+ label_radius=6,
185
+ )
186
+
187
+
188
+ def _pastel() -> Theme:
189
+ """Soft tones, generous rounding, dark text. Easy on a projector."""
190
+ return Theme(
191
+ name="pastel",
192
+ palette=sv.ColorPalette.from_hex(
193
+ ["#FFADAD", "#A0C4FF", "#B9FBC0", "#FFD6A5", "#CDB4DB", "#9BF6FF"]
194
+ ),
195
+ box_style="round",
196
+ thickness=2,
197
+ roundness=0.8,
198
+ text_color=sv.Color.from_hex("#2E2E2E"),
199
+ text_scale=0.45,
200
+ text_padding=8,
201
+ label_radius=10,
202
+ )
203
+
204
+
205
+ _THEMES: dict[str, Callable[[], Theme]] = {
206
+ "minimal": _minimal,
207
+ "neon": _neon,
208
+ "pastel": _pastel,
209
+ }
210
+
211
+
212
+ def available_themes() -> list[str]:
213
+ """Names accepted by :func:`get_theme` and by ``Camera(theme=...)``."""
214
+ return sorted(_THEMES)
215
+
216
+
217
+ def get_theme(theme: str | Theme) -> Theme:
218
+ """
219
+ Resolve a theme name to a fresh :class:`Theme`; pass instances through.
220
+
221
+ Each call builds a new instance, so two cameras never share annotators.
222
+ """
223
+ if isinstance(theme, Theme):
224
+ return theme
225
+ if not isinstance(theme, str):
226
+ raise TypeError(f"theme must be a name or a Theme instance, got {type(theme).__name__}.")
227
+ try:
228
+ return _THEMES[theme.strip().lower()]()
229
+ except KeyError:
230
+ raise ValueError(
231
+ f"Unknown theme {theme!r}. Available: {', '.join(available_themes())}."
232
+ ) from None
@@ -0,0 +1,249 @@
1
+ Metadata-Version: 2.4
2
+ Name: cvflair
3
+ Version: 0.2.0
4
+ Summary: Theme-based, model-agnostic visualisation layer for computer vision detections, built on supervision.
5
+ Author: kbycode
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kbycode/cvflair
8
+ Project-URL: Documentation, https://github.com/kbycode/cvflair#readme
9
+ Project-URL: Repository, https://github.com/kbycode/cvflair
10
+ Project-URL: Issues, https://github.com/kbycode/cvflair/issues
11
+ Project-URL: Changelog, https://github.com/kbycode/cvflair/blob/main/CHANGELOG.md
12
+ Keywords: computer-vision,opencv,supervision,annotation,visualization,webcam,yolo,bounding-box,themes
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
25
+ Classifier: Topic :: Multimedia :: Video :: Display
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.24
30
+ Requires-Dist: opencv-python>=4.8
31
+ Requires-Dist: supervision<0.30,>=0.28
32
+ Provides-Extra: yolo
33
+ Requires-Dist: ultralytics>=8.2; extra == "yolo"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8.0; extra == "dev"
36
+ Requires-Dist: ruff>=0.6; extra == "dev"
37
+ Requires-Dist: pillow>=10.0; extra == "dev"
38
+ Provides-Extra: release
39
+ Requires-Dist: build>=1.2; extra == "release"
40
+ Requires-Dist: twine>=5.0; extra == "release"
41
+ Dynamic: license-file
42
+
43
+ # cvflair
44
+
45
+ Bilgisayarlı görü tespitlerini üç satırda, hazır temalarla ekrana çizen ince bir katman.
46
+
47
+ Çizim işini [supervision](https://github.com/roboflow/supervision) yapar; cvflair kamera
48
+ döngüsünü ve tema ayarlarını üstlenir. Model bağımsızdır: `supervision`'ın `Detections`
49
+ nesnesini üreten her kaynak (YOLO, MediaPipe, InsightFace veya özel bir model) tema
50
+ tarafından çizilebilir.
51
+
52
+ ![cvflair demo](https://raw.githubusercontent.com/kbycode/cvflair/main/docs/demo.gif)
53
+
54
+ *Aynı tespitler, üç tema. Animasyon `tools/make_demo_gif.py` ile üretildi — sentetik
55
+ sahne, kamera gerekmiyor.*
56
+
57
+ > **Durum:** Faz 1 tamam (kamera döngüsü, üç tema, testler), Faz 2 başladı (model
58
+ > bağlama). Paket henüz PyPI'da yayınlanmadı.
59
+
60
+ ## Kurulum
61
+
62
+ Python 3.10 veya üzeri gerekir (bu alt sınır `supervision`'dan geliyor).
63
+
64
+ ```bash
65
+ git clone https://github.com/kbycode/cvflair.git
66
+ cd cvflair
67
+ pip install -e .
68
+ ```
69
+
70
+ Yayınlandıktan sonra: `pip install cvflair`
71
+
72
+ YOLO ile kullanmak için Ultralytics extra'sı: `pip install -e ".[yolo]"`
73
+ (ayrıntı ve lisans notu için aşağıdaki [Lisans](#lisans) bölümü).
74
+
75
+ ## Hızlı başlangıç
76
+
77
+ ```python
78
+ from cvflair import Camera
79
+
80
+ cam = Camera(source=0, theme="neon")
81
+ for frame in cam.stream():
82
+ cam.show(frame)
83
+ ```
84
+
85
+ Kamera açılır, kareler ayrı bir thread'de okunur, pencere `q` veya ESC ile kapanır —
86
+ `release()` çağırmaya, `while True` kurmaya gerek yok.
87
+
88
+ > Model verilmeyen bu akışta ekranda ham kare görünür: tema ancak ortada tespit
89
+ > varken çizim yapar. Temayı modelsiz, canlı görmek için:
90
+ > `python examples/demo_fake_detections.py` — kamera görüntüsü üzerine hareketli sahte
91
+ > kutular çizer ve temaları 3 saniyede bir değiştirir.
92
+
93
+ ## Modelle kullanım
94
+
95
+ `stream()`'e bir model verildiğinde her adım `(kare, tespitler)` çifti döndürür ve
96
+ tema otomatik uygulanır:
97
+
98
+ ```python
99
+ from cvflair import Camera
100
+
101
+ cam = Camera(source=0, theme="neon")
102
+ for frame, detections in cam.stream(model="yolov8n.pt"):
103
+ cam.show(frame, detections)
104
+ ```
105
+
106
+ `model` üç şeyden biri olabilir:
107
+
108
+ | Değer | Anlamı |
109
+ |---|---|
110
+ | `"yolov8n.pt"` (ağırlık yolu) | Ultralytics ile yüklenir — `cvflair[yolo]` gerekir |
111
+ | Hazır bir Ultralytics modeli | `YOLO(...)` nesnesi doğrudan verilebilir, çıktısı dönüştürülür |
112
+ | Herhangi bir çağrılabilir | `sv.Detections` döndüren kendi fonksiyonun — MediaPipe, InsightFace, özel model |
113
+
114
+ Son seçenek kütüphaneyi model-agnostik yapan yer:
115
+
116
+ ```python
117
+ import supervision as sv
118
+ from cvflair import Camera
119
+
120
+ def detect(frame) -> sv.Detections:
121
+ ... # kendi modelin
122
+ return sv.Detections(xyxy=..., class_id=..., confidence=...)
123
+
124
+ cam = Camera(source=0, theme="pastel")
125
+ for frame, detections in cam.stream(model=detect):
126
+ cam.show(frame, detections)
127
+ ```
128
+
129
+ Çıkarım bu döngüde çalışır, okuma thread'inde değil: bir kare işlenirken okuyucu
130
+ kuyruktaki kareyi tazelemeye devam eder, dolayısıyla bir sonraki tur birikmiş
131
+ kareyle değil en güncel kareyle başlar.
132
+
133
+ Ultralytics'e ek ayar geçirmek için `UltralyticsDetector` doğrudan kullanılabilir:
134
+
135
+ ```python
136
+ from cvflair import Camera, UltralyticsDetector
137
+ from ultralytics import YOLO
138
+
139
+ detector = UltralyticsDetector(YOLO("yolov8n.pt"), conf=0.4, device="cpu", classes=[0])
140
+ cam = Camera(source=0, theme="neon")
141
+ for frame, detections in cam.stream(model=detector):
142
+ cam.show(frame, detections)
143
+ ```
144
+
145
+ Etiket metni doğrudan da verilebilir: `cam.show(frame, detections, labels=[...])`.
146
+ Pencere yönetimi uygulamaya aitse `cam.annotate(frame, detections)` yalnızca çizim yapar.
147
+
148
+ ## Temalar
149
+
150
+ | Tema | Görünüm | |
151
+ |---|---|---|
152
+ | `minimal` | ince beyaz çerçeve, sade etiket — ekran kaydı ve profesyonel demo için | ![minimal](https://raw.githubusercontent.com/kbycode/cvflair/main/docs/theme-minimal.png) |
153
+ | `neon` | sınıf başına canlı renk, yuvarlak köşe, koyu hâle ile parlama hissi | ![neon](https://raw.githubusercontent.com/kbycode/cvflair/main/docs/theme-neon.png) |
154
+ | `pastel` | yumuşak tonlar, geniş yuvarlama, koyu etiket yazısı — atölye/projeksiyon | ![pastel](https://raw.githubusercontent.com/kbycode/cvflair/main/docs/theme-pastel.png) |
155
+
156
+ Yol haritasındaki `cyberpunk` ve `hud` temaları Faz 2'de gelecek.
157
+
158
+ Özel bir tema, `Theme` doğrudan kurulup `Camera`'ya verilerek tanımlanır:
159
+
160
+ ```python
161
+ import supervision as sv
162
+ from cvflair import Camera, Theme
163
+
164
+ my_theme = Theme(
165
+ name="my-theme",
166
+ palette=sv.ColorPalette.from_hex(["#39FF14", "#FF00E5"]),
167
+ box_style="corner", # "box" | "round" | "corner"
168
+ thickness=2,
169
+ glow=True,
170
+ text_scale=0.6,
171
+ )
172
+ cam = Camera(source=0, theme=my_theme)
173
+ ```
174
+
175
+ Temaları görmenin iki yolu:
176
+
177
+ ```bash
178
+ python examples/demo_fake_detections.py # canlı kamera + hareketli sahte tespitler
179
+ python examples/theme_preview.py # kamerasız, her temayı bir PNG'ye çizer
180
+ ```
181
+
182
+ ## API özeti
183
+
184
+ | Üye | Ne yapar |
185
+ |---|---|
186
+ | `Camera(source, theme, width, height, fps, window_name, capture_factory)` | Kaynağı ve temayı bağlar; kamerayı henüz açmaz |
187
+ | `cam.start()` / `cam.close()` | Cihazı açar ve okuma thread'ini başlatır / her şeyi bırakır |
188
+ | `cam.stream(timeout, model=None)` | Kareleri üretir; model verilirse `(kare, tespitler)` çifti. İlk kullanımda `start()`, bitince `close()` eder |
189
+ | `cam.read(timeout)` | En güncel tek kareyi döndürür, kaynak bittiyse `None` |
190
+ | `cam.show(frame, detections, labels)` | Temayı uygular, pencerede gösterir; çıkış istendiğinde `False` döner |
191
+ | `cam.annotate(frame, detections, labels)` | Sadece çizer, pencere açmaz |
192
+ | `cam.theme` | Okunur/yazılır; `cam.theme = "minimal"` çalışır |
193
+ | `cam.frames_read` / `cam.frames_dropped` | Okunan ve tüketici yetişemediği için atılan kare sayısı |
194
+ | `get_theme(ad)` / `available_themes()` | Tema adını çözer / mevcut adları listeler |
195
+ | `UltralyticsDetector(model, **kwargs)` | Ultralytics çıktısını `sv.Detections`'a çevirir; `conf`, `iou`, `device` gibi ayarları taşır |
196
+ | `resolve_detector(model)` | Ağırlık yolu / model / çağrılabilir → detektör; `stream()` bunu kullanır |
197
+
198
+ `Camera` bağlam yöneticisi olarak da kullanılabilir: `with Camera() as cam: ...`
199
+
200
+ ## Nasıl çalışıyor
201
+
202
+ - **Kareler ayrı thread'de okunur.** Okuyucu, tüketiciyi beklemez.
203
+ - **Kuyruk tek slotlu.** Yeni kare gelince bekleyen eski kare düşürülür
204
+ (`frames_dropped` ile sayılır). Böylece işleme yavaşladığında gecikme birikmez;
205
+ ekranda hep en güncel kare olur.
206
+ - **Annotator'lar bir kere kurulur.** `Theme` nesnesi oluşturulurken `supervision`
207
+ annotator'ları hazırlanır ve her karede yeniden kullanılır — döngü içinde annotator
208
+ kurmak bu tür işlerde en sık görülen gereksiz maliyettir.
209
+ - **Çizim matematiği yeniden yazılmadı.** Her piksel `supervision` tarafından çiziliyor;
210
+ cvflair sadece yapılandırma ve akış katmanı.
211
+ - **Model paketin dışında.** `stream(model=...)` verilen şeyi bir çağrılabilire çevirir;
212
+ ağırlıklar ilk yinelemede yüklenir. Hiçbir model kodu veya ağırlığı pakete gömülü değil.
213
+
214
+ ## Geliştirme
215
+
216
+ ```bash
217
+ python -m venv .venv
218
+ .venv\Scripts\activate
219
+ pip install -e ".[dev]"
220
+ pytest
221
+ ruff check .
222
+ ```
223
+
224
+ Testler kamera gerektirmez: `Camera`'ya `capture_factory` üzerinden sahte bir
225
+ `VideoCapture` verilir, temalar da sentetik kareler üzerinde doğrulanır.
226
+
227
+ Dokümantasyon görselleri de kamerasız üretilir:
228
+
229
+ ```bash
230
+ python tools/make_demo_gif.py # docs/demo.gif
231
+ python examples/theme_preview.py # examples/output/theme-*.png
232
+ ```
233
+
234
+ ## Yol haritası
235
+
236
+ | Faz | İçerik | Durum |
237
+ |---|---|---|
238
+ | Faz 1 | Kamera döngüsü, `minimal`/`neon`/`pastel` temaları, README, demo GIF, testler | tamam |
239
+ | Faz 2 | Model bağlama (`stream(model=...)`) | tamam |
240
+ | Faz 2 | PyPI paketi, Türkçe dokümantasyon sitesi, tema playground, `cyberpunk`/`hud` | sırada |
241
+ | Faz 3 | GitHub Actions (lint + test), issue şablonları, örnek galerisi | planlandı |
242
+
243
+ ## Lisans
244
+
245
+ MIT — bkz. [LICENSE](LICENSE). Bağımlılıkların hepsi izin verici lisanslı
246
+ (`supervision` MIT, `opencv-python` Apache 2.0, `numpy` BSD).
247
+
248
+ YOLO ağırlıkları veya Ultralytics kodu bu pakete gömülü değildir; Ultralytics'in
249
+ kullanılması hâlinde AGPL-3.0 koşulları onu kullanan projenin sorumluluğundadır.
@@ -0,0 +1,10 @@
1
+ cvflair/__init__.py,sha256=JFOK3FPvkLiY-yM9De81mRCwdj_YYZho1ZPVHfQTbrM,805
2
+ cvflair/camera.py,sha256=Tpkwd98ecRFoBcsCKiomH-Z7n75GEVDenL60XN5oTbk,8984
3
+ cvflair/models.py,sha256=gfoUBFEsozY5l0NbJJ5XYo-GWhwX84CpLBbAHzF0x8Y,4731
4
+ cvflair/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cvflair/themes.py,sha256=r4EmV1DneFP5-c5ioccXdPoqM4YnSMf-Yw9y_DSb5c8,7650
6
+ cvflair-0.2.0.dist-info/licenses/LICENSE,sha256=0Tnxs_G4BvM4usmyYt-fIEogX96duNa_QmLJelQzb0w,1064
7
+ cvflair-0.2.0.dist-info/METADATA,sha256=LgVHZilD4jgaP-B3U_345z-pTTbx7N3ROFI1A8SniuY,10382
8
+ cvflair-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ cvflair-0.2.0.dist-info/top_level.txt,sha256=lPrkOHgpDfjoyb4I-sPatny-icHdTZjAtpb81IGQLXM,8
10
+ cvflair-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kbycode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cvflair