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 +106 -0
- pixpick/backends/base.py +46 -0
- pixpick/backends/cv2_backend.py +269 -0
- pixpick/core/selection.py +362 -0
- pixpick/selectors/box.py +64 -0
- pixpick/selectors/polygon.py +60 -0
- pixpick/utils.py +33 -0
- pixpick-0.1.0.dist-info/METADATA +164 -0
- pixpick-0.1.0.dist-info/RECORD +12 -0
- pixpick-0.1.0.dist-info/WHEEL +5 -0
- pixpick-0.1.0.dist-info/licenses/LICENSE +21 -0
- pixpick-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
pixpick/backends/base.py
ADDED
|
@@ -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
|
+
)
|
pixpick/selectors/box.py
ADDED
|
@@ -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
|
+
[](https://badge.fury.io/py/pixpick)
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
[](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,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
|