openvisionkit 0.4.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.
- openvisionkit/__init__.py +1 -0
- openvisionkit/_version.py +24 -0
- openvisionkit/capture/draw_object.py +296 -0
- openvisionkit/capture/image_template.py +61 -0
- openvisionkit/capture/screen_capture.py +13 -0
- openvisionkit/capture/video_recorder.py +128 -0
- openvisionkit/capture/video_template.py +336 -0
- openvisionkit/lib/classifier.py +186 -0
- openvisionkit/lib/face_detector.py +587 -0
- openvisionkit/lib/face_mesh_detector.py +913 -0
- openvisionkit/lib/form_detector.py +465 -0
- openvisionkit/lib/form_roi_annotator.py +679 -0
- openvisionkit/lib/form_roi_detector.py +1078 -0
- openvisionkit/lib/fps_counter.py +38 -0
- openvisionkit/lib/hair_segmentation.py +298 -0
- openvisionkit/lib/hand_detector.py +1230 -0
- openvisionkit/lib/image_detector.py +1095 -0
- openvisionkit/lib/object_detector.py +401 -0
- openvisionkit/lib/pose_detector.py +919 -0
- openvisionkit/lib/selfie_segmentation.py +528 -0
- openvisionkit/lib/text_detector.py +1229 -0
- openvisionkit/utility/live_plot.py +141 -0
- openvisionkit/utility/vision_utilis.py +871 -0
- openvisionkit-0.4.0.dist-info/METADATA +1018 -0
- openvisionkit-0.4.0.dist-info/RECORD +26 -0
- openvisionkit-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Form ROI Annotator
|
|
10
|
+
- Click two diagonal corners to define a form field ROI
|
|
11
|
+
- Choose field type + label when prompted
|
|
12
|
+
- Keyboard shortcuts for undo, delete, edit, clear, save, list, help
|
|
13
|
+
- Auto-saves after every change + timestamped backups
|
|
14
|
+
- Output format: roi = [[(x1,y1), (x2,y2), "type", "label","category"], ...]
|
|
15
|
+
|
|
16
|
+
Undo last ROI => Press u
|
|
17
|
+
Delete specific ROI => Press d (shows numbered list)
|
|
18
|
+
Edit existing ROI => Press e (change type or label)
|
|
19
|
+
Auto-save with timestamp => Every change + final exit (JSON + overlaid PNG)
|
|
20
|
+
Always saves annotated_rois_latest.json (easy to continue)
|
|
21
|
+
Load previous session => Pass JSON as second argument
|
|
22
|
+
Right-click to cancel current selection
|
|
23
|
+
Clear all => Press c (with confirmation)
|
|
24
|
+
Manual save anytime => Press s
|
|
25
|
+
List ROIs => Press l
|
|
26
|
+
Help menu => Press h
|
|
27
|
+
|
|
28
|
+
Other features to consider:
|
|
29
|
+
Index numbers shown on image for easy identification
|
|
30
|
+
Custom field types supported
|
|
31
|
+
More field types pre-loaded
|
|
32
|
+
Annotated image export with all boxes/labels
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
python form_roi_annotator.py your_form.jpg
|
|
37
|
+
# or continue from previous:
|
|
38
|
+
python form_roi_annotator.py your_form.jpg annotated_rois_latest.json
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FormROIAnnotator:
|
|
44
|
+
def __init__(self, image_path: str, load_path: str = None):
|
|
45
|
+
self.image_path = image_path
|
|
46
|
+
self.image = cv2.imread(image_path)
|
|
47
|
+
if self.image is None:
|
|
48
|
+
raise FileNotFoundError(f"Could not load image: {image_path}")
|
|
49
|
+
|
|
50
|
+
self.clone = self.image.copy()
|
|
51
|
+
self.rois: list = [] # Now: [[(x1,y1), (x2,y2), "type", "label", "category"], ...]
|
|
52
|
+
self.current_points: list[tuple[int, int]] = []
|
|
53
|
+
|
|
54
|
+
# Supported field types
|
|
55
|
+
self.field_types = [
|
|
56
|
+
"text",
|
|
57
|
+
"textbox",
|
|
58
|
+
"box",
|
|
59
|
+
"checkbox",
|
|
60
|
+
"radio",
|
|
61
|
+
"daterange",
|
|
62
|
+
"date_range",
|
|
63
|
+
"table",
|
|
64
|
+
"table_cell",
|
|
65
|
+
"signature",
|
|
66
|
+
"dropdown",
|
|
67
|
+
"header",
|
|
68
|
+
"footer",
|
|
69
|
+
"paragraph",
|
|
70
|
+
"logo",
|
|
71
|
+
"line",
|
|
72
|
+
"custom",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Load existing annotations if provided
|
|
76
|
+
if load_path and os.path.exists(load_path):
|
|
77
|
+
try:
|
|
78
|
+
with open(load_path) as f:
|
|
79
|
+
self.rois = json.load(f)
|
|
80
|
+
print(f"✅ Loaded {len(self.rois)} existing ROIs from {load_path}")
|
|
81
|
+
self._redraw_all_rois()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
print(f"⚠️ Could not load existing ROIs: {e}")
|
|
84
|
+
|
|
85
|
+
def mouse_callback(self, event, x, y, flags, param):
|
|
86
|
+
if event == cv2.EVENT_LBUTTONDOWN:
|
|
87
|
+
self.current_points.append((x, y))
|
|
88
|
+
cv2.circle(self.clone, (x, y), 6, (0, 255, 255), -1)
|
|
89
|
+
cv2.imshow("Form ROI Annotator", self.clone)
|
|
90
|
+
|
|
91
|
+
if len(self.current_points) == 2:
|
|
92
|
+
pt1 = self.current_points[0]
|
|
93
|
+
pt2 = self.current_points[1]
|
|
94
|
+
|
|
95
|
+
x1 = min(pt1[0], pt2[0])
|
|
96
|
+
y1 = min(pt1[1], pt2[1])
|
|
97
|
+
x2 = max(pt1[0], pt2[0])
|
|
98
|
+
y2 = max(pt1[1], pt2[1])
|
|
99
|
+
|
|
100
|
+
self._select_and_add_roi(x1, y1, x2, y2)
|
|
101
|
+
|
|
102
|
+
self.current_points.clear()
|
|
103
|
+
self.clone = self.image.copy()
|
|
104
|
+
self._redraw_all_rois()
|
|
105
|
+
|
|
106
|
+
elif event == cv2.EVENT_RBUTTONDOWN and self.current_points:
|
|
107
|
+
self.current_points.clear()
|
|
108
|
+
self.clone = self.image.copy()
|
|
109
|
+
self._redraw_all_rois()
|
|
110
|
+
print("🗑️ Current selection cancelled.")
|
|
111
|
+
|
|
112
|
+
def _select_and_add_roi(self, x1: int, y1: int, x2: int, y2: int):
|
|
113
|
+
print("\n" + "=" * 75)
|
|
114
|
+
print("Select Form Field Type:")
|
|
115
|
+
for i, t in enumerate(self.field_types, 1):
|
|
116
|
+
print(f" {i:2d}. {t}")
|
|
117
|
+
print(" 0. Custom type")
|
|
118
|
+
|
|
119
|
+
while True:
|
|
120
|
+
try:
|
|
121
|
+
choice = int(input("\nEnter number for field type: "))
|
|
122
|
+
if choice == 0:
|
|
123
|
+
field_type = input("Enter custom field type: ").strip() or "custom"
|
|
124
|
+
break
|
|
125
|
+
if 1 <= choice <= len(self.field_types):
|
|
126
|
+
field_type = self.field_types[choice - 1]
|
|
127
|
+
break
|
|
128
|
+
print("Invalid number.")
|
|
129
|
+
except ValueError:
|
|
130
|
+
print("Please enter a valid number.")
|
|
131
|
+
|
|
132
|
+
# Label (what is written inside / on the field)
|
|
133
|
+
label = input(
|
|
134
|
+
"Enter label/text visible on field (press Enter to skip): "
|
|
135
|
+
).strip()
|
|
136
|
+
|
|
137
|
+
# NEW: Category (semantic group / section the field belongs to)
|
|
138
|
+
print(
|
|
139
|
+
"\nEnter Category (e.g., Personal Info, Contact, Meal Preference, Satisfaction, etc.)"
|
|
140
|
+
)
|
|
141
|
+
category = input("Category: ").strip()
|
|
142
|
+
if not category:
|
|
143
|
+
category = "Uncategorized"
|
|
144
|
+
|
|
145
|
+
# Store in new 5-element format
|
|
146
|
+
self.rois.append([(x1, y1), (x2, y2), field_type, label, category])
|
|
147
|
+
|
|
148
|
+
print(
|
|
149
|
+
f"✅ ROI Added → Type: {field_type} | Label: '{label}' | Category: '{category}'"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
self._save_latest()
|
|
153
|
+
|
|
154
|
+
def _redraw_all_rois(self):
|
|
155
|
+
self.clone = self.image.copy()
|
|
156
|
+
|
|
157
|
+
for idx, roi in enumerate(self.rois, 1):
|
|
158
|
+
(x1, y1), (x2, y2), ftype, label, category = roi
|
|
159
|
+
color = self._get_color(ftype)
|
|
160
|
+
|
|
161
|
+
cv2.rectangle(self.clone, (x1, y1), (x2, y2), color, 3)
|
|
162
|
+
|
|
163
|
+
# Main display text: Type + Label
|
|
164
|
+
display_text = f"{ftype}: {label}" if label else ftype
|
|
165
|
+
cv2.putText(
|
|
166
|
+
self.clone,
|
|
167
|
+
display_text,
|
|
168
|
+
(x1, max(15, y1 - 10)),
|
|
169
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
170
|
+
0.65,
|
|
171
|
+
color,
|
|
172
|
+
2,
|
|
173
|
+
cv2.LINE_AA,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Show Category below the box
|
|
177
|
+
if category and category != "Uncategorized":
|
|
178
|
+
cv2.putText(
|
|
179
|
+
self.clone,
|
|
180
|
+
f"[{category}]",
|
|
181
|
+
(x1, y2 + 25),
|
|
182
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
183
|
+
0.55,
|
|
184
|
+
(200, 200, 255),
|
|
185
|
+
2,
|
|
186
|
+
cv2.LINE_AA,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Index number
|
|
190
|
+
cv2.putText(
|
|
191
|
+
self.clone,
|
|
192
|
+
str(idx),
|
|
193
|
+
(x1 + 8, y1 + 28),
|
|
194
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
195
|
+
0.85,
|
|
196
|
+
(255, 255, 255),
|
|
197
|
+
2,
|
|
198
|
+
)
|
|
199
|
+
cv2.putText(
|
|
200
|
+
self.clone,
|
|
201
|
+
str(idx),
|
|
202
|
+
(x1 + 8, y1 + 28),
|
|
203
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
204
|
+
0.85,
|
|
205
|
+
color,
|
|
206
|
+
1,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
cv2.setWindowTitle(
|
|
210
|
+
"Form ROI Annotator", f"Form ROI Annotator — {len(self.rois)} ROIs"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _get_color(self, ftype: str):
|
|
214
|
+
color_map = {
|
|
215
|
+
"text": (0, 255, 0),
|
|
216
|
+
"textbox": (0, 165, 255),
|
|
217
|
+
"box": (255, 0, 0),
|
|
218
|
+
"checkbox": (255, 0, 255),
|
|
219
|
+
"radio": (0, 255, 255),
|
|
220
|
+
"daterange": (255, 140, 0),
|
|
221
|
+
"table": (128, 0, 255),
|
|
222
|
+
"table_cell": (255, 0, 128),
|
|
223
|
+
"signature": (0, 128, 255),
|
|
224
|
+
"dropdown": (100, 200, 50),
|
|
225
|
+
"header": (0, 100, 200),
|
|
226
|
+
"footer": (200, 100, 0),
|
|
227
|
+
"custom": (255, 255, 0),
|
|
228
|
+
}
|
|
229
|
+
return color_map.get(ftype, (255, 255, 0))
|
|
230
|
+
|
|
231
|
+
def _undo_last(self):
|
|
232
|
+
if not self.rois:
|
|
233
|
+
print("⚠️ No ROIs to undo.")
|
|
234
|
+
return
|
|
235
|
+
removed = self.rois.pop()
|
|
236
|
+
print(f"🗑️ Undid last ROI: {removed}")
|
|
237
|
+
self._redraw_all_rois()
|
|
238
|
+
self._save_latest()
|
|
239
|
+
|
|
240
|
+
def _delete_specific(self):
|
|
241
|
+
if not self.rois:
|
|
242
|
+
print("⚠️ No ROIs.")
|
|
243
|
+
return
|
|
244
|
+
print("\nCurrent ROIs:")
|
|
245
|
+
for i, r in enumerate(self.rois, 1):
|
|
246
|
+
print(f" {i:2d}. {r[2]:10} | Label: '{r[3]}' | Category: '{r[4]}'")
|
|
247
|
+
try:
|
|
248
|
+
idx = int(input("\nEnter ROI number to delete (0 to cancel): "))
|
|
249
|
+
if 1 <= idx <= len(self.rois):
|
|
250
|
+
self.rois.pop(idx - 1)
|
|
251
|
+
print(f"🗑️ Deleted ROI #{idx}")
|
|
252
|
+
self._redraw_all_rois()
|
|
253
|
+
self._save_latest()
|
|
254
|
+
except ValueError:
|
|
255
|
+
print("Invalid input.")
|
|
256
|
+
|
|
257
|
+
def _edit_roi(self):
|
|
258
|
+
if not self.rois:
|
|
259
|
+
print("⚠️ No ROIs to edit.")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
print("\nCurrent ROIs:")
|
|
263
|
+
for i, r in enumerate(self.rois, 1):
|
|
264
|
+
print(f" {i:2d}. {r[2]:10} | Label: '{r[3]}' | Category: '{r[4]}'")
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
idx = int(input("\nEnter ROI number to edit (0 to cancel): "))
|
|
268
|
+
if idx == 0 or not (1 <= idx <= len(self.rois)):
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
roi = self.rois[idx - 1]
|
|
272
|
+
_, _, ftype, label, category = roi
|
|
273
|
+
|
|
274
|
+
print(
|
|
275
|
+
f"\nEditing ROI #{idx} (Current: Type={ftype}, Label='{label}', Category='{category}')"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Re-select type
|
|
279
|
+
choice_str = input("New type number (Enter to keep): ").strip()
|
|
280
|
+
if choice_str:
|
|
281
|
+
try:
|
|
282
|
+
ch = int(choice_str)
|
|
283
|
+
if ch == 0:
|
|
284
|
+
new_type = input("Custom type: ").strip() or "custom"
|
|
285
|
+
elif 1 <= ch <= len(self.field_types):
|
|
286
|
+
new_type = self.field_types[ch - 1]
|
|
287
|
+
else:
|
|
288
|
+
new_type = ftype
|
|
289
|
+
except (ValueError, IndexError):
|
|
290
|
+
new_type = ftype
|
|
291
|
+
else:
|
|
292
|
+
new_type = ftype
|
|
293
|
+
|
|
294
|
+
new_label = input(f"New label (current: '{label}'): ").strip()
|
|
295
|
+
if not new_label:
|
|
296
|
+
new_label = label
|
|
297
|
+
|
|
298
|
+
new_category = input(f"New category (current: '{category}'): ").strip()
|
|
299
|
+
if not new_category:
|
|
300
|
+
new_category = category
|
|
301
|
+
|
|
302
|
+
self.rois[idx - 1] = [roi[0], roi[1], new_type, new_label, new_category]
|
|
303
|
+
print(f"✅ ROI #{idx} updated successfully.")
|
|
304
|
+
self._redraw_all_rois()
|
|
305
|
+
self._save_latest()
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f"Error during edit: {e}")
|
|
308
|
+
|
|
309
|
+
def _save_latest(self):
|
|
310
|
+
with open("annotated_rois_latest.json", "w") as f:
|
|
311
|
+
json.dump(self.rois, f, indent=2)
|
|
312
|
+
|
|
313
|
+
def _save_with_timestamp(self):
|
|
314
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
315
|
+
json_file = f"annotated_rois_{ts}.json"
|
|
316
|
+
png_file = f"annotated_image_{ts}.png"
|
|
317
|
+
|
|
318
|
+
with open(json_file, "w") as f:
|
|
319
|
+
json.dump(self.rois, f, indent=2)
|
|
320
|
+
|
|
321
|
+
# Save visual image
|
|
322
|
+
vis = self.image.copy()
|
|
323
|
+
for roi in self.rois:
|
|
324
|
+
(x1, y1), (x2, y2), ftype, label, category = roi
|
|
325
|
+
color = self._get_color(ftype)
|
|
326
|
+
cv2.rectangle(vis, (x1, y1), (x2, y2), color, 3)
|
|
327
|
+
text = f"{ftype}: {label}" if label else ftype
|
|
328
|
+
cv2.putText(
|
|
329
|
+
vis,
|
|
330
|
+
text,
|
|
331
|
+
(x1, max(10, y1 - 8)),
|
|
332
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
333
|
+
0.65,
|
|
334
|
+
color,
|
|
335
|
+
2,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if category and category != "Uncategorized":
|
|
339
|
+
cv2.putText(
|
|
340
|
+
vis,
|
|
341
|
+
f"[{category}]",
|
|
342
|
+
(x1, y2 + 22),
|
|
343
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
344
|
+
0.55,
|
|
345
|
+
(200, 200, 255),
|
|
346
|
+
2,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
cv2.imwrite(png_file, vis)
|
|
350
|
+
print(f"💾 Timestamped backup saved: {json_file} & {png_file}")
|
|
351
|
+
|
|
352
|
+
def run(self):
|
|
353
|
+
print("\n" + "=" * 95)
|
|
354
|
+
print("🚀 FORM ROI ANNOTATOR with Category Support")
|
|
355
|
+
print("=" * 95)
|
|
356
|
+
print("Now each ROI includes: [ (x1,y1), (x2,y2), type, label, category ]")
|
|
357
|
+
print("\nKeyboard Shortcuts:")
|
|
358
|
+
print(" u = Undo last | d = Delete specific | e = Edit ROI")
|
|
359
|
+
print(" c = Clear all | s = Manual save | l = List ROIs")
|
|
360
|
+
print(" h = Help | ESC = Finish & Exit")
|
|
361
|
+
print("=" * 95)
|
|
362
|
+
|
|
363
|
+
cv2.namedWindow("Form ROI Annotator", cv2.WINDOW_NORMAL)
|
|
364
|
+
cv2.setMouseCallback("Form ROI Annotator", self.mouse_callback)
|
|
365
|
+
|
|
366
|
+
while True:
|
|
367
|
+
cv2.imshow("Form ROI Annotator", self.clone)
|
|
368
|
+
key = cv2.waitKey(1) & 0xFF
|
|
369
|
+
|
|
370
|
+
if key == 27: # ESC
|
|
371
|
+
break
|
|
372
|
+
elif key == ord("u"):
|
|
373
|
+
self._undo_last()
|
|
374
|
+
elif key == ord("d"):
|
|
375
|
+
self._delete_specific()
|
|
376
|
+
elif key == ord("e"):
|
|
377
|
+
self._edit_roi()
|
|
378
|
+
elif key == ord("c"):
|
|
379
|
+
if (
|
|
380
|
+
input("Clear ALL ROIs? Type YES to confirm: ").strip().upper()
|
|
381
|
+
== "YES"
|
|
382
|
+
):
|
|
383
|
+
self.rois.clear()
|
|
384
|
+
self.clone = self.image.copy()
|
|
385
|
+
self._redraw_all_rois()
|
|
386
|
+
self._save_latest()
|
|
387
|
+
elif key == ord("s"):
|
|
388
|
+
self._save_with_timestamp()
|
|
389
|
+
elif key == ord("l"):
|
|
390
|
+
print("\nCurrent ROIs:")
|
|
391
|
+
for i, r in enumerate(self.rois, 1):
|
|
392
|
+
print(f" {i:2d}. {r}")
|
|
393
|
+
elif key == ord("h"):
|
|
394
|
+
print("Use mouse + shortcuts as shown above.")
|
|
395
|
+
|
|
396
|
+
cv2.destroyAllWindows()
|
|
397
|
+
|
|
398
|
+
self._save_latest()
|
|
399
|
+
self._save_with_timestamp()
|
|
400
|
+
|
|
401
|
+
print("\n" + "=" * 95)
|
|
402
|
+
print("ANNOTATION COMPLETED!")
|
|
403
|
+
print("Final ROI (with category):")
|
|
404
|
+
print("roi =", self.rois)
|
|
405
|
+
print("\nSaved files: annotated_rois_latest.json + timestamped backups")
|
|
406
|
+
print(" • Multiple timestamped backups + annotated images")
|
|
407
|
+
|
|
408
|
+
# ─────────────────────────── NEW METHODS ───────────────────────────
|
|
409
|
+
|
|
410
|
+
def export_rois_to_json(self, path: str = "annotated_rois_export.json") -> str:
|
|
411
|
+
"""Save current ROI list to a JSON file (public API wrapper around _save_latest).
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
path: Output file path.
|
|
415
|
+
Returns:
|
|
416
|
+
str: Absolute path of the written file.
|
|
417
|
+
"""
|
|
418
|
+
import os
|
|
419
|
+
|
|
420
|
+
with open(path, "w") as f:
|
|
421
|
+
json.dump(self.rois, f, indent=2)
|
|
422
|
+
return os.path.abspath(path)
|
|
423
|
+
|
|
424
|
+
def import_from_json(self, path: str):
|
|
425
|
+
"""Load ROI annotations from a JSON file and redraw the canvas.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
path: Path to a JSON file previously exported by export_to_json().
|
|
429
|
+
"""
|
|
430
|
+
with open(path) as f:
|
|
431
|
+
self.rois = json.load(f)
|
|
432
|
+
self._redraw_all_rois()
|
|
433
|
+
print(f"Loaded {len(self.rois)} ROIs from {path}")
|
|
434
|
+
|
|
435
|
+
def get_rois_by_category(self, category: str):
|
|
436
|
+
"""Return all ROIs belonging to a given category (case-insensitive).
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
category: Category string (e.g. 'Personal Info').
|
|
440
|
+
Returns:
|
|
441
|
+
List of ROI entries matching the category.
|
|
442
|
+
"""
|
|
443
|
+
cat_lower = category.lower()
|
|
444
|
+
return [r for r in self.rois if r[4].lower() == cat_lower]
|
|
445
|
+
|
|
446
|
+
def get_rois_by_type(self, field_type: str):
|
|
447
|
+
"""Return all ROIs with the specified field type (case-insensitive).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
field_type: e.g. 'checkbox', 'text', 'signature'.
|
|
451
|
+
Returns:
|
|
452
|
+
List of ROI entries matching the type.
|
|
453
|
+
"""
|
|
454
|
+
ft_lower = field_type.lower()
|
|
455
|
+
return [r for r in self.rois if r[2].lower() == ft_lower]
|
|
456
|
+
|
|
457
|
+
def count_by_type(self) -> dict:
|
|
458
|
+
"""Return a count of each field type present in the current annotations.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
dict: {'checkbox': 5, 'text': 3, ...}
|
|
462
|
+
"""
|
|
463
|
+
counts: dict = {}
|
|
464
|
+
for roi in self.rois:
|
|
465
|
+
ft = roi[2]
|
|
466
|
+
counts[ft] = counts.get(ft, 0) + 1
|
|
467
|
+
return counts
|
|
468
|
+
|
|
469
|
+
def count_by_category(self) -> dict:
|
|
470
|
+
"""Return a count of ROIs per category.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
dict: {'Personal Info': 4, 'Contact': 2, ...}
|
|
474
|
+
"""
|
|
475
|
+
counts: dict = {}
|
|
476
|
+
for roi in self.rois:
|
|
477
|
+
cat = roi[4]
|
|
478
|
+
counts[cat] = counts.get(cat, 0) + 1
|
|
479
|
+
return counts
|
|
480
|
+
|
|
481
|
+
def to_dataframe(self):
|
|
482
|
+
"""Convert current annotations to a pandas DataFrame.
|
|
483
|
+
Columns: x1, y1, x2, y2, field_type, label, category.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
pandas.DataFrame
|
|
487
|
+
"""
|
|
488
|
+
import pandas as pd
|
|
489
|
+
|
|
490
|
+
rows = []
|
|
491
|
+
for roi in self.rois:
|
|
492
|
+
(x1, y1), (x2, y2), ftype, label, category = roi
|
|
493
|
+
rows.append(
|
|
494
|
+
{
|
|
495
|
+
"x1": x1,
|
|
496
|
+
"y1": y1,
|
|
497
|
+
"x2": x2,
|
|
498
|
+
"y2": y2,
|
|
499
|
+
"field_type": ftype,
|
|
500
|
+
"label": label,
|
|
501
|
+
"category": category,
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
return pd.DataFrame(rows)
|
|
505
|
+
|
|
506
|
+
def validate_rois(self) -> list:
|
|
507
|
+
"""Check annotations for common issues: zero-area boxes, missing labels, overlaps.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
List[str]: Human-readable issue descriptions. Empty list = no issues.
|
|
511
|
+
"""
|
|
512
|
+
issues = []
|
|
513
|
+
for i, roi in enumerate(self.rois, 1):
|
|
514
|
+
(x1, y1), (x2, y2), ftype, label, category = roi
|
|
515
|
+
if x2 <= x1 or y2 <= y1:
|
|
516
|
+
issues.append(f"ROI #{i} ({ftype}): zero or negative area.")
|
|
517
|
+
if not label:
|
|
518
|
+
issues.append(f"ROI #{i} ({ftype}): missing label.")
|
|
519
|
+
|
|
520
|
+
# Simple O(n²) overlap check
|
|
521
|
+
for i in range(len(self.rois)):
|
|
522
|
+
for j in range(i + 1, len(self.rois)):
|
|
523
|
+
(ax1, ay1), (ax2, ay2) = self.rois[i][0], self.rois[i][1]
|
|
524
|
+
(bx1, by1), (bx2, by2) = self.rois[j][0], self.rois[j][1]
|
|
525
|
+
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
|
526
|
+
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
|
527
|
+
if ix2 > ix1 and iy2 > iy1:
|
|
528
|
+
inter = (ix2 - ix1) * (iy2 - iy1)
|
|
529
|
+
area_a = (ax2 - ax1) * (ay2 - ay1)
|
|
530
|
+
iou = inter / max(area_a, 1)
|
|
531
|
+
if iou > 0.5:
|
|
532
|
+
issues.append(
|
|
533
|
+
f"ROI #{i + 1} and #{j + 1} overlap significantly (IoU={iou:.2f})."
|
|
534
|
+
)
|
|
535
|
+
return issues
|
|
536
|
+
|
|
537
|
+
def get_summary(self) -> str:
|
|
538
|
+
"""Return a compact text summary of current annotations.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
str: Multi-line summary string.
|
|
542
|
+
"""
|
|
543
|
+
lines = [f"Total ROIs: {len(self.rois)}"]
|
|
544
|
+
for ft, count in self.count_by_type().items():
|
|
545
|
+
lines.append(f" {ft}: {count}")
|
|
546
|
+
lines.append("Categories:")
|
|
547
|
+
for cat, count in self.count_by_category().items():
|
|
548
|
+
lines.append(f" {cat}: {count}")
|
|
549
|
+
return "\n".join(lines)
|
|
550
|
+
|
|
551
|
+
def get_annotations_by_type(self, annotations, field_type) -> list:
|
|
552
|
+
"""Filter annotations to those matching a given field type.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
annotations: List of annotation entries in
|
|
556
|
+
[(x1,y1), (x2,y2), type, label, category] format.
|
|
557
|
+
field_type: String field type to filter by (e.g. "checkbox").
|
|
558
|
+
Returns:
|
|
559
|
+
List of matching annotation entries.
|
|
560
|
+
"""
|
|
561
|
+
return [a for a in annotations if a[2] == field_type]
|
|
562
|
+
|
|
563
|
+
def export_to_json(self, annotations, path) -> None:
|
|
564
|
+
"""Serialize annotations to a JSON file.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
annotations: List of annotation entries in
|
|
568
|
+
[(x1,y1), (x2,y2), type, label, category] format.
|
|
569
|
+
path: Output file path string.
|
|
570
|
+
"""
|
|
571
|
+
data = [
|
|
572
|
+
{
|
|
573
|
+
"x1": a[0][0],
|
|
574
|
+
"y1": a[0][1],
|
|
575
|
+
"x2": a[1][0],
|
|
576
|
+
"y2": a[1][1],
|
|
577
|
+
"type": a[2],
|
|
578
|
+
"label": a[3],
|
|
579
|
+
"category": a[4],
|
|
580
|
+
}
|
|
581
|
+
for a in annotations
|
|
582
|
+
]
|
|
583
|
+
with open(path, "w") as f:
|
|
584
|
+
json.dump(data, f, indent=2)
|
|
585
|
+
|
|
586
|
+
def get_annotation_count(self, annotations) -> dict:
|
|
587
|
+
"""Return count of annotations per field type.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
annotations: List of annotation entries.
|
|
591
|
+
Returns:
|
|
592
|
+
dict mapping field type string to integer count.
|
|
593
|
+
"""
|
|
594
|
+
from collections import Counter
|
|
595
|
+
|
|
596
|
+
return dict(Counter(a[2] for a in annotations))
|
|
597
|
+
|
|
598
|
+
def merge_annotations(self, ann_list_a, ann_list_b) -> list:
|
|
599
|
+
"""Merge two annotation lists, deduplicating by IoU > 0.5.
|
|
600
|
+
|
|
601
|
+
Annotations in ann_list_a are kept as-is. Each annotation in
|
|
602
|
+
ann_list_b is added only if it does not overlap (IoU > 0.5) with
|
|
603
|
+
any already-merged annotation.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
ann_list_a: First list of annotation entries.
|
|
607
|
+
ann_list_b: Second list of annotation entries.
|
|
608
|
+
Returns:
|
|
609
|
+
Merged list with duplicates removed.
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
def iou(a, b):
|
|
613
|
+
ax1, ay1 = a[0]
|
|
614
|
+
ax2, ay2 = a[1]
|
|
615
|
+
bx1, by1 = b[0]
|
|
616
|
+
bx2, by2 = b[1]
|
|
617
|
+
ix1 = max(ax1, bx1)
|
|
618
|
+
iy1 = max(ay1, by1)
|
|
619
|
+
ix2 = min(ax2, bx2)
|
|
620
|
+
iy2 = min(ay2, by2)
|
|
621
|
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
|
622
|
+
if inter == 0:
|
|
623
|
+
return 0.0
|
|
624
|
+
area_a = max(1, (ax2 - ax1) * (ay2 - ay1))
|
|
625
|
+
area_b = max(1, (bx2 - bx1) * (by2 - by1))
|
|
626
|
+
return inter / (area_a + area_b - inter)
|
|
627
|
+
|
|
628
|
+
merged = list(ann_list_a)
|
|
629
|
+
for b in ann_list_b:
|
|
630
|
+
if not any(iou(a, b) > 0.5 for a in merged):
|
|
631
|
+
merged.append(b)
|
|
632
|
+
return merged
|
|
633
|
+
|
|
634
|
+
def draw_annotation_summary(self, image, annotations):
|
|
635
|
+
"""Draw a type-count summary overlay on the image.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
image: BGR numpy array.
|
|
639
|
+
annotations: List of annotation entries.
|
|
640
|
+
Returns:
|
|
641
|
+
Annotated BGR numpy array (copy of input).
|
|
642
|
+
"""
|
|
643
|
+
out = image.copy()
|
|
644
|
+
counts = self.get_annotation_count(annotations)
|
|
645
|
+
lines = [f"{t}: {c}" for t, c in counts.items()]
|
|
646
|
+
box_h = 20 + len(lines) * 22
|
|
647
|
+
cv2.rectangle(out, (5, 5), (180, box_h), (0, 0, 0), -1)
|
|
648
|
+
for i, line in enumerate(lines):
|
|
649
|
+
cv2.putText(
|
|
650
|
+
out,
|
|
651
|
+
line,
|
|
652
|
+
(10, 22 + i * 22),
|
|
653
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
654
|
+
0.5,
|
|
655
|
+
(255, 255, 255),
|
|
656
|
+
1,
|
|
657
|
+
)
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
if __name__ == "__main__":
|
|
662
|
+
if len(sys.argv) < 2:
|
|
663
|
+
print("Usage:")
|
|
664
|
+
print(" python form_roi_annotator.py <image_path> [existing_rois.json]")
|
|
665
|
+
print("Example:")
|
|
666
|
+
print(" python form_roi_annotator.py form_sample.jpg")
|
|
667
|
+
print(
|
|
668
|
+
" python form_roi_annotator.py form_sample.jpg annotated_rois_latest.json"
|
|
669
|
+
)
|
|
670
|
+
sys.exit(1)
|
|
671
|
+
|
|
672
|
+
image_path = sys.argv[1]
|
|
673
|
+
load_path = sys.argv[2] if len(sys.argv) > 2 else None
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
annotator = FormROIAnnotator(image_path, load_path)
|
|
677
|
+
annotator.run()
|
|
678
|
+
except Exception as e:
|
|
679
|
+
print(f"Error: {e}")
|