pixpick 0.1.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.
pixpick/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ pixpick
3
+ -------
4
+ Interactive coordinate picker for Computer Vision frameworks.
5
+
6
+ Quick start
7
+ -----------
8
+ import pixpick
9
+
10
+ # Box
11
+ region = pixpick.box("frame.jpg")
12
+ model.predict("frame.jpg", **region.yolo_region())
13
+ print(region.xyxy) # [x1, y1, x2, y2]
14
+ print(region.norm) # [0.12, 0.08, 0.64, 0.48]
15
+
16
+ # Polygon
17
+ zone = pixpick.polygon("frame.jpg")
18
+ sv.PolygonZone(**zone.to_supervision())
19
+ print(zone.points) # [(x0,y0), (x1,y1), ...]
20
+
21
+ # Save and reload either type
22
+ region.save("zone.json")
23
+ region = pixpick.load("zone.json")
24
+ """
25
+
26
+ from pixpick.selectors.box import BoxSelector
27
+ from pixpick.selectors.polygon import PolygonSelector, SelectionCancelled
28
+ from pixpick.core.selection import Box, Polygon
29
+ from pixpick.utils import ImageSource
30
+
31
+
32
+ def box(source: ImageSource, title: str = "pixpick") -> Box:
33
+ """
34
+ Open an interactive window on `source`, drag a rectangle, return a Box.
35
+
36
+ Parameters
37
+ ----------
38
+ source : str | Path | np.ndarray
39
+ Image file path or BGR numpy array.
40
+ title : str
41
+ Window title shown to the user.
42
+
43
+ Returns
44
+ -------
45
+ Box
46
+
47
+ Raises
48
+ ------
49
+ SelectionCancelled
50
+ If the user pressed Esc.
51
+ """
52
+ return BoxSelector().select(source, title=title)
53
+
54
+
55
+ def polygon(source: ImageSource, title: str = "pixpick") -> Polygon:
56
+ """
57
+ Open an interactive window on `source`, click vertices, return a Polygon.
58
+
59
+ Controls: LMB=add point RMB=undo last Enter=confirm Z=clear Esc=cancel
60
+
61
+ Parameters
62
+ ----------
63
+ source : str | Path | np.ndarray
64
+ Image file path or BGR numpy array.
65
+ title : str
66
+ Window title shown to the user.
67
+
68
+ Returns
69
+ -------
70
+ Polygon
71
+
72
+ Raises
73
+ ------
74
+ SelectionCancelled
75
+ If the user pressed Esc.
76
+ """
77
+ return PolygonSelector().select(source, title=title)
78
+
79
+
80
+ def load(path: str) -> Box | Polygon:
81
+ """
82
+ Load a previously saved selection from a JSON file.
83
+ Dispatches to Box.load or Polygon.load based on the 'type' field.
84
+ """
85
+ import json
86
+ from pathlib import Path
87
+ data = json.loads(Path(path).read_text())
88
+ sel_type = data.get("type")
89
+ if sel_type == "box":
90
+ return Box.load(path)
91
+ elif sel_type == "polygon":
92
+ return Polygon.load(path)
93
+ else:
94
+ raise ValueError(f"Unknown selection type in JSON: '{sel_type}'")
95
+
96
+
97
+ __all__ = [
98
+ "box",
99
+ "polygon",
100
+ "load",
101
+ "Box",
102
+ "Polygon",
103
+ "BoxSelector",
104
+ "PolygonSelector",
105
+ "SelectionCancelled",
106
+ ]
@@ -0,0 +1,46 @@
1
+ from abc import ABC, abstractmethod
2
+ import numpy as np
3
+
4
+
5
+ class BaseBackend(ABC):
6
+ """
7
+ A backend owns the UI layer — it opens a window (or widget),
8
+ captures user interactions, and returns raw pixel coordinates.
9
+
10
+ It knows nothing about Selection objects or adapters.
11
+ The selector calls the backend and wraps the raw result in the
12
+ appropriate Selection type.
13
+
14
+ Adding a new environment (Jupyter, Gradio, …) means adding a new
15
+ backend — zero changes to selectors or adapters.
16
+ """
17
+
18
+ @abstractmethod
19
+ def select_box(
20
+ self,
21
+ image: np.ndarray,
22
+ title: str = "pixpick",
23
+ ) -> tuple[int, int, int, int] | None:
24
+ """
25
+ Let the user drag a rectangle on the image.
26
+
27
+ Returns
28
+ -------
29
+ (x1, y1, x2, y2) in absolute pixels, or None if cancelled.
30
+ """
31
+ ...
32
+
33
+ @abstractmethod
34
+ def select_polygon(
35
+ self,
36
+ image: np.ndarray,
37
+ title: str = "pixpick",
38
+ ) -> list[tuple[int, int]] | None:
39
+ """
40
+ Let the user click polygon vertices on the image.
41
+
42
+ Returns
43
+ -------
44
+ List of (x, y) tuples (≥ 3 points), or None if cancelled.
45
+ """
46
+ ...
@@ -0,0 +1,269 @@
1
+ import cv2
2
+ import numpy as np
3
+ from pixpick.backends.base import BaseBackend
4
+
5
+
6
+ class CV2Backend(BaseBackend):
7
+ """
8
+ OpenCV imshow backend.
9
+
10
+ Controls
11
+ --------
12
+ Left-click + drag : draw the box
13
+ Enter / Space : confirm selection
14
+ R : reset and redraw
15
+ Esc : cancel — returns None
16
+
17
+ The live rubber-band rect is drawn on a scratch copy of the image
18
+ so the original is never mutated.
19
+ """
20
+
21
+ # Internal mouse state — kept on the instance so the callback has access.
22
+ _drawing: bool
23
+ _start: tuple[int, int]
24
+ _end: tuple[int, int]
25
+ _confirmed: bool
26
+ _cancelled: bool
27
+
28
+ def select_box(
29
+ self,
30
+ image: np.ndarray,
31
+ title: str = "pixpick | drag to select | Enter=confirm R=reset Esc=cancel",
32
+ ) -> tuple[int, int, int, int] | None:
33
+
34
+ self._reset_state()
35
+ canvas = image.copy() # scratch copy for live drawing
36
+
37
+ cv2.namedWindow(title, cv2.WINDOW_AUTOSIZE)
38
+ cv2.setMouseCallback(title, self._mouse_callback, param={"image": image, "title": title})
39
+ cv2.imshow(title, canvas)
40
+
41
+ while True:
42
+ # Redraw every frame so the rubber-band rect updates.
43
+ canvas = self._draw_rect(image)
44
+ cv2.imshow(title, canvas)
45
+
46
+ key = cv2.waitKey(20) & 0xFF
47
+
48
+ if self._cancelled or key == 27: # Esc
49
+ cv2.destroyWindow(title)
50
+ return None
51
+
52
+ # Backspace or Delete -> clear current selection
53
+ if key in (ord("z"), 8, 127):
54
+ self._reset_state()
55
+ continue
56
+
57
+ if key in (13, 32) or self._confirmed: # Enter / Space
58
+ if self._start == self._end:
59
+ # User hit confirm without completing a drag — ignore.
60
+ continue
61
+ cv2.destroyWindow(title)
62
+ x1 = min(self._start[0], self._end[0])
63
+ y1 = min(self._start[1], self._end[1])
64
+ x2 = max(self._start[0], self._end[0])
65
+ y2 = max(self._start[1], self._end[1])
66
+ return x1, y1, x2, y2
67
+
68
+
69
+ def select_polygon(
70
+ self,
71
+ image: np.ndarray,
72
+ title: str = "pixpick | polygon | LMB=add RMB=undo Enter=confirm Z=clear Esc=cancel",
73
+ ) -> list[tuple[int, int]] | None:
74
+
75
+ self._points = []
76
+
77
+ cv2.namedWindow(title, cv2.WINDOW_AUTOSIZE)
78
+ cv2.setMouseCallback(title, self._polygon_callback)
79
+
80
+ while True:
81
+ canvas = self._draw_polygon(image)
82
+ cv2.imshow(title, canvas)
83
+
84
+ key = cv2.waitKey(20) & 0xFF
85
+
86
+ # Esc
87
+ if key == 27:
88
+ cv2.destroyWindow(title)
89
+ return None
90
+
91
+ # Z / Backspace / Delete
92
+ if key in (ord("z"), 8, 127):
93
+ self._points.clear()
94
+ continue
95
+
96
+ # Enter / Space
97
+ if key in (13, 32):
98
+
99
+ if len(self._points) < 3:
100
+ continue
101
+
102
+ cv2.destroyWindow(title)
103
+
104
+ return self._points
105
+
106
+
107
+ # ------------------------------------------------------------------ #
108
+ # Mouse callback #
109
+ # ------------------------------------------------------------------ #
110
+
111
+ def _mouse_callback(self, event: int, x: int, y: int, flags: int, param: dict) -> None:
112
+ if event == cv2.EVENT_LBUTTONDOWN:
113
+ self._drawing = True
114
+ self._start = (x, y)
115
+ self._end = (x, y)
116
+
117
+ elif event == cv2.EVENT_MOUSEMOVE and self._drawing:
118
+ self._end = (x, y)
119
+
120
+ elif event == cv2.EVENT_LBUTTONUP:
121
+ self._drawing = False
122
+ self._end = (x, y)
123
+ # self._confirmed = True # auto-confirm on mouse release
124
+
125
+
126
+ def _polygon_callback(
127
+ self,
128
+ event: int,
129
+ x: int,
130
+ y: int,
131
+ flags: int,
132
+ param,
133
+ ) -> None:
134
+
135
+ # Add point
136
+ if event == cv2.EVENT_LBUTTONDOWN:
137
+ self._points.append((x, y))
138
+
139
+ # Remove last point
140
+ elif event == cv2.EVENT_RBUTTONDOWN:
141
+ if self._points:
142
+ self._points.pop()
143
+
144
+
145
+ # ------------------------------------------------------------------ #
146
+ # Helpers #
147
+ # ------------------------------------------------------------------ #
148
+
149
+ def _reset_state(self) -> None:
150
+ self._drawing = False
151
+ self._start = (0, 0)
152
+ self._end = (0, 0)
153
+ self._confirmed = False
154
+ self._cancelled = False
155
+ self._points = []
156
+
157
+ def _draw_rect(self, image: np.ndarray) -> np.ndarray:
158
+ """Return a copy of image with the current rubber-band rect drawn."""
159
+ canvas = image.copy()
160
+ if self._start != self._end:
161
+ # Semi-transparent fill
162
+ overlay = canvas.copy()
163
+ cv2.rectangle(overlay, self._start, self._end, (0, 255, 0), -1)
164
+ cv2.addWeighted(overlay, 0.15, canvas, 0.85, 0, canvas)
165
+ # Solid border
166
+ cv2.rectangle(canvas, self._start, self._end, (0, 255, 0), 2)
167
+ # Corner coords label
168
+ x1 = min(self._start[0], self._end[0])
169
+ y1 = min(self._start[1], self._end[1])
170
+ x2 = max(self._start[0], self._end[0])
171
+ y2 = max(self._start[1], self._end[1])
172
+ label = f"({x1},{y1}) ({x2},{y2}) w={x2-x1} h={y2-y1}"
173
+ cv2.putText(canvas, label, (x1, max(y1 - 8, 12)),
174
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
175
+ return canvas
176
+
177
+
178
+ def _draw_polygon(self, image: np.ndarray) -> np.ndarray:
179
+ """
180
+ Draw current polygon preview.
181
+ """
182
+
183
+ canvas = image.copy()
184
+
185
+ if not self._points:
186
+ return canvas
187
+
188
+ # Draw filled polygon
189
+ if len(self._points) >= 3:
190
+
191
+ overlay = canvas.copy()
192
+
193
+ pts = np.array(self._points, dtype=np.int32)
194
+
195
+ cv2.fillPoly(
196
+ overlay,
197
+ [pts],
198
+ (0, 255, 0),
199
+ )
200
+
201
+ cv2.addWeighted(
202
+ overlay,
203
+ 0.15,
204
+ canvas,
205
+ 0.85,
206
+ 0,
207
+ canvas,
208
+ )
209
+
210
+ # Draw edges
211
+ if len(self._points) > 1:
212
+
213
+ pts = np.array(self._points, dtype=np.int32)
214
+
215
+ cv2.polylines(
216
+ canvas,
217
+ [pts],
218
+ isClosed=False,
219
+ color=(0, 255, 0),
220
+ thickness=2,
221
+ )
222
+
223
+ # Preview closing edge
224
+ if len(self._points) >= 3:
225
+
226
+ cv2.line(
227
+ canvas,
228
+ self._points[-1],
229
+ self._points[0],
230
+ (0, 255, 0),
231
+ 1,
232
+ cv2.LINE_AA,
233
+ )
234
+
235
+ # Draw vertices + indices
236
+ for idx, pt in enumerate(self._points):
237
+
238
+ cv2.circle(
239
+ canvas,
240
+ pt,
241
+ 4,
242
+ (0, 255, 0),
243
+ -1,
244
+ )
245
+
246
+ cv2.putText(
247
+ canvas,
248
+ str(idx),
249
+ (pt[0] + 5, pt[1] - 5),
250
+ cv2.FONT_HERSHEY_SIMPLEX,
251
+ 0.5,
252
+ (0, 255, 0),
253
+ 1,
254
+ cv2.LINE_AA,
255
+ )
256
+
257
+ # Info text
258
+ cv2.putText(
259
+ canvas,
260
+ f"Points: {len(self._points)}",
261
+ (10, 25),
262
+ cv2.FONT_HERSHEY_SIMPLEX,
263
+ 0.7,
264
+ (0, 255, 0),
265
+ 2,
266
+ cv2.LINE_AA,
267
+ )
268
+
269
+ return canvas
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+ import cv2
3
+ import json
4
+ import numpy as np
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ # ======================================================================== #
14
+ # Box #
15
+ # ======================================================================== #
16
+
17
+ @dataclass
18
+ class Box:
19
+ """
20
+ Immutable result of a box selection.
21
+
22
+ All coordinate math lives here.
23
+ Adapters are thin — they just re-package what this class already holds.
24
+
25
+ Attributes
26
+ ----------
27
+ x1, y1, x2, y2 : int
28
+ Absolute pixel coordinates (top-left → bottom-right).
29
+ image_width, image_height : int
30
+ Dimensions of the source image — needed for normalisation.
31
+ """
32
+
33
+ x1: int
34
+ y1: int
35
+ x2: int
36
+ y2: int
37
+ image_width: int
38
+ image_height: int
39
+
40
+ # ------------------------------------------------------------------ #
41
+ # Validation #
42
+ # ------------------------------------------------------------------ #
43
+
44
+ def __post_init__(self):
45
+ # Normalise so x1 < x2 and y1 < y2 regardless of drag direction.
46
+ self.x1, self.x2 = sorted([self.x1, self.x2])
47
+ self.y1, self.y2 = sorted([self.y1, self.y2])
48
+
49
+ if self.x1 == self.x2 or self.y1 == self.y2:
50
+ raise ValueError("Box has zero area — did the drag complete?")
51
+
52
+ if not (0 <= self.x1 < self.x2 <= self.image_width):
53
+ raise ValueError(
54
+ f"x coords out of bounds: {self.x1}, {self.x2} (image width={self.image_width})"
55
+ )
56
+ if not (0 <= self.y1 < self.y2 <= self.image_height):
57
+ raise ValueError(
58
+ f"y coords out of bounds: {self.y1}, {self.y2} (image height={self.image_height})"
59
+ )
60
+
61
+ # ------------------------------------------------------------------ #
62
+ # Core format properties #
63
+ # ------------------------------------------------------------------ #
64
+
65
+ @property
66
+ def xyxy(self) -> list[int]:
67
+ """[x1, y1, x2, y2] — absolute pixels."""
68
+ return [self.x1, self.y1, self.x2, self.y2]
69
+
70
+ @property
71
+ def xywh(self) -> list[int]:
72
+ """[x, y, width, height] — top-left origin, absolute pixels."""
73
+ return [self.x1, self.y1, self.x2 - self.x1, self.y2 - self.y1]
74
+
75
+ @property
76
+ def cxcywh(self) -> list[float]:
77
+ """[cx, cy, width, height] — centre + size, absolute pixels."""
78
+ w, h = self.x2 - self.x1, self.y2 - self.y1
79
+ return [self.x1 + w / 2, self.y1 + h / 2, float(w), float(h)]
80
+
81
+ @property
82
+ def norm(self) -> list[float]:
83
+ """[x1, y1, x2, y2] — values in [0, 1]."""
84
+ return [
85
+ self.x1 / self.image_width,
86
+ self.y1 / self.image_height,
87
+ self.x2 / self.image_width,
88
+ self.y2 / self.image_height,
89
+ ]
90
+
91
+ @property
92
+ def norm_xywh(self) -> list[float]:
93
+ """[x, y, w, h] normalised — YOLO label format."""
94
+ x1n, y1n, x2n, y2n = self.norm
95
+ return [x1n, y1n, x2n - x1n, y2n - y1n]
96
+
97
+ @property
98
+ def center(self) -> tuple[int, int]:
99
+ """(cx, cy) in absolute pixels."""
100
+ return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2
101
+
102
+ @property
103
+ def area(self) -> int:
104
+ """Area in pixels²."""
105
+ return (self.x2 - self.x1) * (self.y2 - self.y1)
106
+
107
+ @property
108
+ def as_numpy(self) -> np.ndarray:
109
+ """Shape (4,) int32 array — [x1, y1, x2, y2]."""
110
+ return np.array(self.xyxy, dtype=np.int32)
111
+
112
+ # ------------------------------------------------------------------ #
113
+ # Adapter shortcuts #
114
+ # ------------------------------------------------------------------ #
115
+
116
+ def yolo_region(self) -> list[float]:
117
+ """[(point1), (point2), (point3), (point4)] """
118
+ return [
119
+ (self.x1, self.y1),
120
+ (self.x2, self.y1),
121
+ (self.x2, self.y2),
122
+ (self.x1, self.y2)
123
+ ]
124
+
125
+ def yolo_prompt(self) -> np.ndarray:
126
+ """[(point1), (point2), (point3), (point4)] """
127
+ return np.array([
128
+ [self.x1, self.y1, self.x2, self.y2]
129
+ ])
130
+
131
+ def sam(self) -> np.ndarray:
132
+ """[(point1), (point2), (point3), (point4)] """
133
+ return np.array([self.x1, self.y1, self.x2, self.y2])
134
+
135
+ def raw(self) -> dict:
136
+ """All formats at once — handy for debugging."""
137
+ return {
138
+ "xyxy": self.xyxy,
139
+ "xywh": self.xywh,
140
+ "cxcywh": self.cxcywh,
141
+ "normalized": self.norm,
142
+ "normalized_xywh": self.norm_xywh,
143
+ "numpy": self.as_numpy.tolist(),
144
+ }
145
+
146
+ # ------------------------------------------------------------------ #
147
+ # Persistence #
148
+ # ------------------------------------------------------------------ #
149
+
150
+ def save(self, path: str | Path) -> None:
151
+ """Serialise to JSON."""
152
+ data = {
153
+ "type": "box",
154
+ "image_size": [self.image_width, self.image_height],
155
+ "coordinates": {
156
+ "xyxy": self.xyxy,
157
+ "xywh": self.xywh,
158
+ "normalized": self.norm,
159
+ },
160
+ }
161
+ Path(path).write_text(json.dumps(data, indent=2))
162
+
163
+ @classmethod
164
+ def load(cls, path: str | Path) -> "Box":
165
+ """Reconstruct from a saved JSON file."""
166
+ data = json.loads(Path(path).read_text())
167
+ if data["type"] != "box":
168
+ raise ValueError(f"Expected type 'box', got '{data['type']}'")
169
+ w, h = data["image_size"]
170
+ x1, y1, x2, y2 = data["coordinates"]["xyxy"]
171
+ return cls(x1=x1, y1=y1, x2=x2, y2=y2, image_width=w, image_height=h)
172
+
173
+ # ------------------------------------------------------------------ #
174
+ # Visualisation #
175
+ # ------------------------------------------------------------------ #
176
+
177
+ def visualize(
178
+ self,
179
+ image: np.ndarray,
180
+ color: tuple = (0, 255, 0),
181
+ thickness: int = 2,
182
+ ) -> np.ndarray:
183
+ """Draw the box on a copy of image and return it."""
184
+ canvas = image.copy()
185
+ cv2.rectangle(canvas, (self.x1, self.y1), (self.x2, self.y2), color, thickness)
186
+ label = f"({self.x1},{self.y1}) -> ({self.x2},{self.y2})"
187
+ cv2.putText(
188
+ canvas, label, (self.x1, max(self.y1 - 8, 12)),
189
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA,
190
+ )
191
+ return canvas
192
+
193
+ def __repr__(self) -> str:
194
+ return (
195
+ f"Box(xyxy={self.xyxy}, "
196
+ f"size={self.image_width}x{self.image_height}, "
197
+ f"area={self.area}px²)"
198
+ )
199
+
200
+
201
+ # ======================================================================== #
202
+ # Polygon #
203
+ # ======================================================================== #
204
+
205
+ @dataclass
206
+ class Polygon:
207
+ """
208
+ Immutable result of a polygon selection.
209
+
210
+ Attributes
211
+ ----------
212
+ points : list[tuple[int, int]]
213
+ Ordered list of (x, y) vertices in absolute pixels.
214
+ image_width, image_height : int
215
+ Dimensions of the source image — needed for normalisation.
216
+ """
217
+
218
+ points: list[tuple[int, int]]
219
+ image_width: int
220
+ image_height: int
221
+
222
+ # ------------------------------------------------------------------ #
223
+ # Validation #
224
+ # ------------------------------------------------------------------ #
225
+
226
+ def __post_init__(self):
227
+ if len(self.points) < 3:
228
+ raise ValueError(
229
+ f"Polygon needs at least 3 points, got {len(self.points)}"
230
+ )
231
+ for i, pt in enumerate(self.points):
232
+ x, y = pt
233
+ if not (0 <= x <= self.image_width and 0 <= y <= self.image_height):
234
+ raise ValueError(
235
+ f"Point {i} ({x},{y}) is outside image "
236
+ f"({self.image_width}x{self.image_height})"
237
+ )
238
+
239
+ # ------------------------------------------------------------------ #
240
+ # Core format properties #
241
+ # ------------------------------------------------------------------ #
242
+
243
+ @property
244
+ def as_numpy(self) -> np.ndarray:
245
+ """Shape (N, 2) int32 array — [[x0,y0], [x1,y1], ...]."""
246
+ return np.array(self.points, dtype=np.int32)
247
+
248
+ @property
249
+ def norm(self) -> list[tuple[float, float]]:
250
+ """Points normalised to [0, 1]."""
251
+ return [
252
+ (x / self.image_width, y / self.image_height)
253
+ for x, y in self.points
254
+ ]
255
+
256
+ @property
257
+ def norm_numpy(self) -> np.ndarray:
258
+ """Shape (N, 2) float32 array of normalised points."""
259
+ return np.array(self.norm, dtype=np.float32)
260
+
261
+ @property
262
+ def bbox(self) -> Box:
263
+ """Tight axis-aligned Box that encloses this polygon."""
264
+ xs = [p[0] for p in self.points]
265
+ ys = [p[1] for p in self.points]
266
+ x1, y1, x2, y2 = min(xs), min(ys), max(xs), max(ys)
267
+ xyxy = [x1, y1, x2, y2]
268
+ return xyxy
269
+
270
+ @property
271
+ def npoints(self) -> int:
272
+ return len(self.points)
273
+
274
+ # ------------------------------------------------------------------ #
275
+ # Adapter shortcuts #
276
+ # ------------------------------------------------------------------ #
277
+
278
+ def supervision(self) -> dict:
279
+ """
280
+ Ready to unpack into sv.PolygonZone().
281
+
282
+ Usage
283
+ -----
284
+ zone = sv.PolygonZone(**polygon.to_supervision())
285
+ """
286
+ return {"polygon": self.as_numpy}
287
+
288
+ def raw(self) -> dict:
289
+ """All formats at once."""
290
+ return {
291
+ "points": self.points,
292
+ "numpy": self.as_numpy.tolist(),
293
+ "normalized": self.norm,
294
+ "normalized_numpy": self.norm_numpy.tolist(),
295
+ "bbox_xyxy": self.bbox,
296
+ }
297
+
298
+ # ------------------------------------------------------------------ #
299
+ # Persistence #
300
+ # ------------------------------------------------------------------ #
301
+
302
+ def save(self, path: str | Path) -> None:
303
+ """Serialise to JSON."""
304
+ data = {
305
+ "type": "polygon",
306
+ "image_size": [self.image_width, self.image_height],
307
+ "coordinates": {
308
+ "points": self.points,
309
+ "normalized": self.norm,
310
+ },
311
+ }
312
+ Path(path).write_text(json.dumps(data, indent=2))
313
+
314
+ @classmethod
315
+ def load(cls, path: str | Path) -> "Polygon":
316
+ """Reconstruct from a saved JSON file."""
317
+ data = json.loads(Path(path).read_text())
318
+ if data["type"] != "polygon":
319
+ raise ValueError(f"Expected type 'polygon', got '{data['type']}'")
320
+ w, h = data["image_size"]
321
+ points = [tuple(p) for p in data["coordinates"]["points"]]
322
+ return cls(points=points, image_width=w, image_height=h)
323
+
324
+ # ------------------------------------------------------------------ #
325
+ # Visualisation #
326
+ # ------------------------------------------------------------------ #
327
+
328
+ def visualize(
329
+ self,
330
+ image: np.ndarray,
331
+ color: tuple = (0, 255, 0),
332
+ thickness: int = 2,
333
+ fill_alpha: float = 0.15,
334
+ ) -> np.ndarray:
335
+ """Draw the polygon (filled + border + vertex dots) on a copy of image."""
336
+ canvas = image.copy()
337
+ pts = self.as_numpy.reshape((-1, 1, 2))
338
+
339
+ # Semi-transparent fill
340
+ if fill_alpha > 0:
341
+ overlay = canvas.copy()
342
+ cv2.fillPoly(overlay, [self.as_numpy], color)
343
+ cv2.addWeighted(overlay, fill_alpha, canvas, 1 - fill_alpha, 0, canvas)
344
+
345
+ # Border
346
+ cv2.polylines(canvas, [pts], isClosed=True, color=color, thickness=thickness)
347
+
348
+ # Vertex dots + index labels
349
+ for i, (x, y) in enumerate(self.points):
350
+ cv2.circle(canvas, (x, y), 4, color, -1)
351
+ cv2.putText(
352
+ canvas, str(i), (x + 5, y - 5),
353
+ cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA,
354
+ )
355
+
356
+ return canvas
357
+
358
+ def __repr__(self) -> str:
359
+ return (
360
+ f"Polygon(npoints={self.npoints}, "
361
+ f"size={self.image_width}x{self.image_height})"
362
+ )
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+ from pixpick.backends.base import BaseBackend
4
+ from pixpick.backends.cv2_backend import CV2Backend
5
+ from pixpick.core.selection import Box
6
+ from pixpick.utils import load_image, image_size, ImageSource
7
+
8
+
9
+ class BoxSelector:
10
+ """
11
+ Orchestrates: load image → open backend → capture drag → return Box.
12
+
13
+ This is the only class that knows about both the backend and the
14
+ Box result object. Backends know nothing about Box; Box knows nothing
15
+ about backends. BoxSelector is the glue.
16
+
17
+ Parameters
18
+ ----------
19
+ backend : BaseBackend | None
20
+ Pass a backend instance to override auto-detection.
21
+ None → CV2Backend (the only backend implemented in v0.1).
22
+ """
23
+
24
+ def __init__(self, backend: BaseBackend | None = None):
25
+ self.backend = backend or CV2Backend()
26
+
27
+
28
+ def select(self, source: ImageSource,
29
+ title: str = "pixpick | drag to select | Enter=confirm | Backspace=clear | Esc=cancel") -> Box:
30
+ """
31
+ Open an interactive window on `source` and return a Box.
32
+
33
+ Parameters
34
+ ----------
35
+ source : str | Path | np.ndarray
36
+ File path or BGR numpy array.
37
+ title : str
38
+ Window title.
39
+
40
+ Returns
41
+ -------
42
+ Box
43
+ A fully populated Box with all format properties and adapter methods.
44
+
45
+ Raises
46
+ ------
47
+ SelectionCancelled
48
+ If the user pressed Esc or closed the window.
49
+ """
50
+ print(f"Opening interactive selection window for: {source}")
51
+ image = load_image(source)
52
+ w, h = image_size(image)
53
+
54
+ raw = self.backend.select_box(image, title=title)
55
+
56
+ if raw is None:
57
+ raise SelectionCancelled("Box selection was cancelled by the user.")
58
+
59
+ x1, y1, x2, y2 = raw
60
+ return Box(x1=x1, y1=y1, x2=x2, y2=y2, image_width=w, image_height=h)
61
+
62
+
63
+ class SelectionCancelled(Exception):
64
+ """Raised when the user cancels an interactive selection (Esc)."""
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+ from pixpick.backends.base import BaseBackend
4
+ from pixpick.backends.cv2_backend import CV2Backend
5
+ from pixpick.core.selection import Polygon
6
+ from pixpick.utils import load_image, image_size, ImageSource
7
+
8
+
9
+ class PolygonSelector:
10
+ """
11
+ Orchestrates: load image → open backend → capture clicks → return Polygon.
12
+
13
+ Mirrors BoxSelector exactly — same pattern, different backend method
14
+ and different Selection type returned.
15
+
16
+ Parameters
17
+ ----------
18
+ backend : BaseBackend | None
19
+ Pass a backend instance to override auto-detection.
20
+ None → CV2Backend.
21
+ """
22
+
23
+ def __init__(self, backend: BaseBackend | None = None):
24
+ self.backend = backend or CV2Backend()
25
+
26
+ def select(self, source: ImageSource, title: str = "pixpick") -> Polygon:
27
+ """
28
+ Open an interactive window on `source`, let the user click polygon
29
+ vertices, and return a Polygon.
30
+
31
+ Parameters
32
+ ----------
33
+ source : str | Path | np.ndarray
34
+ Image file path or BGR numpy array.
35
+ title : str
36
+ Window title.
37
+
38
+ Returns
39
+ -------
40
+ Polygon
41
+
42
+ Raises
43
+ ------
44
+ SelectionCancelled
45
+ If the user pressed Esc or closed the window.
46
+ """
47
+ image = load_image(source)
48
+ w, h = image_size(image)
49
+
50
+ raw = self.backend.select_polygon(image, title=title)
51
+
52
+ if raw is None:
53
+ raise SelectionCancelled("Polygon selection was cancelled by the user.")
54
+
55
+ # raw is list[tuple[int, int]] coming straight from the backend
56
+ return Polygon(points=raw, image_width=w, image_height=h)
57
+
58
+
59
+ class SelectionCancelled(Exception):
60
+ """Raised when the user cancels an interactive selection (Esc)."""
pixpick/utils.py ADDED
@@ -0,0 +1,33 @@
1
+ import numpy as np
2
+ import cv2
3
+ from pathlib import Path
4
+ from typing import Union
5
+
6
+ ImageSource = Union[str, Path, np.ndarray]
7
+
8
+
9
+ def load_image(source: ImageSource) -> np.ndarray:
10
+ """
11
+ Accept a file path (str / Path) or a raw numpy array.
12
+ Always returns a BGR uint8 np.ndarray, which is what cv2 expects.
13
+ """
14
+ if isinstance(source, np.ndarray):
15
+ if source.ndim != 3 or source.shape[2] != 3:
16
+ raise ValueError(f"Array must be (H, W, 3) BGR, got shape {source.shape}")
17
+ return source.copy()
18
+
19
+ path = Path(source)
20
+ if not path.exists():
21
+ raise FileNotFoundError(f"Image not found: {path}")
22
+
23
+ img = cv2.imread(str(path))
24
+ if img is None:
25
+ raise ValueError(f"cv2 could not read image: {path}")
26
+
27
+ return img
28
+
29
+
30
+ def image_size(img: np.ndarray) -> tuple[int, int]:
31
+ """Return (width, height) from a cv2 BGR array."""
32
+ h, w = img.shape[:2]
33
+ return w, h
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: pixpick
3
+ Version: 0.1.0
4
+ Summary: Interactive coordinate picker for Computer Vision frameworks
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/K-saif/pixpick
7
+ Project-URL: Documentation, https://github.com/K-saif/pixpick/tree/main/docs
8
+ Project-URL: Bug Tracker, https://github.com/K-saif/pixpick/issues
9
+ Keywords: computer-vision,yolo,sam2,supervision,roi,annotation
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: opencv-python>=4.5
19
+ Requires-Dist: numpy>=1.21
20
+ Dynamic: license-file
21
+
22
+ <div align="center">
23
+
24
+ # pixpick 🎯
25
+
26
+ **Interactive coordinate picker for Computer Vision — no external tools needed.**
27
+
28
+ [![PyPI version](https://badge.fury.io/py/pixpick.svg)](https://badge.fury.io/py/pixpick)
29
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
+
32
+ </div>
33
+
34
+ ---
35
+
36
+ ## The problem
37
+
38
+ Every major CV framework needs coordinates before it can run.
39
+
40
+ ```python
41
+ regioncounter = RegionCounter(region=[120, 80, 640, 480]) # YOLO — where does this region come from?
42
+ predictor.predict(box=np.array([120, 80, 640, 480])) # SAM2 — same problem
43
+ ```
44
+
45
+ The standard workflow: open CVAT or Roboflow → grab coordinates → paste them back into code. Every. Single. Time.
46
+
47
+ ## The fix
48
+
49
+ ```python
50
+ import pixpick
51
+
52
+ region = pixpick.box("frame.jpg") # drag a box on the image
53
+ zone = pixpick.polygon("frame.jpg") # click polygon vertices
54
+
55
+ # coordinates are ready — unpack directly into any framework
56
+ # YOLO:
57
+ regioncounter = RegionCounter(
58
+ region=region.yolo_region, # pass region points
59
+ model="yolo26n.pt",
60
+ )
61
+
62
+ # same for YOLOE
63
+ model.predict("frame.jpg", visual_prompt= region.yolo_prompt())
64
+
65
+ # SAM1/SAM2:
66
+ predictor.predict(box=region.sam())
67
+ ```
68
+
69
+ A window opens on your image. You interact. You get framework-ready coordinates back in Python. No round-trips.
70
+
71
+ ---
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install pixpick
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Selectors
82
+
83
+ | Selector | How to use | Returns |
84
+ |---|---|---|
85
+ | `pixpick.box()` | Left-click + drag | `Box` |
86
+ | `pixpick.polygon()` | Click vertices → `Enter` to confirm | `Polygon` |
87
+
88
+ **Box controls** — `drag` to draw · `R` to reset · `Esc` to cancel
89
+
90
+ **Polygon controls** — `LMB` add point · `RMB` undo · `Z` clear · `Enter` confirm · `Esc` cancel
91
+
92
+ ---
93
+
94
+ ## Output formats
95
+
96
+ Every selection object carries all the formats you'll ever need.
97
+
98
+ ```python
99
+ # ── Box ──────────────────────────────────────────────────────
100
+ region = pixpick.box("frame.jpg")
101
+
102
+ region.xyxy # [x1, y1, x2, y2] absolute pixels
103
+ region.xywh # [x, y, w, h] absolute pixels
104
+ region.norm_xywh # [x, y, w, h] 0.0 – 1.0 ← YOLO label format
105
+ region.center # (cx, cy)
106
+ region.area # pixels²
107
+
108
+
109
+ # ── Polygon ───────────────────────────────────────────────────
110
+ zone = pixpick.polygon("frame.jpg")
111
+
112
+ zone.points # [(x0,y0), (x1,y1), ...] absolute pixels
113
+ zone.as_numpy # np.array shape (N, 2)
114
+ zone.norm # [(x0n,y0n), ...] 0.0 – 1.0
115
+ zone.bbox # → Box tight bbox around the polygon
116
+ zone.npoints # int
117
+ ```
118
+ For more details, see [Selectors](docs/selectors.md).
119
+ ---
120
+
121
+ ## Framework integration
122
+
123
+ | Framework | Selector | Method |
124
+ |---|---|---|
125
+ | Ultralytics YOLOE — visual prompt | `Box` | `region.yolo_prompt()` |
126
+ | Ultralytics YOLO — region | `Box` | `region.yolo_region()` |
127
+ | SAM / SAM2 — box prompt | `Box` | `region.sam()` |
128
+ | Any other format | `Box` / `Polygon` | `region.to_raw()` |
129
+
130
+ ---
131
+
132
+ ## Persistence
133
+
134
+ Pick once, reuse forever.
135
+
136
+ ```python
137
+ region.save("zone.json")
138
+ region = pixpick.load("zone.json") # Box and Polygon both work
139
+ ```
140
+
141
+ Production pattern — pick interactively the first time, load on every subsequent run:
142
+
143
+ ```python
144
+ from pathlib import Path
145
+ import pixpick
146
+
147
+ ZONE = "config/count_zone.json"
148
+
149
+ zone = pixpick.load(ZONE) if Path(ZONE).exists() else pixpick.polygon("frame.jpg")
150
+ zone.save(ZONE)
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Docs
156
+
157
+ | | |
158
+ |---|---|
159
+ | 🚀 [Getting Started](docs/getting-started.md) | Installation, first selection, controls |
160
+ | 🎯 [Selectors](docs/selectors.md) | All properties and methods for Box and Polygon |
161
+ | 🔌 [Framework Integration](docs/frameworks.md) | YOLO, SAM2 and more |
162
+ | 💾 [Persistence](docs/persistence.md) | Save, load, JSON schema |
163
+ | 🏗️ [Architecture](docs/architecture.md) | How it's built and how to extend it |
164
+ | 🗺️ [Roadmap](docs/roadmap.md) | What's coming next |
@@ -0,0 +1,12 @@
1
+ pixpick/__init__.py,sha256=4SGFAS950rNiToQv6NTIc4EOLn87juU0jI4iuJ0jiPw,2537
2
+ pixpick/utils.py,sha256=YJo4fNMHDrF-Sox6q4VBN4j1VdprD6RqlwyTZb0STxk,920
3
+ pixpick/backends/base.py,sha256=wyWVQQvDqw1D7LufLp8orW6F97GZmVEYsup1TrOcTio,1212
4
+ pixpick/backends/cv2_backend.py,sha256=4PafUoRO6tksxHK1PTvXJ_OeOi3x9Lqha2ogmylwGO4,7740
5
+ pixpick/core/selection.py,sha256=Nh540mEN5ocrgJI7o-uyRxry5tH0e6rR11om8H3FZtI,12800
6
+ pixpick/selectors/box.py,sha256=JMbZr1t6jAd3CrAOsE_SkRDRTTPzEJujMn-kqZPG3-I,2038
7
+ pixpick/selectors/polygon.py,sha256=5b7vevc6X6wmyK9IFlO9cPbWpu8T18Rw-M1mnM4kMkg,1810
8
+ pixpick-0.1.0.dist-info/licenses/LICENSE,sha256=7xVUULIVw6C8NnD9Eq6htNix3pST38jkkvD5lQq44bA,1066
9
+ pixpick-0.1.0.dist-info/METADATA,sha256=LRdO8yHwKyzn0bnLAW1ZzsZUK0s_kIS-lCAxev1qd5E,5155
10
+ pixpick-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ pixpick-0.1.0.dist-info/top_level.txt,sha256=EGQWHYVK7lv1e_xz0LMcF_GANTdu3RlDxQbaOo_hz_k,8
12
+ pixpick-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saif Khan
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
+ pixpick