-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathframe_annotator.py
More file actions
284 lines (241 loc) · 10.2 KB
/
frame_annotator.py
File metadata and controls
284 lines (241 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""
frame_annotator.py — Interactively annotate ball positions in pitch frames.
Click to mark the ball center in each frame. Press [N] to mark "not visible".
Saves annotations in a deterministic JSON schema compatible with
export_yolo.py and validate_tracking.py.
Usage
-----
python frame_annotator.py pitches/20260301_030216
python frame_annotator.py pitches/20260301_030216 -o custom_annotations.json
Controls
--------
Left-click = Mark ball center at cursor position
N = Mark ball as NOT visible in this frame
B / Left = Go back one frame (undo last annotation for review)
S = Save annotations to disk (auto-saves on completion)
Q / ESC = Quit (prompts to save if unsaved changes)
R = Remove annotation for current frame and re-annotate
Schema (annotations.json)
-------------------------
See docs/annotations_schema.md for the full specification.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import cv2
import numpy as np
WINDOW_NAME = "MSB Frame Annotator"
FONT = cv2.FONT_HERSHEY_SIMPLEX
class FrameAnnotator:
"""Interactive frame-by-frame ball position annotator."""
def __init__(
self,
folder: Path,
output_path: Optional[Path] = None,
display_scale: float = 1.0,
) -> None:
self.folder = folder
self.output_path = output_path or (folder / "annotations.json")
self.scale = display_scale
# Load frames
exts = {".png", ".jpg", ".jpeg", ".bmp"}
self.files = sorted(
f for f in folder.iterdir() if f.suffix.lower() in exts
)
if not self.files:
print(f"[ERROR] No image files in {folder}")
sys.exit(1)
self.n_frames = len(self.files)
self.current_idx = 0
self.annotations: Dict[str, Dict[str, Any]] = {}
self.click_pos: Optional[Tuple[int, int]] = None
self.unsaved_changes = False
# Read first frame to get image dimensions (for bounds clamping)
first = cv2.imread(str(self.files[0]))
if first is not None:
self._img_h, self._img_w = first.shape[:2]
else:
self._img_h, self._img_w = 0, 0
# Load existing annotations if present
if self.output_path.exists():
with open(self.output_path) as f:
data = json.load(f)
self.annotations = data.get("annotations", {})
# Skip to first unannotated frame
for i in range(self.n_frames):
if str(i) not in self.annotations:
self.current_idx = i
break
else:
self.current_idx = self.n_frames - 1
n_existing = len(self.annotations)
print(f"[INFO] Loaded {n_existing} existing annotations. "
f"Resuming at frame {self.current_idx}.")
print(f"[INFO] {self.n_frames} frames from {folder}")
print(f"[INFO] Output: {self.output_path}")
def _mouse_callback(
self, event: int, x: int, y: int, flags: int, param: Any
) -> None:
if event == cv2.EVENT_LBUTTONDOWN:
# Convert display coords back to frame coords
real_x = int(x / self.scale) if self.scale != 1.0 else x
real_y = int(y / self.scale) if self.scale != 1.0 else y
# Clamp to image bounds (prevents out-of-range annotations)
h, w = self._img_h, self._img_w
real_x = max(0, min(real_x, w - 1))
real_y = max(0, min(real_y, h - 1))
self.click_pos = (real_x, real_y)
def _draw_frame(self) -> np.ndarray:
"""Load and annotate the current frame for display."""
frame = cv2.imread(str(self.files[self.current_idx]))
if frame is None:
frame = np.zeros((480, 640, 3), dtype=np.uint8)
disp = frame.copy()
h, w = disp.shape[:2]
idx_str = str(self.current_idx)
# Draw existing annotation if any
ann = self.annotations.get(idx_str)
if ann is not None:
if ann.get("visible"):
cx, cy = ann["x"], ann["y"]
cv2.drawMarker(disp, (cx, cy), (0, 255, 0),
cv2.MARKER_CROSS, 20, 2)
cv2.circle(disp, (cx, cy), 8, (0, 255, 0), 2)
cv2.putText(disp, f"({cx},{cy})", (cx + 12, cy - 12),
FONT, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
else:
cv2.putText(disp, "NOT VISIBLE", (w // 2 - 80, h // 2),
FONT, 0.8, (0, 0, 255), 2, cv2.LINE_AA)
# Status bar
n_done = len(self.annotations)
pct = n_done / self.n_frames * 100 if self.n_frames > 0 else 0
status = (f"Frame {self.current_idx}/{self.n_frames - 1} "
f"| Annotated: {n_done}/{self.n_frames} ({pct:.0f}%) "
f"| {'* UNSAVED' if self.unsaved_changes else 'Saved'}")
cv2.rectangle(disp, (0, 0), (w, 30), (40, 40, 40), -1)
cv2.putText(disp, status, (10, 20), FONT, 0.5,
(255, 255, 255), 1, cv2.LINE_AA)
# Controls help
help_lines = [
"Click=mark ball N=not visible B=back R=redo S=save Q=quit"
]
for j, line in enumerate(help_lines):
cv2.putText(disp, line, (10, h - 10 - j * 18), FONT, 0.4,
(200, 200, 200), 1, cv2.LINE_AA)
# Scale for display
if self.scale != 1.0:
dw = int(w * self.scale)
dh = int(h * self.scale)
disp = cv2.resize(disp, (dw, dh))
return disp
def save(self) -> None:
"""Save annotations to disk."""
data = {
"schema_version": "1.0",
"folder": str(self.folder),
"n_frames": self.n_frames,
"image_dimensions": {
"width": self._img_w,
"height": self._img_h,
},
"frame_files": [f.name for f in self.files],
"annotations": self.annotations,
}
with open(self.output_path, "w") as f:
json.dump(data, f, indent=2)
self.unsaved_changes = False
print(f"[SAVED] {len(self.annotations)} annotations → "
f"{self.output_path}")
def run(self) -> None:
"""Main annotation loop."""
cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
cv2.setMouseCallback(WINDOW_NAME, self._mouse_callback)
while True:
self.click_pos = None
disp = self._draw_frame()
cv2.imshow(WINDOW_NAME, disp)
key = cv2.waitKey(30) & 0xFF
# Check for click
if self.click_pos is not None:
cx, cy = self.click_pos
self.annotations[str(self.current_idx)] = {
"x": cx, "y": cy, "visible": True,
"frame_file": self.files[self.current_idx].name,
}
self.unsaved_changes = True
print(f" F{self.current_idx:03d}: ball at ({cx}, {cy})")
# Auto-advance
if self.current_idx < self.n_frames - 1:
self.current_idx += 1
self.click_pos = None
continue
if key == ord("n"):
# Mark not visible
self.annotations[str(self.current_idx)] = {
"x": None, "y": None, "visible": False,
"frame_file": self.files[self.current_idx].name,
}
self.unsaved_changes = True
print(f" F{self.current_idx:03d}: not visible")
if self.current_idx < self.n_frames - 1:
self.current_idx += 1
elif key == ord("b") or key == 81: # B or Left arrow
if self.current_idx > 0:
self.current_idx -= 1
elif key == ord("r"):
# Remove current annotation
idx_str = str(self.current_idx)
if idx_str in self.annotations:
del self.annotations[idx_str]
self.unsaved_changes = True
print(f" F{self.current_idx:03d}: annotation removed")
elif key == ord("s"):
self.save()
elif key in (ord("q"), 27):
if self.unsaved_changes:
print("[WARN] Unsaved changes! Saving before exit...")
self.save()
break
# Auto-navigation with arrow keys
elif key == 83: # Right arrow
if self.current_idx < self.n_frames - 1:
self.current_idx += 1
elif key == 82: # Up arrow — jump forward 10
self.current_idx = min(self.n_frames - 1,
self.current_idx + 10)
elif key == 84: # Down arrow — jump back 10
self.current_idx = max(0, self.current_idx - 10)
cv2.destroyAllWindows()
# Final stats
n_vis = sum(1 for v in self.annotations.values()
if v.get("visible"))
n_not_vis = sum(1 for v in self.annotations.values()
if not v.get("visible"))
print(f"\n[DONE] {len(self.annotations)} total annotations: "
f"{n_vis} visible, {n_not_vis} not visible")
def main() -> None:
ap = argparse.ArgumentParser(
description="Annotate ball positions in pitch recording frames")
ap.add_argument("folder",
help="Path to pitch recording folder with frame images")
ap.add_argument("-o", "--output", default=None,
help="Output annotations JSON "
"(default: <folder>/annotations.json)")
ap.add_argument("--scale", type=float, default=1.0,
help="Display scale factor (default: 1.0)")
args = ap.parse_args()
folder = Path(args.folder)
if not folder.is_dir():
print(f"[ERROR] Not a directory: {folder}")
sys.exit(1)
annotator = FrameAnnotator(
folder,
output_path=Path(args.output) if args.output else None,
display_scale=args.scale,
)
annotator.run()
if __name__ == "__main__":
main()