feat: interactive zone calibration tool
This commit is contained in:
1 parent
f0ea95dd26
commit
859d6a0f0f
1 file changed
+175
Executable
+175
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Interactive zone calibration tool.
|
||||||
|
|
||||||
|
Click 4 points for truck area, 4 for counting area, 2 for count line.
|
||||||
|
Keys: s=save, c=clear/restart, q/ESC=quit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ZONE_KEYS = ["truck_area", "counting_area"]
|
||||||
|
ZONE_SIZES = [4, 4]
|
||||||
|
LINE_POINTS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def load_zones(path: str) -> dict:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"presets": {}, "active_preset": None}
|
||||||
|
|
||||||
|
|
||||||
|
def save_zones(config: dict, path: str):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_state(frame, points, current_zone, zone_idx, zone_sizes, zone_names):
|
||||||
|
vis = frame.copy()
|
||||||
|
colors = [(0, 255, 0), (255, 0, 0), (0, 0, 255)]
|
||||||
|
total_zones = len(zone_names)
|
||||||
|
|
||||||
|
all_zones = []
|
||||||
|
offset = 0
|
||||||
|
for i, sz in enumerate(zone_sizes):
|
||||||
|
all_zones.append(points[offset : offset + sz])
|
||||||
|
offset += sz
|
||||||
|
if current_zone < total_zones:
|
||||||
|
all_zones.append(points[offset:])
|
||||||
|
|
||||||
|
for i, zone_pts in enumerate(all_zones):
|
||||||
|
if not zone_pts:
|
||||||
|
continue
|
||||||
|
color = colors[i % len(colors)]
|
||||||
|
if i < total_zones:
|
||||||
|
name = zone_names[i]
|
||||||
|
need = zone_sizes[i]
|
||||||
|
else:
|
||||||
|
name = "count_line"
|
||||||
|
need = LINE_POINTS
|
||||||
|
|
||||||
|
if len(zone_pts) > 1:
|
||||||
|
pts = np.array(zone_pts, dtype=np.int32)
|
||||||
|
if len(zone_pts) >= 3 and i < total_zones:
|
||||||
|
cv2.polylines(vis, [pts], True, color, 2)
|
||||||
|
else:
|
||||||
|
cv2.polylines(vis, [pts], False, color, 2)
|
||||||
|
|
||||||
|
for j, pt in enumerate(zone_pts):
|
||||||
|
cv2.circle(vis, pt, 6, color, -1)
|
||||||
|
cv2.putText(vis, str(j + 1), (pt[0] + 8, pt[1] - 8),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
||||||
|
|
||||||
|
if i == current_zone:
|
||||||
|
status = f"[{name}] {len(zone_pts)}/{need} pts"
|
||||||
|
else:
|
||||||
|
status = f"{name}: {len(zone_pts)}/{need}"
|
||||||
|
y_pos = 30 + i * 25
|
||||||
|
cv2.putText(vis, status, (10, y_pos),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||||
|
|
||||||
|
if current_zone < total_zones:
|
||||||
|
cur_name = zone_names[current_zone]
|
||||||
|
else:
|
||||||
|
cur_name = "count_line"
|
||||||
|
hint = f"Click {cur_name} point. s=save c=clear q=quit"
|
||||||
|
cv2.putText(vis, hint, (10, vis.shape[0] - 20),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||||
|
return vis
|
||||||
|
|
||||||
|
|
||||||
|
def run(video_path: str, preset_name: str, zones_path: str):
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"Error: cannot open {video_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ret, frame = cap.read()
|
||||||
|
cap.release()
|
||||||
|
if not ret:
|
||||||
|
print("Error: cannot read frame from video")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
print(f"Video: {w}x{h}")
|
||||||
|
|
||||||
|
points = []
|
||||||
|
all_sizes = list(ZONE_SIZES) + [LINE_POINTS]
|
||||||
|
all_names = list(ZONE_KEYS) + ["count_line"]
|
||||||
|
zone_idx = 0
|
||||||
|
|
||||||
|
def mouse_cb(event, x, y, flags, param):
|
||||||
|
nonlocal points, zone_idx
|
||||||
|
if event == cv2.EVENT_LBUTTONDOWN:
|
||||||
|
total = sum(all_sizes)
|
||||||
|
if len(points) < total:
|
||||||
|
points.append((x, y))
|
||||||
|
cur_total = sum(all_sizes[:zone_idx])
|
||||||
|
if zone_idx < len(all_sizes):
|
||||||
|
cur_total += len(points) - sum(all_sizes[:zone_idx])
|
||||||
|
if cur_total >= all_sizes[zone_idx] and zone_idx < len(all_sizes) - 1:
|
||||||
|
zone_idx += 1
|
||||||
|
|
||||||
|
win = "Calibrate Zone"
|
||||||
|
cv2.namedWindow(win, cv2.WINDOW_NORMAL)
|
||||||
|
cv2.resizeWindow(win, min(w, 1280), min(h, 720))
|
||||||
|
cv2.setMouseCallback(win, mouse_cb)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
vis = draw_state(frame, points, zone_idx, all_sizes, all_names)
|
||||||
|
cv2.imshow(win, vis)
|
||||||
|
key = cv2.waitKey(30) & 0xFF
|
||||||
|
|
||||||
|
if key in (ord("q"), 27):
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
print("Quit.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == ord("c"):
|
||||||
|
points.clear()
|
||||||
|
zone_idx = 0
|
||||||
|
print("Cleared.")
|
||||||
|
|
||||||
|
if key == ord("s"):
|
||||||
|
total_needed = sum(all_sizes)
|
||||||
|
if len(points) < total_needed:
|
||||||
|
print(f"Need {total_needed} points, have {len(points)}. Continue clicking.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
preset = {}
|
||||||
|
for i, sz in enumerate(ZONE_SIZES):
|
||||||
|
preset[ZONE_KEYS[i]] = [list(p) for p in points[offset : offset + sz]]
|
||||||
|
offset += sz
|
||||||
|
|
||||||
|
x_start = points[offset][0]
|
||||||
|
x_end = points[offset + 1][0]
|
||||||
|
line_y = min(points[offset][1], points[offset + 1][1])
|
||||||
|
preset["count_line"] = {"x_start": x_start, "x_end": x_end, "y": line_y}
|
||||||
|
|
||||||
|
config = load_zones(zones_path)
|
||||||
|
config.setdefault("presets", {})[preset_name] = preset
|
||||||
|
save_zones(config, zones_path)
|
||||||
|
print(f"Saved preset '{preset_name}' to {zones_path}")
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
return
|
||||||
|
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Interactive zone calibration tool")
|
||||||
|
parser.add_argument("video_path", help="Path to video file")
|
||||||
|
parser.add_argument("--preset", default="calibrated", help="Preset name (default: calibrated)")
|
||||||
|
parser.add_argument("--zones", default="zones.json", help="Path to zones.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
run(args.video_path, args.preset, args.zones)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user