feat: interactive zone calibration tool

This commit is contained in:
jetson committed 2026-09-22 13:10:45 +07:00
1 parent f0ea95dd26
commit 859d6a0f0f
1 file changed
+175
+175
View File
@@ -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()