484 lines
15 KiB
Python
484 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
WINDOW_NAME = "ROI Editor"
|
|
POLYGON_WINDOW_NAME = "ROI Polygon Editor"
|
|
|
|
|
|
def to_uint8_image(image):
|
|
array = np.asarray(image)
|
|
if array.dtype == np.uint8:
|
|
return array
|
|
array = np.nan_to_num(array.astype(np.float32), copy=False)
|
|
if array.size > 0 and float(np.max(array)) <= 1.0:
|
|
array = array * 255.0
|
|
return np.clip(array, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def normalize_editor_frame(frame):
|
|
if not isinstance(frame, np.ndarray):
|
|
raise TypeError("frame must be a numpy ndarray with shape HxW or HxWxC")
|
|
|
|
frame = to_uint8_image(frame)
|
|
if frame.ndim == 2:
|
|
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
|
if frame.ndim != 3:
|
|
raise ValueError("frame must have shape HxW or HxWxC")
|
|
|
|
channels = frame.shape[2]
|
|
if channels == 1:
|
|
return cv2.cvtColor(frame[:, :, 0], cv2.COLOR_GRAY2BGR)
|
|
if channels == 3:
|
|
return np.ascontiguousarray(frame)
|
|
if channels == 4:
|
|
return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
|
|
raise ValueError("frame channel count must be 1, 3, or 4")
|
|
|
|
|
|
def polygon_to_mask(points, image_size):
|
|
width, height = int(image_size[0]), int(image_size[1])
|
|
mask = np.zeros((height, width), dtype=np.uint8)
|
|
polygon = np.asarray(points, dtype=np.int32).reshape(-1, 1, 2)
|
|
cv2.fillPoly(mask, [polygon], 255)
|
|
return mask
|
|
|
|
|
|
def scale_points(points, source_size, target_size):
|
|
source_w, source_h = int(source_size[0]), int(source_size[1])
|
|
target_w, target_h = int(target_size[0]), int(target_size[1])
|
|
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
|
if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h):
|
|
pts[:, 0] *= target_w / float(source_w)
|
|
pts[:, 1] *= target_h / float(source_h)
|
|
pts[:, 0] = np.clip(pts[:, 0], 0, max(target_w - 1, 0))
|
|
pts[:, 1] = np.clip(pts[:, 1], 0, max(target_h - 1, 0))
|
|
return np.rint(pts).astype(np.int32)
|
|
|
|
|
|
def normalize_mask(mask, shape):
|
|
if mask is None:
|
|
return np.ones(shape, dtype=np.uint8) * 255
|
|
mask = np.asarray(mask)
|
|
if mask.shape[:2] != shape:
|
|
mask = cv2.resize(mask, (shape[1], shape[0]), interpolation=cv2.INTER_NEAREST)
|
|
return np.where(mask > 0, 255, 0).astype(np.uint8)
|
|
|
|
|
|
def build_roi_payload(image_size, points, mask_path=None, metadata=None):
|
|
"""Build the shared ``roi`` object written into sensor / FFC JSON configs."""
|
|
points = np.asarray(points, dtype=np.int32).reshape(-1, 2)
|
|
if len(points) < 3:
|
|
raise ValueError("polygon ROI requires at least 3 points")
|
|
payload = {
|
|
"type": "polygon",
|
|
"image_size": [int(image_size[0]), int(image_size[1])],
|
|
"points": [[int(x), int(y)] for x, y in points],
|
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
}
|
|
|
|
if mask_path is not None:
|
|
payload["mask_path"] = str(mask_path)
|
|
if metadata:
|
|
payload["metadata"] = metadata
|
|
return payload
|
|
|
|
|
|
def extract_roi_section(data):
|
|
"""Return the ROI dict from either a nested ``roi`` field or a legacy top-level ROI file."""
|
|
if not isinstance(data, dict):
|
|
raise TypeError("ROI config must be a JSON object")
|
|
if isinstance(data.get("roi"), dict):
|
|
return data["roi"]
|
|
return data
|
|
|
|
|
|
def load_roi_config(path):
|
|
path = Path(path)
|
|
with path.open("r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
data["_config_path"] = str(path)
|
|
return data
|
|
|
|
|
|
def save_polygon_roi_config(path, points, image_size, mask_path=None, metadata=None):
|
|
"""Backward-compatible standalone polygon ROI JSON writer."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
data = build_roi_payload(
|
|
image_size,
|
|
points,
|
|
mask_path=mask_path,
|
|
metadata=metadata,
|
|
)
|
|
data["version"] = "1.0"
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
return data
|
|
|
|
|
|
def upsert_roi_in_config(path, roi_payload, *, create_if_missing=True):
|
|
"""Read-modify-write ``roi`` into an existing sensor config, preserving other fields."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.exists():
|
|
with path.open("r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"Config must be a JSON object: {path}")
|
|
elif create_if_missing:
|
|
data = {"version": "1.0"}
|
|
else:
|
|
raise FileNotFoundError(f"Config not found: {path}")
|
|
|
|
data["roi"] = roi_payload
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
return data
|
|
|
|
|
|
def load_roi_mask(config_path, frame_shape):
|
|
config_path = Path(config_path)
|
|
data = load_roi_config(config_path)
|
|
roi = extract_roi_section(data)
|
|
|
|
height, width = frame_shape[:2]
|
|
target_size = (width, height)
|
|
source_size = roi.get("image_size") or data.get("image_size") or [width, height]
|
|
points = roi.get("points")
|
|
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
|
|
if points:
|
|
polygon = scale_points(points, source_size, target_size).reshape(-1, 1, 2)
|
|
output = np.zeros((height, width), dtype=np.uint8)
|
|
cv2.fillPoly(output, [polygon], 255)
|
|
return output
|
|
|
|
mask_path = roi.get("mask_path") or data.get("mask_path") or data.get("roi_mask")
|
|
if mask_path:
|
|
mask_path = Path(mask_path)
|
|
if not mask_path.is_absolute():
|
|
mask_path = config_path.parent / mask_path
|
|
loaded = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE)
|
|
if loaded is None:
|
|
raise FileNotFoundError(f"Failed to read ROI mask: {mask_path}")
|
|
return normalize_mask(loaded, (height, width))
|
|
|
|
raise ValueError(f"Unsupported ROI config: {config_path}")
|
|
|
|
|
|
def resolve_video_source(video):
|
|
"""Accept camera index (int/str digit) or filesystem path."""
|
|
if isinstance(video, int):
|
|
return video
|
|
text = str(video).strip()
|
|
if text.isdigit():
|
|
return int(text)
|
|
return text
|
|
|
|
|
|
def open_video_capture(video):
|
|
source = resolve_video_source(video)
|
|
# Avoid MSMF HW transform stalls on Windows camera capture.
|
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
if isinstance(source, int):
|
|
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
|
|
if not cap.isOpened():
|
|
cap.release()
|
|
cap = cv2.VideoCapture(source)
|
|
else:
|
|
path = Path(source)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Video file not found: {path}")
|
|
cap = cv2.VideoCapture(str(path))
|
|
if not cap.isOpened():
|
|
raise RuntimeError(f"Failed to open video source: {video}")
|
|
return cap
|
|
|
|
|
|
def capture_frame(cap, *, max_attempts=60):
|
|
"""Grab the next readable frame from an opened capture."""
|
|
last_error = None
|
|
for _ in range(max_attempts):
|
|
ok, frame = cap.read()
|
|
if ok and frame is not None and getattr(frame, "size", 0) > 0:
|
|
return normalize_editor_frame(frame)
|
|
last_error = "empty frame" if ok else "read failed"
|
|
raise RuntimeError(f"Failed to capture frame from video source ({last_error})")
|
|
|
|
|
|
def capture_frame_from_source(video):
|
|
cap = open_video_capture(video)
|
|
try:
|
|
return capture_frame(cap)
|
|
finally:
|
|
cap.release()
|
|
|
|
|
|
class RoiPolygonEditor:
|
|
def __init__(self, frame):
|
|
self.frame = normalize_editor_frame(frame)
|
|
self.points = []
|
|
|
|
def set_frame(self, frame):
|
|
self.frame = normalize_editor_frame(frame)
|
|
self.points = []
|
|
|
|
def on_mouse(self, event, x, y, flags, param):
|
|
if event == cv2.EVENT_LBUTTONDOWN:
|
|
self.points.append((int(x), int(y)))
|
|
elif event == cv2.EVENT_RBUTTONDOWN and self.points:
|
|
self.points.pop()
|
|
|
|
def clear(self):
|
|
self.points = []
|
|
|
|
def undo(self):
|
|
if self.points:
|
|
self.points.pop()
|
|
|
|
def render(self):
|
|
view = self.frame.copy()
|
|
if len(self.points) >= 3:
|
|
overlay = view.copy()
|
|
polygon = np.asarray(self.points, dtype=np.int32).reshape(-1, 1, 2)
|
|
cv2.fillPoly(overlay, [polygon], (0, 128, 255))
|
|
view = cv2.addWeighted(overlay, 0.25, view, 0.75, 0.0)
|
|
cv2.polylines(view, [polygon], True, (0, 255, 255), 2, cv2.LINE_AA)
|
|
elif len(self.points) >= 2:
|
|
cv2.polylines(
|
|
view,
|
|
[np.asarray(self.points, dtype=np.int32).reshape(-1, 1, 2)],
|
|
False,
|
|
(0, 255, 255),
|
|
2,
|
|
cv2.LINE_AA,
|
|
)
|
|
|
|
for idx, (x, y) in enumerate(self.points):
|
|
cv2.circle(view, (x, y), 4, (0, 255, 0), -1, cv2.LINE_AA)
|
|
cv2.putText(
|
|
view,
|
|
str(idx + 1),
|
|
(x + 6, y - 6),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
0.45,
|
|
(0, 255, 0),
|
|
1,
|
|
cv2.LINE_AA,
|
|
)
|
|
|
|
lines = [
|
|
"LClick add RClick/U undo C clear R recapture S/Enter save Q/Esc quit",
|
|
f"points={len(self.points)} resolution={view.shape[1]}x{view.shape[0]}",
|
|
]
|
|
for i, text in enumerate(lines):
|
|
y = 24 + i * 24
|
|
cv2.putText(view, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 3, cv2.LINE_AA)
|
|
cv2.putText(view, text, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINE_AA)
|
|
return view
|
|
|
|
|
|
def build_roi_result(
|
|
points,
|
|
image_size,
|
|
mask,
|
|
config=None,
|
|
config_path=None,
|
|
mask_path=None,
|
|
):
|
|
result = {
|
|
"type": "polygon",
|
|
"points": [[int(x), int(y)] for x, y in points] if points is not None else None,
|
|
"image_size": [int(image_size[0]), int(image_size[1])],
|
|
"mask": mask,
|
|
"config": config,
|
|
"config_path": str(config_path) if config_path is not None else None,
|
|
"mask_path": str(mask_path) if mask_path is not None else None,
|
|
}
|
|
return result
|
|
|
|
|
|
def _save_roi_outputs(
|
|
*,
|
|
image_size,
|
|
output,
|
|
points,
|
|
metadata=None,
|
|
save_mask=True,
|
|
nest_in_config=True,
|
|
):
|
|
mask = polygon_to_mask(points, image_size)
|
|
config = None
|
|
config_path = None
|
|
mask_path = None
|
|
|
|
if output is not None:
|
|
config_path = Path(output)
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
saved_mask = None
|
|
if save_mask:
|
|
mask_path = config_path.with_name(config_path.stem + "_mask.png")
|
|
cv2.imwrite(str(mask_path), mask)
|
|
saved_mask = mask_path.name
|
|
|
|
roi_payload = build_roi_payload(
|
|
image_size,
|
|
points,
|
|
mask_path=saved_mask,
|
|
metadata=metadata,
|
|
)
|
|
if nest_in_config:
|
|
config = upsert_roi_in_config(config_path, roi_payload)
|
|
else:
|
|
# Standalone ROI file (legacy FFC polygon style).
|
|
standalone = dict(roi_payload)
|
|
standalone["version"] = "1.0"
|
|
with config_path.open("w", encoding="utf-8") as f:
|
|
json.dump(standalone, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
config = standalone
|
|
|
|
return build_roi_result(
|
|
points,
|
|
image_size,
|
|
mask,
|
|
config=config,
|
|
config_path=config_path,
|
|
mask_path=mask_path,
|
|
)
|
|
|
|
|
|
def select_polygon_roi(
|
|
frame,
|
|
output=None,
|
|
window_name=POLYGON_WINDOW_NAME,
|
|
metadata=None,
|
|
save_mask=True,
|
|
destroy_window=True,
|
|
recapture_callback=None,
|
|
nest_in_config=False,
|
|
):
|
|
"""Open an OpenCV window, draw a polygon ROI, and return points/mask/config info."""
|
|
editor = RoiPolygonEditor(frame)
|
|
cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE)
|
|
cv2.setMouseCallback(window_name, editor.on_mouse)
|
|
|
|
try:
|
|
while True:
|
|
cv2.imshow(window_name, editor.render())
|
|
key = cv2.waitKey(16) & 0xFF
|
|
if key in (27, ord("q")):
|
|
return None
|
|
if key in (8, ord("u")):
|
|
editor.undo()
|
|
elif key == ord("c"):
|
|
editor.clear()
|
|
elif key == ord("r") and recapture_callback is not None:
|
|
editor.set_frame(recapture_callback())
|
|
elif key in (13, ord("s")):
|
|
if len(editor.points) < 3:
|
|
print("Need at least 3 points to save ROI.")
|
|
continue
|
|
|
|
image_size = (editor.frame.shape[1], editor.frame.shape[0])
|
|
return _save_roi_outputs(
|
|
image_size=image_size,
|
|
output=output,
|
|
points=editor.points,
|
|
metadata=metadata,
|
|
save_mask=save_mask,
|
|
nest_in_config=nest_in_config,
|
|
)
|
|
finally:
|
|
if destroy_window:
|
|
cv2.destroyWindow(window_name)
|
|
|
|
|
|
select_roi = select_polygon_roi
|
|
|
|
|
|
def parse_args(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
description="Capture a frame from a camera/video and save a polygon ROI to JSON."
|
|
)
|
|
parser.add_argument(
|
|
"--video",
|
|
"-v",
|
|
required=True,
|
|
help="Camera index (e.g. 0) or path to a video file",
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
"-c",
|
|
required=True,
|
|
help="Output JSON config path (existing sensor configs are updated in place)",
|
|
)
|
|
parser.add_argument(
|
|
"--frame-output",
|
|
default=None,
|
|
help="Optional path to save the captured reference frame (PNG/JPG)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-mask",
|
|
action="store_true",
|
|
help="Do not write a companion *_mask.png next to the config",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv=None):
|
|
args = parse_args(argv)
|
|
video = resolve_video_source(args.video)
|
|
cap = open_video_capture(video)
|
|
|
|
def recapture():
|
|
# For file sources, rewind so R always gets a usable frame.
|
|
if not isinstance(video, int):
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
return capture_frame(cap)
|
|
|
|
try:
|
|
frame = capture_frame(cap)
|
|
if args.frame_output:
|
|
frame_path = Path(args.frame_output)
|
|
frame_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cv2.imwrite(str(frame_path), frame)
|
|
print(f"Saved reference frame: {frame_path}")
|
|
|
|
result = select_polygon_roi(
|
|
frame,
|
|
output=args.config,
|
|
metadata={"source": str(args.video)},
|
|
save_mask=not args.no_mask,
|
|
recapture_callback=recapture,
|
|
nest_in_config=True,
|
|
)
|
|
finally:
|
|
cap.release()
|
|
cv2.destroyAllWindows()
|
|
|
|
if result is None:
|
|
print("ROI capture cancelled.")
|
|
return 1
|
|
|
|
print(f"Saved ROI type={result['type']} image_size={result['image_size']}")
|
|
if result.get("points") is not None:
|
|
print(f"points={result['points']}")
|
|
if result.get("config_path"):
|
|
print(f"config={result['config_path']}")
|
|
if result.get("mask_path"):
|
|
print(f"mask={result['mask_path']}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|