From 59d1e51ea4cbeafed7cfea940047650c43861b21 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Tue, 28 Apr 2026 09:18:15 -0400 Subject: [PATCH 01/10] improved football anylyzer pipeline. Signed-off-by: Marcus Edel --- plugins/python/football_analyzer.py | 784 ++++++++++++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 plugins/python/football_analyzer.py diff --git a/plugins/python/football_analyzer.py b/plugins/python/football_analyzer.py new file mode 100644 index 0000000..e54873f --- /dev/null +++ b/plugins/python/football_analyzer.py @@ -0,0 +1,784 @@ +# FootballAnalyzer +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os +import pickle + +from log.global_logger import GlobalLogger + +CAN_REGISTER_ELEMENT = True +try: + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + gi.require_version("GstVideo", "1.0") + from gi.repository import Gst, GstBase, GstVideo, GObject # noqa: E402 + + import cv2 + import numpy as np + import supervision as sv + from ultralytics import YOLO + + from log.logger_factory import LoggerFactory # noqa: E402 + + VIDEO_CAPS = Gst.Caps.from_string("video/x-raw, format=BGR") + +except ImportError as e: + CAN_REGISTER_ELEMENT = False + GlobalLogger().warning( + f"The 'pyml_football_analyzer' element will not be available. Error: {e}" + ) + + +def get_center_of_bbox(bbox): + x1, y1, x2, y2 = bbox + return int((x1 + x2) / 2), int((y1 + y2) / 2) + + +def get_bbox_width(bbox): + return bbox[2] - bbox[0] + + +class Tracker: + """Tracker. + """ + + def __init__(self, model_path): + self.model = YOLO(model_path) + self.tracker = sv.ByteTrack() + self.sift = cv2.SIFT_create() + self.matcher = cv2.BFMatcher(cv2.NORM_L2) + + def _foreground_mask(self, shape, frame_tracks, dilation=15): + h, w = shape[:2] + mask = np.full((h, w), 255, dtype=np.uint8) + bboxes = [] + for key in ("players", "referees", "ball"): + for obj in frame_tracks.get(key, {}).values(): + bboxes.append(obj["bbox"]) + for bbox in bboxes: + x1 = max(0, int(bbox[0]) - dilation) + y1 = max(0, int(bbox[1]) - dilation) + x2 = min(w, int(bbox[2]) + dilation) + y2 = min(h, int(bbox[3]) + dilation) + mask[y1:y2, x1:x2] = 0 + return mask + + def get_camera_motion(self, frames, tracks, read_from_stub=False, stub_path=None, + ratio=0.75, ransac_thresh=3.0, min_matches=8): + if read_from_stub and stub_path is not None and os.path.exists(stub_path): + with open(stub_path, 'rb') as f: + return pickle.load(f) + + cumulative = [np.eye(3, dtype=np.float64)] + prev_gray = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY) + prev_mask = self._foreground_mask(frames[0].shape, + {k: tracks[k][0] for k in tracks}) + prev_kp, prev_desc = self.sift.detectAndCompute(prev_gray, prev_mask) + + for i in range(1, len(frames)): + curr_gray = cv2.cvtColor(frames[i], cv2.COLOR_BGR2GRAY) + curr_mask = self._foreground_mask(frames[i].shape, + {k: tracks[k][i] for k in tracks}) + curr_kp, curr_desc = self.sift.detectAndCompute(curr_gray, curr_mask) + + H_step = np.eye(3, dtype=np.float64) + if prev_desc is not None and curr_desc is not None and len(prev_desc) >= 2 and len(curr_desc) >= 2: + knn = self.matcher.knnMatch(prev_desc, curr_desc, k=2) + good = [m for pair in knn if len(pair) == 2 for m, n in [pair] if m.distance < ratio * n.distance] + if len(good) >= min_matches: + pts_prev = np.float32([prev_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) + pts_curr = np.float32([curr_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) + H, _ = cv2.findHomography(pts_prev, pts_curr, cv2.RANSAC, ransac_thresh) + if H is not None: + H_step = H + + cumulative.append(H_step @ cumulative[-1]) + prev_kp, prev_desc = curr_kp, curr_desc + + if stub_path is not None: + with open(stub_path, 'wb') as f: + pickle.dump(cumulative, f) + return cumulative + + def detect_frames(self, frames): + batch_size = 20 + detections = [] + for i in range(0, len(frames), batch_size): + detections_batch = self.model.predict(frames[i: i + batch_size], conf=0.1) + detections += detections_batch + return detections + + def get_object_tracks(self, frames, read_from_stub=False, stub_path=None): + if read_from_stub and stub_path is not None and os.path.exists(stub_path): + with open(stub_path, 'rb') as f: + tracks = pickle.load(f) + return tracks + + detections = self.detect_frames(frames) + tracks = {"players": [], "referees": [], "ball": []} + + per_frame = [] + class_votes = {} + for detection in detections: + cls_names = detection.names + cls_names_inv = {v: k for k, v in cls_names.items()} + + detection_supervision = sv.Detections.from_ultralytics(detection) + + for object_ind, class_id in enumerate(detection_supervision.class_id): + if cls_names[class_id] == "goalkeeper": + detection_supervision.class_id[object_ind] = cls_names_inv["player"] + + tracked = self.tracker.update_with_detections(detection_supervision) + per_frame.append((tracked, detection_supervision, cls_names, cls_names_inv)) + + for fd in tracked: + cls_name = cls_names[fd[3]] + track_id = fd[4] + if cls_name in ("player", "referee"): + v = class_votes.setdefault(track_id, {"player": 0, "referee": 0}) + v[cls_name] += 1 + + track_class = { + tid: ("player" if v["player"] >= v["referee"] else "referee") + for tid, v in class_votes.items() + } + + for frame_num, (tracked, raw_detections, cls_names, cls_names_inv) in enumerate(per_frame): + tracks["players"].append({}) + tracks["referees"].append({}) + tracks["ball"].append({}) + + for fd in tracked: + bbox = fd[0].tolist() + track_id = fd[4] + stable_cls = track_class.get(track_id) + if stable_cls == "player": + tracks["players"][frame_num][track_id] = {"bbox": bbox} + elif stable_cls == "referee": + tracks["referees"][frame_num][track_id] = {"bbox": bbox} + + for fd in raw_detections: + if fd[3] == cls_names_inv['ball']: + tracks["ball"][frame_num][1] = {"bbox": fd[0].tolist()} + + if stub_path is not None: + with open(stub_path, 'wb') as f: + pickle.dump(tracks, f) + + return tracks + + def draw_ellipse(self, frame, bbox, color, track_id=None): + y2 = int(bbox[3]) + x_center, _ = get_center_of_bbox(bbox) + width = get_bbox_width(bbox) + + cv2.ellipse( + frame, + center=(x_center, y2), + axes=(int(width), int(0.35 * width)), + angle=0.0, + startAngle=-45, + endAngle=235, + color=color, + thickness=2, + lineType=cv2.LINE_4, + ) + + rectangle_width = 40 + rectangle_height = 20 + x1_rect = x_center - rectangle_width // 2 + x2_rect = x_center + rectangle_width // 2 + y1_rect = (y2 - rectangle_height // 2) + 15 + y2_rect = (y2 + rectangle_height // 2) + 15 + + if track_id is not None: + cv2.rectangle(frame, (int(x1_rect), int(y1_rect)), + (int(x2_rect), int(y2_rect)), color, cv2.FILLED) + x1_text = x1_rect + 12 + if track_id > 99: + x1_text -= 10 + cv2.putText(frame, f"{track_id}", + (int(x1_text), int(y1_rect + 15)), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2) + return frame + + def draw_traingle(self, frame, bbox, color): + y = int(bbox[1]) + x, _ = get_center_of_bbox(bbox) + triangle_points = np.array([ + [x, y], + [x - 10, y - 20], + [x + 10, y - 20], + ]) + cv2.drawContours(frame, [triangle_points], 0, color, cv2.FILLED) + cv2.drawContours(frame, [triangle_points], 0, (0, 0, 0), 2) + return frame + + def classify_jersey(self, frame, bbox): + x1, y1, x2, y2 = [int(v) for v in bbox] + h_box, w_box = y2 - y1, x2 - x1 + if h_box <= 0 or w_box <= 0: + return None + jy1 = y1 + int(0.15 * h_box) + jy2 = y1 + int(0.55 * h_box) + jx1 = x1 + int(0.25 * w_box) + jx2 = x1 + int(0.75 * w_box) + H, W = frame.shape[:2] + jy1, jy2 = max(0, jy1), min(H, jy2) + jx1, jx2 = max(0, jx1), min(W, jx2) + if jy2 - jy1 < 3 or jx2 - jx1 < 3: + return None + patch = frame[jy1:jy2, jx1:jx2] + hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV) + s_v = (hsv[..., 1] > 80) & (hsv[..., 2] > 50) + h = hsv[..., 0] + red = (((h <= 10) | (h >= 170)) & s_v).sum() + blue = ((h >= 100) & (h <= 130) & s_v).sum() + min_pixels = max(20, int(0.02 * patch.shape[0] * patch.shape[1])) + if red < min_pixels and blue < min_pixels: + return None + return "red" if red >= blue else "blue" + + def _ref_bottom_center(self, bbox, H_inv): + xc, _ = get_center_of_bbox(bbox) + yb = int(bbox[3]) + pt = cv2.perspectiveTransform( + np.array([[[xc, yb]]], dtype=np.float32), H_inv + )[0][0] + return float(pt[0]), float(pt[1]) + + def _minimap_extent(self, tracks, camera_motion): + xs, ys = [], [] + n = len(tracks["players"]) + for i in range(n): + H_inv = np.linalg.inv(camera_motion[i]) if camera_motion is not None else np.eye(3) + for key in ("players", "referees"): + for p in tracks[key][i].values(): + x, y = self._ref_bottom_center(p["bbox"], H_inv) + xs.append(x); ys.append(y) + if not xs: + return None + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + pad_x = 0.05 * max(1.0, max_x - min_x) + pad_y = 0.05 * max(1.0, max_y - min_y) + return min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y + + def _make_minimap_bg(self, mm_w, mm_h): + bg = np.full((mm_h, mm_w, 3), (40, 110, 40), dtype=np.uint8) + cv2.rectangle(bg, (2, 2), (mm_w - 3, mm_h - 3), (240, 240, 240), 2) + cv2.line(bg, (mm_w // 2, 2), (mm_w // 2, mm_h - 3), (240, 240, 240), 1) + cv2.circle(bg, (mm_w // 2, mm_h // 2), max(10, mm_h // 8), (240, 240, 240), 1) + return bg + + def _project_to_minimap(self, extent, mm_w, mm_h, x, y): + min_x, min_y, max_x, max_y = extent + dx = max(1e-6, max_x - min_x) + dy = max(1e-6, max_y - min_y) + scale = min((mm_w - 10) / dx, (mm_h - 10) / dy) + off_x = (mm_w - scale * dx) / 2.0 + off_y = (mm_h - scale * dy) / 2.0 + return int(off_x + (x - min_x) * scale), int(off_y + (y - min_y) * scale) + + def _smooth_points(self, pts, window): + if window <= 1 or len(pts) < 2: + return pts + pts = np.asarray(pts, dtype=np.float32) + n = len(pts) + half = window // 2 + smoothed = np.empty_like(pts) + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + smoothed[i] = pts[lo:hi].mean(axis=0) + return smoothed + + def draw_trail(self, frame, points, color): + if len(points) < 2: + return frame + pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(frame, [pts], isClosed=False, color=color, thickness=2, lineType=cv2.LINE_AA) + return frame + + def _point_to_bbox_distance(self, px, py, bbox): + x1, y1, x2, y2 = bbox + dx = max(x1 - px, 0.0, px - x2) + dy = max(y1 - py, 0.0, py - y2) + return float(np.hypot(dx, dy)) + + def _ball_contact(self, player_dict, ball_bbox, contact_pad_ratio): + bx, by = get_center_of_bbox(ball_bbox) + best_tid, best_d, best_bbox = None, float("inf"), None + for tid, player in player_dict.items(): + d = self._point_to_bbox_distance(bx, by, player["bbox"]) + if d < best_d: + best_tid, best_d, best_bbox = tid, d, player["bbox"] + if best_bbox is None: + return None + w_box = best_bbox[2] - best_bbox[0] + h_box = best_bbox[3] - best_bbox[1] + if best_d > contact_pad_ratio * max(w_box, h_box): + return None + return best_tid + + def _count_total_contacts(self, tracks, contact_gap_frames, contact_pad_ratio): + totals = {} + last_contact_frame = {} + for frame_num, (player_dict, ball_dict) in enumerate(zip(tracks["players"], tracks["ball"])): + ball = ball_dict.get(1) + if ball is None or not player_dict: + continue + tid = self._ball_contact(player_dict, ball["bbox"], contact_pad_ratio) + if tid is None: + continue + last = last_contact_frame.get(tid) + if last is None or (frame_num - last) > contact_gap_frames: + totals[tid] = totals.get(tid, 0) + 1 + last_contact_frame[tid] = frame_num + return totals + + def draw_player_hud(self, frame, player_id, contacts, distance_m, color, headshot=None): + x, y = 10, 10 + bg_color = (131, 41, 92) + text_color = (47, 186, 64) + if headshot is not None: + hh, hw = headshot.shape[:2] + w, h = hw + 280, max(110, hh + 20) + text_x = x + hw + 20 + else: + w, h = 320, 100 + text_x = x + 12 + cv2.rectangle(frame, (x, y), (x + w, y + h), bg_color, cv2.FILLED) + cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) + if headshot is not None: + hy, hx = y + 10, x + 10 + frame[hy:hy + headshot.shape[0], hx:hx + headshot.shape[1]] = headshot + cv2.rectangle(frame, (hx, hy), + (hx + headshot.shape[1], hy + headshot.shape[0]), color, 2) + cv2.putText(frame, "Player #8", (text_x, y + 28), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, text_color, 2) + cv2.putText(frame, f"Ball contacts: {contacts}", (text_x, y + 58), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, text_color, 1) + cv2.putText(frame, f"Distance: {distance_m:.1f} m", (text_x, y + 85), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, text_color, 1) + return frame + + def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_length=30, + contact_gap_frames=5, contact_pad_ratio=0.25, + player_height_m=1.8, headshot_path=None, headshot_size=90, + logo_path=None, logo_height=80, logo_margin=15, + trail_smooth_window=11, + show_minimap=True, minimap_size=(320, 200), minimap_margin=15): + output_video_frames = [] + player_trails = {} + team_votes = {} + team_bgr = {"red": (0, 0, 255), "blue": (255, 0, 0)} + default_color = (200, 200, 200) + + frames_count = {} + for frame_players in tracks["players"]: + for tid in frame_players: + frames_count[tid] = frames_count.get(tid, 0) + 1 + total_contacts = self._count_total_contacts(tracks, contact_gap_frames, contact_pad_ratio) + + heights = [p["bbox"][3] - p["bbox"][1] + for frame_players in tracks["players"] + for p in frame_players.values() + if p["bbox"][3] > p["bbox"][1]] + px_per_meter = float(np.median(heights)) / player_height_m if heights else 1.0 + + headshot = None + if headshot_path is not None and os.path.exists(headshot_path): + img = cv2.imread(headshot_path) + if img is not None: + headshot = cv2.resize(img, (headshot_size, headshot_size), interpolation=cv2.INTER_AREA) + + logo_bgr, logo_alpha = None, None + if logo_path is not None and os.path.exists(logo_path): + img = cv2.imread(logo_path, cv2.IMREAD_UNCHANGED) + if img is not None: + scale = logo_height / img.shape[0] + new_w = max(1, int(round(img.shape[1] * scale))) + img = cv2.resize(img, (new_w, logo_height), interpolation=cv2.INTER_LANCZOS4) + if img.ndim == 3 and img.shape[2] == 4: + logo_bgr = img[..., :3] + logo_alpha = (img[..., 3:4].astype(np.float32)) / 255.0 + else: + logo_bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + + minimap_bg, minimap_extent = None, None + if show_minimap: + minimap_extent = self._minimap_extent(tracks, camera_motion) + if minimap_extent is not None: + minimap_bg = self._make_minimap_bg(minimap_size[0], minimap_size[1]) + + if total_contacts: + focal_tid = max(total_contacts, + key=lambda t: (total_contacts[t], frames_count.get(t, 0))) + elif frames_count: + focal_tid = max(frames_count, key=frames_count.get) + else: + focal_tid = None + + last_ref_pt = {} + player_distance = {} + player_contacts = {} + last_contact_frame = {} + for frame_num, frame in enumerate(video_frames): + frame = frame.copy() + + player_dict = tracks["players"][frame_num] + ball_dict = tracks["ball"][frame_num] + referee_dict = tracks["referees"][frame_num] + + H_cum = camera_motion[frame_num] if camera_motion is not None else np.eye(3) + H_inv = np.linalg.inv(H_cum) + + active_ids = set(player_dict.keys()) + for track_id, player in player_dict.items(): + x_center, _ = get_center_of_bbox(player["bbox"]) + y_bottom = int(player["bbox"][3]) + ref_pt = cv2.perspectiveTransform( + np.array([[[x_center, y_bottom]]], dtype=np.float32), H_inv + )[0][0] + ref_tuple = (float(ref_pt[0]), float(ref_pt[1])) + player_trails.setdefault(track_id, []).append(ref_tuple) + if len(player_trails[track_id]) > trail_length: + player_trails[track_id] = player_trails[track_id][-trail_length:] + + if track_id in last_ref_pt: + dx = ref_tuple[0] - last_ref_pt[track_id][0] + dy = ref_tuple[1] - last_ref_pt[track_id][1] + player_distance[track_id] = player_distance.get(track_id, 0.0) + float(np.hypot(dx, dy)) + last_ref_pt[track_id] = ref_tuple + + vote = self.classify_jersey(frame, player["bbox"]) + if vote is not None: + counts = team_votes.setdefault(track_id, {"red": 0, "blue": 0}) + counts[vote] += 1 + for track_id in list(player_trails.keys()): + if track_id not in active_ids: + del player_trails[track_id] + last_ref_pt.pop(track_id, None) + + ball = ball_dict.get(1) + if ball is not None and player_dict: + tid = self._ball_contact(player_dict, ball["bbox"], contact_pad_ratio) + if tid is not None: + last = last_contact_frame.get(tid) + if last is None or (frame_num - last) > contact_gap_frames: + player_contacts[tid] = player_contacts.get(tid, 0) + 1 + last_contact_frame[tid] = frame_num + + focal_color = (131, 41, 92) + + def color_for(track_id): + if track_id == focal_tid: + return focal_color + counts = team_votes.get(track_id) + if not counts or (counts["red"] == 0 and counts["blue"] == 0): + return default_color + return team_bgr["red"] if counts["red"] >= counts["blue"] else team_bgr["blue"] + + for track_id, ref_points in player_trails.items(): + smoothed_ref = self._smooth_points(ref_points, trail_smooth_window) + pts = cv2.perspectiveTransform( + np.asarray(smoothed_ref, dtype=np.float32).reshape(-1, 1, 2), H_cum + ).reshape(-1, 2) + frame = self.draw_trail(frame, pts.tolist(), color_for(track_id)) + + for track_id, player in player_dict.items(): + frame = self.draw_ellipse(frame, player["bbox"], color_for(track_id)) + + for _, referee in referee_dict.items(): + frame = self.draw_ellipse(frame, referee["bbox"], (0, 255, 255)) + + for track_id, ball in ball_dict.items(): + frame = self.draw_traingle(frame, ball["bbox"], (0, 255, 0)) + + if focal_tid is not None: + frame = self.draw_player_hud( + frame, + focal_tid, + player_contacts.get(focal_tid, 0), + player_distance.get(focal_tid, 0.0) / px_per_meter, + color_for(focal_tid), + headshot=headshot, + ) + + if logo_bgr is not None: + lh, lw = logo_bgr.shape[:2] + fh, fw = frame.shape[:2] + x0 = max(0, fw - lw - logo_margin) + y0 = logo_margin + x1, y1 = x0 + lw, y0 + lh + if logo_alpha is not None: + roi = frame[y0:y1, x0:x1].astype(np.float32) + blended = roi * (1.0 - logo_alpha) + logo_bgr.astype(np.float32) * logo_alpha + frame[y0:y1, x0:x1] = blended.astype(np.uint8) + else: + frame[y0:y1, x0:x1] = logo_bgr + + if minimap_bg is not None and minimap_extent is not None: + mm = minimap_bg.copy() + mm_w, mm_h = minimap_size + for tid, player in player_dict.items(): + rx, ry = self._ref_bottom_center(player["bbox"], H_inv) + mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, rx, ry) + dot_color = color_for(tid) + radius = 6 if tid == focal_tid else 4 + cv2.circle(mm, (mx, my), radius, dot_color, cv2.FILLED) + cv2.circle(mm, (mx, my), radius, (0, 0, 0), 1) + for referee in referee_dict.values(): + rx, ry = self._ref_bottom_center(referee["bbox"], H_inv) + mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, rx, ry) + cv2.circle(mm, (mx, my), 3, (0, 255, 255), cv2.FILLED) + cv2.circle(mm, (mx, my), 3, (0, 0, 0), 1) + ball = ball_dict.get(1) + if ball is not None: + bx, by = get_center_of_bbox(ball["bbox"]) + bref = cv2.perspectiveTransform( + np.array([[[bx, by]]], dtype=np.float32), H_inv + )[0][0] + mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, + float(bref[0]), float(bref[1])) + cv2.circle(mm, (mx, my), 4, (0, 255, 0), cv2.FILLED) + cv2.circle(mm, (mx, my), 4, (0, 0, 0), 1) + fh, fw = frame.shape[:2] + x0 = max(0, fw - mm_w - minimap_margin) + y0 = max(0, fh - mm_h - minimap_margin) + frame[y0:y0 + mm_h, x0:x0 + mm_w] = mm + + output_video_frames.append(frame) + + return output_video_frames + + +class FootballAnalyzer(GstBase.BaseTransform): + """ + Buffers every incoming video frame, then on EOS runs the full batch + pipeline (YOLO detection, ByteTrack with whole-clip class voting, + SIFT/RANSAC camera motion, annotated drawing with trails / HUD / + logo / minimap) and pushes the annotated frames downstream before + forwarding EOS. + """ + + __gstmetadata__ = ( + "Football Analyzer", + "Filter/Effect/Video", + "Runs football_analysis (YOLO + ByteTrack + SIFT camera motion + " + "annotated drawing) on the full clip and emits annotated frames on EOS", + "Marcus Edel ", + ) + + src_template = Gst.PadTemplate.new( + "src", + Gst.PadDirection.SRC, + Gst.PadPresence.ALWAYS, + VIDEO_CAPS.copy(), + ) + sink_template = Gst.PadTemplate.new( + "sink", + Gst.PadDirection.SINK, + Gst.PadPresence.ALWAYS, + VIDEO_CAPS.copy(), + ) + __gsttemplates__ = (src_template, sink_template) + + model_path = GObject.Property( + type=str, + default="", + nick="Model Path", + blurb="Path to the YOLO weights (must be set before processing)", + flags=GObject.ParamFlags.READWRITE, + ) + + headshot_path = GObject.Property( + type=str, + default="", + nick="Headshot Path", + blurb="Optional headshot image for the focal-player HUD", + flags=GObject.ParamFlags.READWRITE, + ) + + logo_path = GObject.Property( + type=str, + default="", + nick="Logo Path", + blurb="Optional top-right logo overlay", + flags=GObject.ParamFlags.READWRITE, + ) + + tracks_stub_path = GObject.Property( + type=str, + default="", + nick="Tracks Stub Path", + blurb="Optional pickle path for cached object tracks (read & written)", + flags=GObject.ParamFlags.READWRITE, + ) + + camera_motion_stub_path = GObject.Property( + type=str, + default="", + nick="Camera Motion Stub Path", + blurb="Optional pickle path for cached camera-motion homographies (read & written)", + flags=GObject.ParamFlags.READWRITE, + ) + + show_minimap = GObject.Property( + type=bool, + default=True, + nick="Show Minimap", + blurb="Render the bottom-right minimap overlay", + flags=GObject.ParamFlags.READWRITE, + ) + + def __init__(self): + super().__init__() + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self._frames = [] + self._pts = [] + self._duration = [] + self._width = 0 + self._height = 0 + self._tracker = None + + def _ensure_tracker(self): + if self._tracker is not None: + return self._tracker + if not self.model_path or not os.path.exists(self.model_path): + raise FileNotFoundError(f"YOLO model not found: {self.model_path!r}") + self.logger.info(f"Loading FootballAnalyzer Tracker from {self.model_path}") + self._tracker = Tracker(self.model_path) + return self._tracker + + def do_set_caps(self, incaps, outcaps): + info = GstVideo.VideoInfo.new_from_caps(incaps) + self._width = info.width + self._height = info.height + return True + + def do_transform_ip(self, buf): + try: + ok, mapinfo = buf.map(Gst.MapFlags.READ) + if not ok: + self.logger.error("Failed to map incoming buffer for read") + return Gst.FlowReturn.ERROR + try: + frame = np.frombuffer(mapinfo.data, dtype=np.uint8).reshape( + self._height, self._width, 3 + ).copy() + finally: + buf.unmap(mapinfo) + + self._frames.append(frame) + self._pts.append(buf.pts) + self._duration.append(buf.duration) + return Gst.FlowReturn.OK + + except Exception as e: + self.logger.error(f"FootballAnalyzer chain error: {e}") + return Gst.FlowReturn.ERROR + + def do_sink_event(self, event): + if event.type == Gst.EventType.EOS: + try: + self._run_pipeline_and_push() + except Exception as e: + self.logger.error(f"FootballAnalyzer EOS processing failed: {e}") + # Forward EOS regardless so the pipeline shuts down cleanly. + return GstBase.BaseTransform.do_sink_event(self, event) + + def _run_pipeline_and_push(self): + if not self._frames: + self.logger.info("FootballAnalyzer: no frames buffered, skipping") + return + + tracker = self._ensure_tracker() + n = len(self._frames) + self.logger.info(f"FootballAnalyzer: running pipeline on {n} frames") + + tracks_stub = self.tracks_stub_path or None + cam_stub = self.camera_motion_stub_path or None + headshot = self.headshot_path or None + logo = self.logo_path or None + + tracks = tracker.get_object_tracks( + self._frames, + read_from_stub=tracks_stub is not None and os.path.exists(tracks_stub), + stub_path=tracks_stub, + ) + camera_motion = tracker.get_camera_motion( + self._frames, + tracks, + read_from_stub=cam_stub is not None and os.path.exists(cam_stub), + stub_path=cam_stub, + ) + annotated = tracker.draw_annotations( + self._frames, + tracks, + camera_motion=camera_motion, + headshot_path=headshot, + logo_path=logo, + show_minimap=self.show_minimap, + ) + + if len(annotated) != n: + self.logger.warning( + f"draw_annotations returned {len(annotated)} frames for {n} inputs; " + "padding/truncating to match" + ) + if len(annotated) < n: + annotated = list(annotated) + [annotated[-1]] * (n - len(annotated)) + else: + annotated = annotated[:n] + + srcpad = self.srcpad + for i, out in enumerate(annotated): + data = np.ascontiguousarray(out, dtype=np.uint8).tobytes() + outbuf = Gst.Buffer.new_allocate(None, len(data), None) + outbuf.fill(0, data) + outbuf.pts = self._pts[i] + outbuf.duration = self._duration[i] + ret = srcpad.push(outbuf) + if ret != Gst.FlowReturn.OK: + self.logger.error( + f"Pushing annotated frame {i} failed with {ret}; aborting" + ) + break + + self._frames.clear() + self._pts.clear() + self._duration.clear() + + +if CAN_REGISTER_ELEMENT: + GObject.type_register(FootballAnalyzer) + __gstelementfactory__ = ( + "pyml_football_analyzer", + Gst.Rank.NONE, + FootballAnalyzer, + ) +else: + GlobalLogger().warning( + "The 'pyml_football_analyzer' element will not be registered because " + "required modules are missing." + ) From cfbb60d4d9f04ca6006ad04f19e96b70a6108385 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Tue, 16 Jun 2026 02:05:33 +0000 Subject: [PATCH 02/10] Add streaming football overlay (pyml_football_overlay) and DRP-AI engine with ONNX/YOLO class-name and FP16 handling, plus the football models. Signed-off-by: Marcus Edel --- .gitattributes | 2 + data/COLLABORA_02_RGB.png | Bin 0 -> 21419 bytes data/Chinedu-Obasi_2684938.jpg | Bin 0 -> 45242 bytes demo/football/README.md | 18 + demo/football/run.sh | 59 +++ models/football/football.onnx | 3 + models/football/football.pt | 3 + models/football/football_fp16.onnx | 3 + models/football/football_int8.onnx | 3 + plugins/python/engine/drpai_engine.py | 145 +++++++ plugins/python/engine/engine_factory.py | 8 + plugins/python/engine/onnx_engine.py | 4 + plugins/python/football_overlay.py | 495 ++++++++++++++++++++++++ plugins/python/yolo.py | 4 +- 14 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 data/COLLABORA_02_RGB.png create mode 100644 data/Chinedu-Obasi_2684938.jpg create mode 100644 demo/football/README.md create mode 100755 demo/football/run.sh create mode 100644 models/football/football.onnx create mode 100644 models/football/football.pt create mode 100644 models/football/football_fp16.onnx create mode 100644 models/football/football_int8.onnx create mode 100644 plugins/python/engine/drpai_engine.py create mode 100644 plugins/python/football_overlay.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ff86f24 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.pt filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/data/COLLABORA_02_RGB.png b/data/COLLABORA_02_RGB.png new file mode 100644 index 0000000000000000000000000000000000000000..46cd1f18071d657382068d8507cdf7a1cc96dc79 GIT binary patch literal 21419 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 literal 0 HcmV?d00001 diff --git a/demo/football/README.md b/demo/football/README.md new file mode 100644 index 0000000..d32e9fa --- /dev/null +++ b/demo/football/README.md @@ -0,0 +1,18 @@ +# Football demo + +Real-time football broadcast overlay: **detection → tracking → overlay** +(`pyml_yolo`/`pyml_objectdetector` -> `pyml_tracker` -> `pyml_football_overlay`). + +The overlay draws a foot ellipse per subject (players one colour, referee gold), +a green triangle on the ball, motion trails, and a focal-player HUD + ball contacts + distance. + +## Run + +```bash +# file -> annotated MP4 +demo/football/run.sh +demo/football/run.sh 08fd33_4.mp4 demo/football/out.mp4 1280x720 + +# live camera -> on-screen +demo/football/run.sh camera /dev/video0 +``` diff --git a/demo/football/run.sh b/demo/football/run.sh new file mode 100755 index 0000000..f9b316d --- /dev/null +++ b/demo/football/run.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Football broadcast-overlay demo. +# +# Ppipeline: +# detector -> pyml_tracker (ByteTrack) -> pyml_football_overlay +# +# Usage: +# demo/football/run.sh [INPUT.mp4] [OUTPUT.mp4] [WxH] # file -> annotated mp4 +# demo/football/run.sh camera [/dev/videoN] [WxH] # live -> on-screen +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO" +source .venv/bin/activate +export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" + +BACKEND="${BACKEND:-pt}" +CLASSES="ball,goalkeeper,player,referee" +TRACK="pyml_tracker tracker-type=bytetrack" +OVERLAY="pyml_football_overlay class-names=$CLASSES show-ids=false show-labels=false" + +if [[ "$BACKEND" == "fp16" ]]; then + export LD_LIBRARY_PATH="$(python -c "import os,nvidia,glob;b=os.path.dirname(nvidia.__file__);print(':'.join(sorted(set(glob.glob(b+'/*/lib')))))"):${LD_LIBRARY_PATH:-}" + DETECT="pyml_objectdetector engine-name=onnx model-name=models/football/football_fp16.onnx device=cuda:0 input-format=nchw post-process=anchor_free" + IN_FMT="RGB"; FORCE_SQUARE=1 +else + DETECT="pyml_yolo model-name=models/football/football device=cuda:0" + IN_FMT="RGBA"; FORCE_SQUARE=0 +fi + +POST_DETECT="$TRACK" +[[ "$IN_FMT" == "RGB" ]] && POST_DETECT="$TRACK ! videoconvert ! video/x-raw,format=RGBA" + +MODE="${1:-file}" +if [[ "$MODE" == "camera" ]]; then + DEV="${2:-/dev/video0}"; SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + echo "[$BACKEND] live camera $DEV @ ${W}x${H} -> autovideosink (needs a display)" + exec gst-launch-1.0 -e \ + v4l2src device="$DEV" ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $DETECT ! $POST_DETECT ! $OVERLAY \ + ! videoconvert ! autovideosink sync=false +else + IN="${1:-data/soccer_tracking.mp4}" + OUT="${2:-demo/football/out.mp4}" + SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + [[ -f "$IN" ]] || { echo "input not found: $IN" >&2; exit 1; } + echo "[$BACKEND] '$IN' @ ${W}x${H} -> '$OUT'" + gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $DETECT ! $POST_DETECT ! $OVERLAY \ + ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" + echo "Done: $OUT" +fi diff --git a/models/football/football.onnx b/models/football/football.onnx new file mode 100644 index 0000000..0d742a2 --- /dev/null +++ b/models/football/football.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80c093f8f67e866232f3e31e71809071cf4f6c97914ab7c0cd82cbb8d6e30dfb +size 101508645 diff --git a/models/football/football.pt b/models/football/football.pt new file mode 100644 index 0000000..e1fa8fb --- /dev/null +++ b/models/football/football.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffd531b4739e544b075479d6a41118931e82f8362d576258218e8fab2e4bdfa9 +size 51178706 diff --git a/models/football/football_fp16.onnx b/models/football/football_fp16.onnx new file mode 100644 index 0000000..6f15606 --- /dev/null +++ b/models/football/football_fp16.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e6589a1567088115f8e84564e938e09938f152b4b42902e525359bef601e350 +size 50859790 diff --git a/models/football/football_int8.onnx b/models/football/football_int8.onnx new file mode 100644 index 0000000..4dc0e6f --- /dev/null +++ b/models/football/football_int8.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af48014db347b8821efd7d107819cec2959aeb99cbb99c272e6ea9e4bd938519 +size 30817706 diff --git a/plugins/python/engine/drpai_engine.py b/plugins/python/engine/drpai_engine.py new file mode 100644 index 0000000..20c0ca9 --- /dev/null +++ b/plugins/python/engine/drpai_engine.py @@ -0,0 +1,145 @@ +# DRPAIEngine +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os +import numpy as np + +from .ml_engine import MLEngine + + +def _anchor_count(imgsz): + """Total anchors a YOLO model emits for a square input at strides 8/16/32.""" + return sum((imgsz // s) ** 2 for s in (8, 16, 32)) + + +class DRPAIEngine(MLEngine): + """DRP-AI TVM runtime engine for Renesas RZ/V boards (RZ/V2H). + + Runs a model compiled with the Renesas DRP-AI TVM compiler on the DRP-AI + NPU. `model_name` is the path to the compiled deploy directory containing + ``deploy.so`` / ``deploy.json`` / ``deploy.params``. + + Inference goes through the ``drpai_runtime`` pybind11 module (built from + ``rzv2h/`` against the board's DRP-AI TVM runtime. + """ + + def __init__(self): + super().__init__() + self.runtime = None + self.model_name = None + self.kwargs = None + self.imgsz = 640 + + self.input_format = "nchw" + self.post_process = "anchor_free" + + def do_load_model(self, model_name, **kwargs): + self.model_name = model_name + self.kwargs = kwargs + imgsz = kwargs.get("imgsz") + if imgsz: + try: + self.imgsz = int(imgsz) + except (TypeError, ValueError): + pass + + try: + import drpai_runtime + except ImportError as e: + self.logger.error( + "drpai_runtime module not found. Build the pybind11 binding in " + "rzv2h/ inside the RZ/V2H DRP-AI TVM SDK and put it on PYTHONPATH " + f"(see rzv2h/README.md). Import error: {e}" + ) + return False + + if not os.path.isdir(model_name): + self.logger.error( + f"DRP-AI model directory not found: {model_name!r} " + "(expected a folder with deploy.so/json/params)" + ) + return False + + try: + self.runtime = drpai_runtime.Runtime() + if not self.runtime.load(model_name): + self.logger.error(f"DRP-AI failed to load model from {model_name}") + self.runtime = None + return False + self.logger.info( + f"DRP-AI model loaded from {model_name} (imgsz={self.imgsz})" + ) + return True + except Exception as e: + self.logger.error(f"DRP-AI load error: {e}") + self.runtime = None + return False + + def do_set_device(self, device): + self.device = device + self.logger.info(f"DRP-AI engine device set to {device}") + + def do_generate(self, input_text, max_length=1000, system_prompt=None): + raise NotImplementedError( + "DRP-AI engine is a vision-inference engine; text generation is not " + "supported." + ) + + def _preprocess(self, frame_hwc): + """HWC uint8 RGB(A) frame -> contiguous (1, 3, H, W) float32 in [0, 1].""" + x = np.asarray(frame_hwc, dtype=np.float32) + if x.shape[-1] > 3: + x = x[..., :3] + x = x / 255.0 + x = np.transpose(x, (2, 0, 1)) + x = np.expand_dims(x, 0) + return np.ascontiguousarray(x, dtype=np.float32) + + def _gather_output(self): + """Read output 0 and reshape the flat buffer to (1, 4+nc, anchors).""" + out = np.asarray(self.runtime.get_output(0), dtype=np.float32).reshape(-1) + anchors = _anchor_count(self.imgsz) + if anchors and out.size % anchors == 0: + channels = out.size // anchors + return out.reshape(1, channels, anchors) + self.logger.warning( + f"DRP-AI output size {out.size} not divisible by {anchors} anchors; " + "passing raw to post-process" + ) + return out + + def do_forward(self, frames): + if self.runtime is None: + self.logger.error("DRP-AI runtime not loaded") + return None + + is_batch = isinstance(frames, np.ndarray) and frames.ndim == 4 + batch = frames if is_batch else frames[np.newaxis, ...] + + results = [] + for img in batch: + try: + self.runtime.set_input(0, self._preprocess(img)) + self.runtime.run() + raw = self._gather_output() + results.append(self._apply_post_process(raw, is_batch=False)) + except Exception as e: + self.logger.error(f"DRP-AI inference error: {e}") + results.append(None) + + return results if is_batch else results[0] diff --git a/plugins/python/engine/engine_factory.py b/plugins/python/engine/engine_factory.py index 2a0e5fb..d361bdf 100644 --- a/plugins/python/engine/engine_factory.py +++ b/plugins/python/engine/engine_factory.py @@ -44,6 +44,7 @@ class EngineFactory: MIGRAPHX_ENGINE = "migraphx" IREE_ENGINE = "iree" NCNN_ENGINE = "ncnn" + DRPAI_ENGINE = "drpai" _builtins_registered: bool = False # Class-level flag for singleton-like lazy init @@ -154,6 +155,13 @@ def _register_builtins(cls) -> None: except ImportError: pass + try: + from .drpai_engine import DRPAIEngine + + _try_register(cls.DRPAI_ENGINE, DRPAIEngine) + except ImportError: + pass + @staticmethod def register(engine_type: str, engine_class: Type) -> None: _engine_registry[engine_type] = engine_class diff --git a/plugins/python/engine/onnx_engine.py b/plugins/python/engine/onnx_engine.py index ba85e84..0b77d22 100644 --- a/plugins/python/engine/onnx_engine.py +++ b/plugins/python/engine/onnx_engine.py @@ -370,8 +370,12 @@ def do_forward(self, frames): if fmt == "auto" and self._input_is_nchw(): self.input_format = "nchw" img = self._apply_input_format(frames.astype(np.float32) / 255.0, is_batch) + if "float16" in self.session.get_inputs()[0].type: + img = img.astype(np.float16) outputs = self.session.run(self.output_names, {self.input_names[0]: img}) raw = outputs if len(outputs) > 1 else outputs[0] + if isinstance(raw, np.ndarray) and raw.dtype != np.float32: + raw = raw.astype(np.float32) return self._apply_post_process(raw, is_batch) else: diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py new file mode 100644 index 0000000..cdcda9e --- /dev/null +++ b/plugins/python/football_overlay.py @@ -0,0 +1,495 @@ +# FootballOverlay +# Copyright (C) 2024-2026 Collabora Ltd. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Library General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Library General Public License for more details. +# +# You should have received a copy of the GNU Library General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import os + +from log.global_logger import GlobalLogger + +CAN_REGISTER_ELEMENT = True +try: + import re + import gi + + gi.require_version("Gst", "1.0") + gi.require_version("GstBase", "1.0") + gi.require_version("GstVideo", "1.0") + gi.require_version("GstAnalytics", "1.0") + gi.require_version("GLib", "2.0") + from gi.repository import Gst, GstBase, GstVideo, GstAnalytics, GObject, GLib # noqa: E402 + + from log.logger_factory import LoggerFactory # noqa: E402 + + OVERLAY_CAPS = Gst.Caps.from_string( + "video/x-raw, format=(string){ RGBA, ARGB, BGRA, ABGR }" + ) + +except ImportError as e: + CAN_REGISTER_ELEMENT = False + GlobalLogger().warning( + f"The 'pyml_football_overlay' element will not be available. Error: {e}" + ) + + +_FORMAT_ORDER = { + "RGBA": (0, 1, 2, 3), + "ARGB": (3, 0, 1, 2), + "BGRA": (2, 1, 0, 3), + "ABGR": (3, 2, 1, 0), +} + +_PALETTE = [ + (239, 71, 111, 255), + (255, 209, 102, 255), + (6, 214, 160, 255), + (17, 138, 178, 255), + (255, 107, 107, 255), + (78, 205, 196, 255), + (199, 125, 255, 255), + (255, 159, 28, 255), + (46, 196, 182, 255), + (118, 200, 247, 255), +] + +_REFEREE_RGBA = (255, 215, 0, 255) +_BALL_RGBA = (0, 230, 0, 255) +_PLAYER_RGBA = (0, 200, 255, 255) +_DEFAULT_RGBA = (235, 235, 235, 255) +_BLACK_RGBA = (0, 0, 0, 255) +_HUD_BG_RGBA = (92, 41, 131, 255) +_HUD_TEXT_RGBA = (64, 186, 47, 255) + + +def _is_ball(label): + return "ball" in label + + +def _is_referee(label): + return "referee" in label or label == "ref" + + +class FootballOverlay(GstBase.BaseTransform): + """ + Metadata-driven broadcast overlay (football_analysis style), streaming. + + Reads upstream GstAnalytics detection/tracking metadata and draws: an + ellipse + optional id badge per subject, a gold ellipse for referees, a + green triangle on the ball, fading motion trails, and a focal-player HUD + with a headshot, accumulated ball contacts, and distance travelled. + """ + + __gstmetadata__ = ( + "Football Overlay", + "Filter/Effect/Video", + "Broadcast-style detection/tracking overlay (ellipses, ball triangle, " + "trails, headshot HUD with ball contacts + distance) from GstAnalytics", + "Marcus Edel ", + ) + + src_template = Gst.PadTemplate.new( + "src", Gst.PadDirection.SRC, Gst.PadPresence.ALWAYS, OVERLAY_CAPS.copy() + ) + sink_template = Gst.PadTemplate.new( + "sink", Gst.PadDirection.SINK, Gst.PadPresence.ALWAYS, OVERLAY_CAPS.copy() + ) + __gsttemplates__ = (src_template, sink_template) + + show_labels = GObject.Property( + type=bool, default=True, nick="Show Labels", + blurb="Draw the class name above each object", + flags=GObject.ParamFlags.READWRITE) + show_ids = GObject.Property( + type=bool, default=True, nick="Show Track IDs", + blurb="Draw the track-id badge under each tracked object", + flags=GObject.ParamFlags.READWRITE) + trails = GObject.Property( + type=bool, default=True, nick="Show Trails", + blurb="Draw a fading motion trail behind each tracked object", + flags=GObject.ParamFlags.READWRITE) + trail_length = GObject.Property( + type=int, default=30, minimum=2, maximum=300, nick="Trail Length", + blurb="Number of recent positions kept in each motion trail", + flags=GObject.ParamFlags.READWRITE) + show_hud = GObject.Property( + type=bool, default=True, nick="Show HUD", + blurb="Draw the focal-player HUD (headshot, label, contacts, distance)", + flags=GObject.ParamFlags.READWRITE) + headshot_path = GObject.Property( + type=str, default="data/Chinedu-Obasi_2684938.jpg", nick="Headshot Path", + blurb="Image shown in the HUD (empty to disable)", + flags=GObject.ParamFlags.READWRITE) + headshot_size = GObject.Property( + type=int, default=90, minimum=16, maximum=512, nick="Headshot Size", + blurb="Headshot square size in pixels", + flags=GObject.ParamFlags.READWRITE) + player_label = GObject.Property( + type=str, default="Player #8", nick="Player Label", + blurb="Static label drawn in the HUD", + flags=GObject.ParamFlags.READWRITE) + contact_pad_ratio = GObject.Property( + type=float, default=0.25, minimum=0.0, maximum=5.0, nick="Contact Pad Ratio", + blurb="Ball counts as a contact within this fraction of the player box size", + flags=GObject.ParamFlags.READWRITE) + contact_gap_frames = GObject.Property( + type=int, default=5, minimum=0, maximum=1000, nick="Contact Gap Frames", + blurb="Min frames between counted contacts for the same player", + flags=GObject.ParamFlags.READWRITE) + player_height = GObject.Property( + type=float, default=1.8, minimum=0.1, maximum=10.0, nick="Player Height (m)", + blurb="Assumed real-world height used to convert pixels to metres", + flags=GObject.ParamFlags.READWRITE) + min_confidence = GObject.Property( + type=float, default=0.0, minimum=0.0, maximum=1.0, nick="Min Confidence", + blurb="Skip detections whose confidence is below this threshold", + flags=GObject.ParamFlags.READWRITE) + class_names = GObject.Property( + type=str, default="", nick="Class Names", + blurb="Comma-separated names to map numeric labels (label_N) from the " + "onnx/objectdetector path, e.g. 'ball,goalkeeper,player,referee'", + flags=GObject.ParamFlags.READWRITE) + + def __init__(self): + super().__init__() + self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) + self.set_in_place(True) + self.width = 0 + self.height = 0 + self._order = _FORMAT_ORDER["RGBA"] + # per-track state, accumulated across frames + self._trail = {} + self._last_pt = {} + self._distance_px = {} + self._heights = [] + self._contacts = {} + self._last_contact_frame = {} + self._frames_seen = {} + self._track_label = {} + self._frame = 0 + self._headshot = None + self._headshot_loaded = False + + def do_set_caps(self, incaps, outcaps): + info = GstVideo.VideoInfo.new_from_caps(incaps) + self.width = info.width + self.height = info.height + fmt = info.finfo.name if info.finfo else "RGBA" + self._order = _FORMAT_ORDER.get(fmt, _FORMAT_ORDER["RGBA"]) + self._headshot_loaded = False # re-load in the new channel order + self.logger.info(f"FootballOverlay caps: {fmt} {self.width}x{self.height}") + return True + + def _map_label(self, label): + if self.class_names: + m = re.match(r"label_(\d+)$", label) + if m: + names = [s.strip() for s in self.class_names.split(",") if s.strip()] + i = int(m.group(1)) + if 0 <= i < len(names): + return names[i] + return label + + def _parse_label(self, full_label): + core = full_label + m = re.match(r"stream_\d+_(.*)$", full_label) + if m: + core = m.group(1) + m = re.match(r"(.+)_id_(\d+)$", core) + if m: + return self._map_label(m.group(1)), int(m.group(2)) + m = re.match(r"id_(\d+)$", core) + if m: + return "object", int(m.group(1)) + return self._map_label(core or "object"), None + + def _read_metadata(self, buf): + entries = [] + meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) + if not meta: + return entries + for index in range(GstAnalytics.relation_get_length(meta)): + ret, od_mtd = meta.get_od_mtd(index) + if not ret or od_mtd is None: + continue + full_label = GLib.quark_to_string(od_mtd.get_obj_type()) + presence, x, y, w, h, score = od_mtd.get_location() + if not presence: + continue + label, track_id = self._parse_label(full_label) + entries.append({ + "label": label.lower(), "track_id": track_id, + "confidence": score, "box": (x, y, x + w, y + h), + }) + return entries + + @staticmethod + def _point_to_bbox_distance(px, py, box): + x1, y1, x2, y2 = box + dx = max(x1 - px, 0.0, px - x2) + dy = max(y1 - py, 0.0, py - y2) + return (dx * dx + dy * dy) ** 0.5 + + def _ball_contact(self, players, ball_box): + """Closest player to the ball, if within contact_pad_ratio of its size.""" + bx = (ball_box[0] + ball_box[2]) / 2.0 + by = (ball_box[1] + ball_box[3]) / 2.0 + best_tid, best_d, best_box = None, float("inf"), None + for tid, box in players.items(): + d = self._point_to_bbox_distance(bx, by, box) + if d < best_d: + best_tid, best_d, best_box = tid, d, box + if best_box is None: + return None + w = best_box[2] - best_box[0] + h = best_box[3] - best_box[1] + if best_d > self.contact_pad_ratio * max(w, h): + return None + return best_tid + + def _update_tracks(self, entries): + self._frame += 1 + active = set() + players = {} + ball_box = None + for e in entries: + tid = e["track_id"] + if tid is None: + continue + if _is_ball(e["label"]): + ball_box = e["box"] + continue + active.add(tid) + players[tid] = e["box"] + self._track_label[tid] = e["label"] + self._frames_seen[tid] = self._frames_seen.get(tid, 0) + 1 + x1, y1, x2, y2 = e["box"] + foot = (int((x1 + x2) / 2), int(y2)) + if y2 - y1 > 0: + self._heights.append(y2 - y1) + if len(self._heights) > 600: + self._heights = self._heights[-600:] + prev = self._last_pt.get(tid) + if prev is not None: + self._distance_px[tid] = self._distance_px.get(tid, 0.0) + ( + (foot[0] - prev[0]) ** 2 + (foot[1] - prev[1]) ** 2 + ) ** 0.5 + self._last_pt[tid] = foot + trail = self._trail.setdefault(tid, []) + trail.append(foot) + if len(trail) > self.trail_length: + del trail[: -self.trail_length] + + # Ball contacts (debounced per player), like football_analyzer. + if ball_box is not None and players: + tid = self._ball_contact(players, ball_box) + if tid is not None: + last = self._last_contact_frame.get(tid) + if last is None or (self._frame - last) > self.contact_gap_frames: + self._contacts[tid] = self._contacts.get(tid, 0) + 1 + self._last_contact_frame[tid] = self._frame + + for tid in list(self._trail.keys()): + if tid not in active: + del self._trail[tid] + self._last_pt.pop(tid, None) + return active + + def _px_per_meter(self): + if not self._heights: + return None + import numpy as np + return float(np.median(self._heights)) / max(0.1, self.player_height) + + def _focal_track(self): + keys = set(self._frames_seen) + if not keys: + return None + if any(self._contacts.values()): + return max(keys, key=lambda t: (self._contacts.get(t, 0), + self._frames_seen.get(t, 0))) + return max(keys, key=lambda t: self._frames_seen.get(t, 0)) + + def _c(self, rgba): + return tuple(rgba[i] for i in self._order) + + def _color_for(self, label, track_id): + if _is_referee(label): + return _REFEREE_RGBA + if _is_ball(label): + return _BALL_RGBA + return _PLAYER_RGBA + + def _load_headshot(self, cv2, np): + if self._headshot_loaded: + return self._headshot + self._headshot_loaded = True + self._headshot = None + path = self.headshot_path + if not path or not os.path.exists(path): + if path: + self.logger.warning(f"headshot not found: {path}") + return None + img = cv2.imread(path) # BGR + if img is None: + return None + sz = int(self.headshot_size) + img = cv2.resize(img, (sz, sz), interpolation=cv2.INTER_AREA) + rgb = img[:, :, ::-1] # BGR -> RGB + alpha = np.full((sz, sz, 1), 255, dtype=np.uint8) + rgba = np.concatenate([rgb, alpha], axis=2).astype(np.uint8) # logical RGBA + + self._headshot = np.ascontiguousarray(rgba[:, :, list(self._order)]) + return self._headshot + + def _draw_trail(self, cv2, np, frame, points, rgba): + if len(points) < 2: + return + pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(frame, [pts], False, self._c(rgba), 2, cv2.LINE_AA) + + def _draw_ellipse(self, cv2, frame, box, rgba, track_id): + x1, y1, x2, y2 = box + y_bottom = int(y2) + x_center = int((x1 + x2) / 2) + width = max(1, int(x2 - x1)) + color = self._c(rgba) + cv2.ellipse(frame, (x_center, y_bottom), + (width, max(1, int(0.35 * width))), 0.0, -45, 235, + color, 2, cv2.LINE_AA) + if self.show_ids and track_id is not None: + rect_w, rect_h = 40, 18 + x1r = x_center - rect_w // 2 + x2r = x_center + rect_w // 2 + y1r = y_bottom - rect_h // 2 + 15 + y2r = y_bottom + rect_h // 2 + 15 + cv2.rectangle(frame, (x1r, y1r), (x2r, y2r), color, cv2.FILLED) + tx = x1r + 12 - (10 if track_id > 99 else 0) + cv2.putText(frame, str(track_id), (tx, y1r + 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, self._c(_BLACK_RGBA), 2, + cv2.LINE_AA) + + def _draw_triangle(self, cv2, np, frame, box, rgba): + x1, y1, x2, y2 = box + x = int((x1 + x2) / 2) + y = int(y1) + pts = np.array([[x, y], [x - 10, y - 20], [x + 10, y - 20]], dtype=np.int32) + cv2.drawContours(frame, [pts], 0, self._c(rgba), cv2.FILLED) + cv2.drawContours(frame, [pts], 0, self._c(_BLACK_RGBA), 2) + + def _draw_label(self, cv2, frame, box, label, rgba): + x1, y1, _, _ = box + cv2.putText(frame, label, (int(x1), max(12, int(y1) - 6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, self._c(rgba), 1, cv2.LINE_AA) + + def _draw_hud(self, cv2, frame, contacts, distance_m, rgba, headshot): + font = cv2.FONT_HERSHEY_SIMPLEX + x, y = 10, 10 + if headshot is not None: + hh, hw = headshot.shape[:2] + w, h = hw + 280, max(110, hh + 20) + text_x = x + hw + 20 + else: + w, h = 320, 100 + text_x = x + 12 + cv2.rectangle(frame, (x, y), (x + w, y + h), self._c(_HUD_BG_RGBA), cv2.FILLED) + cv2.rectangle(frame, (x, y), (x + w, y + h), self._c(rgba), 2) + if headshot is not None: + hy, hx = y + 10, x + 10 + fh, fw = frame.shape[:2] + hh = min(hh, fh - hy) + hw = min(hw, fw - hx) + if hh > 0 and hw > 0: + frame[hy:hy + hh, hx:hx + hw] = headshot[:hh, :hw] + cv2.rectangle(frame, (hx, hy), (hx + hw, hy + hh), self._c(rgba), 2) + tc = self._c(_HUD_TEXT_RGBA) + cv2.putText(frame, self.player_label, (text_x, y + 28), font, 0.7, tc, 2, cv2.LINE_AA) + cv2.putText(frame, f"Ball contacts: {contacts}", (text_x, y + 58), font, 0.6, tc, 1, cv2.LINE_AA) + cv2.putText(frame, f"Distance: {distance_m:.1f} m", (text_x, y + 85), font, 0.6, tc, 1, cv2.LINE_AA) + + def do_transform_ip(self, buf): + try: + import numpy as np + + entries = self._read_metadata(buf) + if self.min_confidence > 0.0: + entries = [e for e in entries if e["confidence"] >= self.min_confidence] + if any(e["track_id"] is not None for e in entries): + entries = [e for e in entries if e["track_id"] is not None] + + active = self._update_tracks(entries) + if not entries: + return Gst.FlowReturn.OK + + import cv2 + + ok, mapinfo = buf.map(Gst.MapFlags.WRITE) + if not ok: + self.logger.error("Failed to map buffer for writing") + return Gst.FlowReturn.ERROR + try: + frame = np.frombuffer( + mapinfo.data, dtype=np.uint8, count=self.height * self.width * 4 + ).reshape(self.height, self.width, 4) + + if self.trails: + for tid in active: + self._draw_trail( + cv2, np, frame, self._trail.get(tid, []), + self._color_for(self._track_label.get(tid, ""), tid)) + + for e in entries: + label = e["label"] + box = e["box"] + if _is_ball(label): + self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) + continue + rgba = self._color_for(label, e["track_id"]) + self._draw_ellipse(cv2, frame, box, rgba, e["track_id"]) + if self.show_labels: + self._draw_label(cv2, frame, box, label, rgba) + + if self.show_hud: + focal = self._focal_track() + if focal is not None: + ppm = self._px_per_meter() + dist_m = (self._distance_px.get(focal, 0.0) / ppm) if ppm else 0.0 + self._draw_hud( + cv2, frame, self._contacts.get(focal, 0), dist_m, + self._color_for(self._track_label.get(focal, ""), focal), + self._load_headshot(cv2, np), + ) + finally: + buf.unmap(mapinfo) + + return Gst.FlowReturn.OK + + except Exception as e: + self.logger.error(f"FootballOverlay transform error: {e}") + return Gst.FlowReturn.ERROR + + +if CAN_REGISTER_ELEMENT: + GObject.type_register(FootballOverlay) + __gstelementfactory__ = ( + "pyml_football_overlay", + Gst.Rank.NONE, + FootballOverlay, + ) +else: + GlobalLogger().warning( + "The 'pyml_football_overlay' element will not be registered because " + "required modules are missing." + ) diff --git a/plugins/python/yolo.py b/plugins/python/yolo.py index a34deb6..dfe397d 100644 --- a/plugins/python/yolo.py +++ b/plugins/python/yolo.py @@ -250,7 +250,9 @@ def do_decode(self, buf, result, stream_idx=0): score = boxes.conf[i] label = boxes.cls[i] label_num = label.item() - class_name = COCO_CLASSES.get(label_num, f"unknown_{label_num}") + # Prefer the model's own class names; fall back to COCO for plain yolo. + names = getattr(result, "names", None) or COCO_CLASSES + class_name = names.get(label_num, f"unknown_{label_num}") # Use class name for detection, track_id for tracking if self.engine.track and hasattr(boxes, "id") and boxes.id is not None: From bb0e58915f5e902567c9fea7271aa43cbce7c439 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Tue, 16 Jun 2026 02:16:55 +0000 Subject: [PATCH 03/10] Add RZ/V2H DRP-AI tooling: YOLO INT8 compile, C++ runtime pybind and a Yocto layer for the on-board gst-python-ml stack. Signed-off-by: Marcus Edel --- rzv2h/CMakeLists.txt | 54 +++++++ rzv2h/README.md | 80 ++++++++++ rzv2h/convert_yolo11_v2h.sh | 58 +++++++ rzv2h/drpai_runtime_pybind.cpp | 149 ++++++++++++++++++ rzv2h/emulation/drpai_runtime.py | 123 +++++++++++++++ rzv2h/emulation/run_emulated.sh | 37 +++++ rzv2h/sdk_eval/README.md | 113 +++++++++++++ rzv2h/sdk_eval/_probe_sysroot.sh | 23 +++ rzv2h/sdk_eval/build_image.sh | 78 +++++++++ rzv2h/sdk_eval/compile_x86_cpu.py | 60 +++++++ rzv2h/sdk_eval/x86_runtime_check.py | 34 ++++ rzv2h/yocto/README.md | 1 + .../conf/include/gstreamer-1.24.inc | 25 +++ .../yocto/meta-gst-python-ml/conf/layer.conf | 15 ++ .../packagegroup-gst-python-ml.bb | 26 +++ .../gst-python-ml/gst-python-ml_git.bb | 35 ++++ 16 files changed, 911 insertions(+) create mode 100644 rzv2h/CMakeLists.txt create mode 100644 rzv2h/README.md create mode 100755 rzv2h/convert_yolo11_v2h.sh create mode 100644 rzv2h/drpai_runtime_pybind.cpp create mode 100644 rzv2h/emulation/drpai_runtime.py create mode 100755 rzv2h/emulation/run_emulated.sh create mode 100644 rzv2h/sdk_eval/README.md create mode 100644 rzv2h/sdk_eval/_probe_sysroot.sh create mode 100755 rzv2h/sdk_eval/build_image.sh create mode 100644 rzv2h/sdk_eval/compile_x86_cpu.py create mode 100644 rzv2h/sdk_eval/x86_runtime_check.py create mode 100644 rzv2h/yocto/README.md create mode 100644 rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc create mode 100644 rzv2h/yocto/meta-gst-python-ml/conf/layer.conf create mode 100644 rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb create mode 100644 rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb diff --git a/rzv2h/CMakeLists.txt b/rzv2h/CMakeLists.txt new file mode 100644 index 0000000..5f09e2f --- /dev/null +++ b/rzv2h/CMakeLists.txt @@ -0,0 +1,54 @@ +# Build the `drpai_runtime` Python extension for RZ/V2H. +# +# This mirrors the SDK's apps/CMakeLists.txt (same TVM includes, same V2H +# runtime libraries) but produces a Python module instead of an executable. +# It MUST be configured with the SDK cross-toolchain and built inside the +# RZ/V2H DRP-AI TVM SDK Docker. See README.md. +# +# Required env: TVM_ROOT (root of rzv_drp-ai_tvm), SDK (Yocto cross SDK) +# Required -D : PYBIND11_INCLUDE_DIR, PYTHON_INCLUDE_DIR (target aarch64 python) +cmake_minimum_required(VERSION 3.16) +project(drpai_runtime CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT DEFINED ENV{TVM_ROOT}) + message(FATAL_ERROR "TVM_ROOT not set — source the DRP-AI TVM SDK env first") +endif() +set(TVM_ROOT "$ENV{TVM_ROOT}") + +set(DRPAI_APPS "${TVM_ROOT}/apps" CACHE PATH "rzv_drp-ai_tvm/apps directory") +set(PYBIND11_INCLUDE_DIR "" CACHE PATH "pybind11 include directory") +set(PYTHON_INCLUDE_DIR "" CACHE PATH "target python3 include dir") +set(LIBMERA_RT_PATH ${TVM_ROOT}/obj/build_runtime/v2h/lib) + +add_library(drpai_runtime MODULE + drpai_runtime_pybind.cpp + ${DRPAI_APPS}/MeraDrpRuntimeWrapper.cpp +) +set_target_properties(drpai_runtime PROPERTIES PREFIX "" SUFFIX ".so") + +target_include_directories(drpai_runtime PRIVATE + ${DRPAI_APPS} + ${TVM_ROOT}/tvm/include + ${TVM_ROOT}/setup/include + ${TVM_ROOT}/tvm/3rdparty/dlpack/include + ${TVM_ROOT}/tvm/3rdparty/dmlc-core/include + ${TVM_ROOT}/tvm/3rdparty/compiler-rt + ${PYBIND11_INCLUDE_DIR} + ${PYTHON_INCLUDE_DIR} +) + +add_definitions(-DMERA_DRP_RUNTIME) +target_compile_definitions(drpai_runtime PUBLIC KDLDRPAI) +target_link_directories(drpai_runtime PRIVATE ${LIBMERA_RT_PATH}) +target_link_libraries(drpai_runtime PRIVATE + mera2_runtime + mera2_plan_io + drp_tvm_rt + pthread +) +set_target_properties(drpai_runtime PROPERTIES + LINK_FLAGS "-Wl,-rpath,${LIBMERA_RT_PATH} -Wl,-rpath-link,${LIBMERA_RT_PATH}") + +target_compile_options(drpai_runtime PRIVATE -O3 -mtune=cortex-a55 -Wall -fvisibility=hidden) diff --git a/rzv2h/README.md b/rzv2h/README.md new file mode 100644 index 0000000..de8d0bc --- /dev/null +++ b/rzv2h/README.md @@ -0,0 +1,80 @@ +# Object detection on Renesas RZ/V2H (DRP-AI NPU) + +This runs `pyml_objectdetector` on the **RZ/V2H** DRP-AI NPU, using a YOLO11 +model compiled with the **DRP-AI TVM** compiler (powered by EdgeCortix MERA). + +It is the decomposed, metadata-passing pipeline used elsewhere in this repo — +detector -> (tracker) -> overlay, but the detector's inference runs on the NPU: + +``` +... ! pyml_objectdetector engine-name=drpai model-name= device=drpai + input-format=nchw post-process=anchor_free + ! pyml_tracker ! pyml_overlay ! ... +``` +## Prerequisites + +- RZ/V2H EVK with the **RZ/V2H AI SDK v6.00** Yocto image (provides the DRP-AI + driver, `/dev/drpai0`, GStreamer, and Python 3). +- The **DRP-AI TVM** package (`rzv_drp-ai_tvm`) and its SDK Docker, with the + environment sourced so `TVM_ROOT`, `SDK` (cross SDK), and the DRP-AI + translator are set. (`PRODUCT=V2H`.) +- `pybind11` headers available to the cross build. + +## 1 — Convert the model (in the SDK Docker) + +```bash +./convert_yolo11_v2h.sh yolo11m 640 +``` + +This exports YOLO11->ONNX (input node `images`, `1x3x640x640`) and runs the V2H +DRP-AI TVM compiler. See the script for the exact commands. + +## 2 — Build the Python binding (in the SDK Docker) + +Source the SDK env first (so `TVM_ROOT`/`SDK` are set and CXX is the aarch64 +cross compiler), then: + +```bash +cd rzv2h +cmake -B build \ + -DCMAKE_TOOLCHAIN_FILE="$TVM_ROOT/apps/toolchain/runtime.cmake" \ + -DPYBIND11_INCLUDE_DIR="$(python3 -m pybind11 --includes | sed 's/-I//;q')" \ + -DPYTHON_INCLUDE_DIR="$SDK/sysroots/aarch64-poky-linux/usr/include/python3.12" +cmake --build build -j +``` + +Adjust `python3.12` to the AI SDK image's Python version, and point +`PYBIND11_INCLUDE_DIR` at a real pybind11 headers dir if the one-liner doesn't +resolve in the container. + +## 3 — Deploy to the board + +Copy onto the RZ/V2H (e.g. under `/home/weston`): + +- this repo's `plugins/` (the gst-python-ml elements), +- `build/drpai_runtime.so`, +- the compiled `yolo11m_drpai_v2h/` deploy dir, +- a COCO label file if you overlay class names. + +```bash +export GST_PLUGIN_PATH=/home/weston/gst-python-ml/plugins:$GST_PLUGIN_PATH +export PYTHONPATH=/home/weston/rzv2h/build:$PYTHONPATH +gst-inspect-1.0 pyml_objectdetector +``` + +## 4 — Run on the board + +File -> annotated file (run as a user that can open `/dev/drpai0`, often root): + +```bash +gst-launch-1.0 filesrc location=clip.mp4 ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name=yolo11m_drpai_v2h device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker tracker-type=bytetrack \ + ! videoconvert ! "video/x-raw,format=RGBA" ! pyml_overlay \ + ! videoconvert ! autovideosink +``` + +Live camera (MIPI/USB): swap `filesrc ! decodebin` for the camera source +(`v4l2src` / the EVK's ISP source), keeping the `640x640` caps into the detector. diff --git a/rzv2h/convert_yolo11_v2h.sh b/rzv2h/convert_yolo11_v2h.sh new file mode 100755 index 0000000..06d8412 --- /dev/null +++ b/rzv2h/convert_yolo11_v2h.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Compile YOLO11 (ONNX) -> RZ/V2H DRP-AI (INT8) deploy dir, using the REAL +# mera2 + DRP-AI Translator i8 + DRP-AI Quantizer flow. +# +# RUN INSIDE the drpai-tvm-v2h container (built via rzv2h/sdk_eval/build_image.sh), +# with this repo mounted at /work. RZ/V2H uses the DRP-AI INT8 accelerator, so +# quantization is MANDATORY and calibration images are required — this is why +# the plain FP compile_onnx_model.py does NOT work for V2H. +# +# Usage (inside container): +# ./rzv2h/convert_yolo11_v2h.sh [MODEL.onnx] [OUT_DIR] [CALIB_DIR] [IMGSZ] +# Defaults assume the repo is at /work and the ONNX is exported already +# (e.g. `yolo export model=models/yolo11m/yolo11m.pt format=onnx imgsz=640` on a +# host with ultralytics — the container has no ultralytics). +set -euo pipefail + +ONNX="${1:-/work/models/yolo11m/yolo11m.onnx}" +OUT="${2:-/work/rzv2h/yolo11m_drpai_v2h}" +CALIB="${3:-/work/rzv2h/calib}" +IMGSZ="${4:-640}" + +: "${TVM_ROOT:?run inside the drpai-tvm-v2h container (TVM_ROOT unset)}" +export PRODUCT=V2H +export SDK="$(find /opt/ -name sysroots -type d | head -1)/../" +export TRANSLATOR="$(find /opt/ -name python_api -type d | head -1)/../../" +: "${QUANTIZER:?QUANTIZER env not set (expected from the image)}" +export PATH="$TVM_ROOT/tutorials:$PATH" # so run_drp_compiler.sh resolves +chmod +x "$TVM_ROOT"/tutorials/*.sh 2>/dev/null || true # SDK ships them non-+x + +[[ -f "$ONNX" ]] || { echo "ONNX not found: $ONNX (export it first)"; exit 1; } +[[ -d "$CALIB" ]] || { echo "calibration image dir not found: $CALIB"; exit 1; } + +# The stock quant script preprocesses calibration images as ImageNet (224 + +# mean/std) — wrong for YOLO (needs IMGSZ, /255, RGB, CHW). Patch that one line. +python3 - "$TVM_ROOT/tutorials/compile_onnx_model_quant.py" "$IMGSZ" <<'PYEOF' +import sys +p, sz = sys.argv[1], int(sys.argv[2]) +s = open(p).read() +old = "input_data = pre_process_imagenet_pytorch(image, mean, stdev, need_transpose=True)" +new = ("input_data = (cv2.resize(image,(%d,%d))[:,:,::-1]" + ".astype('float32')/255.0).transpose(2,0,1)" % (sz, sz)) +if old in s: + open(p, "w").write(s.replace(old, new)); print("[patch] calibration preprocessing ->", sz) +else: + print("[patch] calibration line already patched / not found") +PYEOF + +rm -rf "$OUT" +cd "$TVM_ROOT/tutorials" +python3 compile_onnx_model_quant.py "$ONNX" \ + -o "$OUT" -i images -s "1,3,${IMGSZ},${IMGSZ}" \ + -t "$SDK" -d "$TRANSLATOR" -c "$QUANTIZER" --images "$CALIB" + +echo +echo "Done. RZ/V2H DRP-AI (INT8) deploy dir: $OUT" +echo " sub_0000__CPU_DRP_TVM/{deploy.so,deploy.json,deploy.params} (aarch64 + DRP-AI)" +echo " preprocess/ (DRP-AI pre-processing runtime objects)" +echo "Copy $OUT to the board; load sub_0000__CPU_DRP_TVM with the MERA runtime." diff --git a/rzv2h/drpai_runtime_pybind.cpp b/rzv2h/drpai_runtime_pybind.cpp new file mode 100644 index 0000000..5d2d0b9 --- /dev/null +++ b/rzv2h/drpai_runtime_pybind.cpp @@ -0,0 +1,149 @@ +// drpai_runtime_pybind.cpp +// Copyright (C) 2024-2026 Collabora Ltd. — LGPL (see COPYING). +// +// pybind11 binding around the Renesas DRP-AI TVM runtime +// (MeraDrpRuntimeWrapper, powered by EdgeCortix MERA(TM)) for RZ/V2H. +// +// Exposes a minimal `drpai_runtime.Runtime` class to Python so the pure-Python +// `drpai_engine.py` can drive the DRP-AI NPU: +// +// import drpai_runtime +// rt = drpai_runtime.Runtime() +// rt.load("/path/to/deploy_dir") # deploy.so/json/params +// rt.set_input(0, nchw_float32_numpy) +// rt.run() +// out0 = rt.get_output(0) # numpy (float32, fp16 upcast) +// +// Build with CMake against the board's DRP-AI TVM runtime — see CMakeLists.txt +// and README.md. This compiles only inside the RZ/V2H DRP-AI TVM SDK and runs +// only on the board (it talks to /dev/drpai0). + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "MeraDrpRuntimeWrapper.h" + +namespace py = pybind11; + +static float fp16_to_fp32(uint16_t h) { + uint32_t sign = static_cast(h & 0x8000) << 16; + uint32_t exp = (h >> 10) & 0x1F; + uint32_t mant = h & 0x3FF; + uint32_t f; + if (exp == 0) { + if (mant == 0) { + f = sign; + } else { + exp = 127 - 15 + 1; + while ((mant & 0x400) == 0) { + mant <<= 1; + exp--; + } + mant &= 0x3FF; + f = sign | (exp << 23) | (mant << 13); + } + } else if (exp == 0x1F) { + f = sign | 0x7F800000 | (mant << 13); // Inf / NaN + } else { + f = sign | ((exp - 15 + 127) << 23) | (mant << 13); + } + float out; + std::memcpy(&out, &f, sizeof(out)); + return out; +} + +static uint64_t get_drpai_start_addr() { + int fd = open("/dev/drpai0", O_RDWR); + if (fd < 0) { + throw std::runtime_error("Failed to open /dev/drpai0 (run on the board, as root?)"); + } + drpai_data_t drpai_data; + int ret = ioctl(fd, DRPAI_GET_DRPAI_AREA, &drpai_data); + close(fd); + if (ret == -1) { + throw std::runtime_error("ioctl(DRPAI_GET_DRPAI_AREA) failed"); + } + return drpai_data.address; +} + +class Runtime { + public: + Runtime() : rt_() {} + + bool load(const std::string& model_dir) { + model_dir_ = model_dir; + return rt_.LoadModel(model_dir, get_drpai_start_addr()); + } + + void set_input(int index, + py::array_t data) { + rt_.SetInput(index, static_cast(data.data())); + } + + void run() { rt_.Run(); } + + int num_input() { return rt_.GetNumInput(model_dir_); } + int num_output() { return rt_.GetNumOutput(); } + + py::array get_output(int index) { + auto out = rt_.GetOutput(index); + InOutDataType dtype = std::get<0>(out); + const void* ptr = std::get<1>(out); + int64_t size = std::get<2>(out); + + switch (dtype) { + case InOutDataType::FLOAT16: { + const uint16_t* src = reinterpret_cast(ptr); + py::array_t result(size); + float* dst = static_cast(result.request().ptr); + for (int64_t i = 0; i < size; ++i) dst[i] = fp16_to_fp32(src[i]); + return result; + } + case InOutDataType::FLOAT32: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(float)); + return result; + } + case InOutDataType::INT32: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(int32_t)); + return result; + } + case InOutDataType::INT64: { + py::array_t result(size); + std::memcpy(result.request().ptr, ptr, size * sizeof(int64_t)); + return result; + } + default: + throw std::runtime_error("Unsupported DRP-AI output data type"); + } + } + + private: + MeraDrpRuntimeWrapper rt_; + std::string model_dir_; +}; + +PYBIND11_MODULE(drpai_runtime, m) { + m.doc() = "pybind11 binding for the Renesas DRP-AI TVM runtime (RZ/V2H)"; + py::class_(m, "Runtime") + .def(py::init<>()) + .def("load", &Runtime::load, py::arg("model_dir"), + "Load a DRP-AI TVM deploy directory (deploy.so/json/params).") + .def("set_input", &Runtime::set_input, py::arg("index"), py::arg("data")) + .def("run", &Runtime::run) + .def("num_input", &Runtime::num_input) + .def("num_output", &Runtime::num_output) + .def("get_output", &Runtime::get_output, py::arg("index")); +} diff --git a/rzv2h/emulation/drpai_runtime.py b/rzv2h/emulation/drpai_runtime.py new file mode 100644 index 0000000..6fdbe25 --- /dev/null +++ b/rzv2h/emulation/drpai_runtime.py @@ -0,0 +1,123 @@ +# drpai_runtime.py — off-board stand-in for the native pybind `drpai_runtime`. +# Copyright (C) 2024-2026 Collabora Ltd. — LGPL (see COPYING). +# +# Same interface as the C++ binding (Runtime.load / set_input / run / +# num_output / get_output), with two backends auto-selected by what's in the +# model directory and what's importable: +# +# 1. MERA / TVM graph_executor — if the dir has deploy.so/json/params AND a +# `tvm` runtime is importable (i.e. inside the Renesas DRP-AI TVM SDK +# container, or on the board). This runs the REAL MERA/TVM runtime — the +# faithful "test through the TVM runtime". On the board the deploy.so runs +# on the DRP-AI NPU / Arm CPU; in the SDK container it runs on whatever the +# module was compiled for (aarch64 needs QEMU; an x86-target build runs +# natively for functional check). +# +# 2. ONNX Runtime (CPU) — fallback look-alike for plain x86 dev boxes +# with no SDK: runs the same yolo11m.onnx that feeds the DRP-AI compiler so +# the engine's preprocess/reshape/decode path is exercised. Validates our +# code, NOT the DRP-AI/MERA runtime. +# +# get_output() always returns a FLAT array, matching the C++ GetOutput buffer, +# so the engine's reshape-to-(1, 4+nc, anchors) path is genuinely tested. + +import glob +import os + +import numpy as np + + +class Runtime: + def __init__(self): + self._backend = None + # tvm backend + self._mod = None + self._dev = None + self._input_name = os.getenv("DRPAI_INPUT_NAME", "images") + # onnx backend + self._sess = None + self._ort_input = None + self._feed = None + self._outputs = None + + def load(self, model_dir): + deploy_so = os.path.join(model_dir, "deploy.so") + if os.path.isfile(deploy_so) and self._try_load_tvm(model_dir, deploy_so): + return True + return self._try_load_onnx(model_dir) + + # ---- backend 1: real MERA / TVM graph_executor ---- + def _try_load_tvm(self, model_dir, deploy_so): + try: + import tvm + from tvm.contrib import graph_executor + except ImportError: + return False + try: + lib = tvm.runtime.load_module(deploy_so) + with open(os.path.join(model_dir, "deploy.json")) as f: + graph = f.read() + self._dev = tvm.cpu(0) + self._mod = graph_executor.create(graph, lib, self._dev) + with open(os.path.join(model_dir, "deploy.params"), "rb") as f: + self._mod.load_params(bytearray(f.read())) + self._backend = "tvm" + print( + f"[drpai_runtime] MERA/TVM graph_executor backend " + f"(deploy.so, input='{self._input_name}') — real runtime" + ) + return True + except Exception as e: + print(f"[drpai_runtime] TVM backend load failed ({e}); trying ONNX") + return False + + # ---- backend 2: ONNX Runtime look-alike ---- + def _try_load_onnx(self, model_dir): + try: + import onnxruntime as ort + except ImportError: + print("[drpai_runtime] no TVM and no onnxruntime — cannot load") + return False + onnx_files = sorted(glob.glob(os.path.join(model_dir, "*.onnx"))) + if not onnx_files: + print(f"[drpai_runtime] no deploy.so and no .onnx in {model_dir!r}") + return False + self._sess = ort.InferenceSession( + onnx_files[0], providers=["CPUExecutionProvider"] + ) + self._ort_input = self._sess.get_inputs()[0].name + self._backend = "onnx" + print( + f"[drpai_runtime] ONNX Runtime EMULATION backend ({onnx_files[0]}, " + f"input='{self._ort_input}') — NOT the NPU/MERA runtime" + ) + return True + + def set_input(self, index, data): + arr = np.ascontiguousarray(data, dtype=np.float32) + if self._backend == "tvm": + import tvm + + self._mod.set_input(self._input_name, tvm.nd.array(arr, self._dev)) + else: + self._feed = arr + + def run(self): + if self._backend == "tvm": + self._mod.run() + else: + self._outputs = self._sess.run(None, {self._ort_input: self._feed}) + + def num_input(self): + return 1 + + def num_output(self): + if self._backend == "tvm": + return self._mod.get_num_outputs() + return len(self._outputs) if self._outputs is not None else 0 + + def get_output(self, index): + # Flat buffer, like the C++ GetOutput; the engine reshapes it. + if self._backend == "tvm": + return self._mod.get_output(index).numpy().reshape(-1).astype(np.float32) + return np.asarray(self._outputs[index], dtype=np.float32).reshape(-1) diff --git a/rzv2h/emulation/run_emulated.sh b/rzv2h/emulation/run_emulated.sh new file mode 100755 index 0000000..527acf8 --- /dev/null +++ b/rzv2h/emulation/run_emulated.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the DRP-AI object-detection pipeline on the DEV BOX using the emulated +# drpai_runtime (CPU/ONNX Runtime stand-in) — same engine code as the board, +# but no NPU. For validating the integration before deploying to RZ/V2H. +# +# Usage: ./run_emulated.sh [INPUT.mp4] [OUTPUT.mp4] +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +cd "$REPO" + +source .venv/bin/activate +export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" +export PYTHONPATH="$HERE:${PYTHONPATH:-}" # resolves `import drpai_runtime` to the fake + +IN="${1:-08fd33_4.mp4}" +OUT="${2:-${IN%.*}_drpai_emu.mp4}" +DEPLOY="$HERE/yolo11m_drpai_v2h_emu" # dir containing yolo11m.onnx + +if [[ ! -f "$DEPLOY/yolo11m.onnx" ]]; then + echo "Missing $DEPLOY/yolo11m.onnx — export it first:" >&2 + echo " yolo export model=yolo11m.pt format=onnx imgsz=640 opset=12 simplify=True" >&2 + echo " mkdir -p $DEPLOY && cp yolo11m.onnx $DEPLOY/" >&2 + exit 1 +fi + +echo "EMULATED DRP-AI run: '$IN' -> '$OUT' (CPU/ONNX, not the NPU)" +gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name="$DEPLOY" device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker tracker-type=bytetrack \ + ! videoconvert ! "video/x-raw,format=RGBA" \ + ! pyml_football_overlay show-ids=false show-labels=false \ + ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" +echo "Done: $OUT" diff --git a/rzv2h/sdk_eval/README.md b/rzv2h/sdk_eval/README.md new file mode 100644 index 0000000..13fa30f --- /dev/null +++ b/rzv2h/sdk_eval/README.md @@ -0,0 +1,113 @@ +# Faithful DRP-AI TVM eval (real mera2 / MERA runtime) + +This is the most faithful test short of running on hardware: the **real** +`mera2` compile and the **real** MERA/TVM runtime, instead of the ONNX-RT +look-alike in [../emulation](../emulation). It composes with the same +`engine-name=drpai` + `drpai_runtime` shim we use everywhere else. + +## Read this first — what's gated, and the aarch64 catch + +Two things make this unable to run on a plain x86 box out of the box: + +1. **License-gated downloads (Renesas account required).** The stack build needs + the **DRP-AI Translator i8** and the **RZ/V2H AI SDK** (`RTK0EF0180F06000SJ.zip`). + There is **no public prebuilt image**; you download these and build Renesas' + `Dockerfile`. I cannot fetch them for you. +2. **The compile targets aarch64, not x86.** Even `compile_cpu_only_onnx_model.py` + uses `target = "llvm ... -mtriple=aarch64-linux-gnu"` and the SDK's aarch64 + cross-g++. So `deploy.so` runs on the board's Arm CPU / NPU — to execute it + off-board you either run on the **board**, under **QEMU-aarch64**, or compile + with an **x86 `llvm` target** for a pure functional check (see below). + +If you don't have the downloads, the ONNX-RT emulation in `../emulation` +already validates all of *our* code (engine preprocess/reshape/decode + +pipeline). What's left to validate here is mera2-compile success and runtime +numerics — both inherently need Renesas assets or hardware. + +## Steps + +### 1. Build the SDK image (host, needs the two downloads) + +```bash +mkdir -p rzv2h/sdk_eval/assets +# put both Renesas downloads in rzv2h/sdk_eval/assets/ : +# DRP-AI_Translator_i8-*-Linux-x86_64-Install and RTK0EF0180F06000SJ.zip +cd rzv2h/sdk_eval && ./build_image.sh +``` + +`build_image.sh` fetches the repo `Dockerfile`, assembles a clean build context +(Dockerfile + the toolchain `.sh` it unzips from the AI SDK zip + the Translator +installer), and runs `docker build --build-arg PRODUCT=V2H -t drpai-tvm-v2h`. +The Dockerfile (`FROM ubuntu:22.04`) defaults `PRODUCT=V2H` and builds the TVM +fork itself, so the build takes a while. + +To fetch just the Dockerfile by hand: +`wget https://raw.githubusercontent.com/renesas-rz/rzv_drp-ai_tvm/main/Dockerfile` + +### 2. Compile YOLO11 with the real mera2 (inside the container) + +```bash +docker run -it --rm -v "$PWD":/workspace/gst-python-ml drpai-tvm-v2h bash +# inside: +cd /workspace/gst-python-ml +./rzv2h/convert_yolo11_v2h.sh yolo11m 640 # real mera2.from_onnx + mera2.drp.build +# -> yolo11m_drpai_v2h/{deploy.so,deploy.json,deploy.params} (aarch64) +``` + +For a **host x86 functional check** instead of the board artifact, compile with a +native target (edit a copy of `tutorials/compile_onnx_model.py` to +`target = "llvm"` and drop the aarch64 cross-compiler), producing an x86 +`deploy.so` the MERA/TVM `graph_executor` can run natively. + +### 3. Run through the real MERA/TVM runtime + +The [../emulation/drpai_runtime.py](../emulation/drpai_runtime.py) shim +auto-selects the **MERA/TVM `graph_executor`** backend as soon as the model dir +has `deploy.so/json/params` and `tvm` is importable (true inside this +container). The engine code is unchanged. + +```bash +export GST_PLUGIN_PATH=/workspace/gst-python-ml/plugins:$GST_PLUGIN_PATH +export PYTHONPATH=/workspace/gst-python-ml/rzv2h/emulation:$PYTHONPATH +# (x86 deploy.so) run natively; (aarch64 deploy.so) run under qemu-aarch64 +gst-launch-1.0 filesrc location=08fd33_4.mp4 ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,format=RGB,width=640,height=640" \ + ! pyml_objectdetector engine-name=drpai model-name=yolo11m_drpai_v2h device=drpai \ + input-format=nchw post-process=anchor_free \ + ! pyml_tracker ! videoconvert ! "video/x-raw,format=RGBA" \ + ! pyml_football_overlay ! videoconvert ! autovideosink +``` + +The shim prints which backend it picked: +`[drpai_runtime] MERA/TVM graph_executor backend ... — real runtime`. + +## On the actual board + +Two ways to run the same pipeline on the RZ/V2H: + +- **Python graph_executor** — copy the `deploy.so/json/params` + the emulation + shim; if the board image has the MERA/TVM python runtime, it Just Works (the + shim's TVM backend), NPU included. +- **C++ pybind binding** — build [../drpai_runtime_pybind.cpp](../drpai_runtime_pybind.cpp) + per [../README.md](../README.md); the native `drpai_runtime.so` takes + precedence over this shim on `PYTHONPATH`. + +## Verified results (RZ/V2H AI SDK v6.00 + DRP-AI Translator i8 v1.11) + +Both paths were run end-to-end driving the `drpai-tvm-v2h` image on an x86 host: + +- **x86 MERA/TVM runtime test** — `compile_x86_cpu.py` compiled YOLO11 via the + MERA-fork TVM (native `llvm`), and `x86_runtime_check.py` ran it through the + real `graph_executor`: output matched ONNX to **max|Δ| = 6.2e-3**, **22 = 22 + detections** (label `person`). Confirms compile + MERA/TVM runtime + the + `drpai_runtime` shim + our decoder, no NPU needed. +- **Real INT8 NPU compile** — `../convert_yolo11_v2h.sh` (quantized flow) + produced the RZ/V2H deploy dir: `[Finish DRP-AI Translator for V2H]`, + `sub_0000__CPU_DRP_TVM/{deploy.so (65 MB),deploy.json,deploy.params}` + + `preprocess/` (DRP-AI pre-processing objects). aarch64 — runs on the board. + +SDK gotchas the scripts now handle automatically: `run_drp_compiler.sh` ships +non-executable and off-PATH (`chmod +x` + add tutorials to PATH); the quant +script preprocesses calibration as ImageNet-224 instead of 640 (patched). And +V2H **requires** the INT8 quantized flow — the plain FP `compile_onnx_model.py` +drives a legacy translator path the i8 v1.11 layout lacks. diff --git a/rzv2h/sdk_eval/_probe_sysroot.sh b/rzv2h/sdk_eval/_probe_sysroot.sh new file mode 100644 index 0000000..0344994 --- /dev/null +++ b/rzv2h/sdk_eval/_probe_sysroot.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Probe the RZ/V2H board rootfs (via the cross-SDK aarch64 sysroot) for the +# GStreamer + Python stack our pipeline needs. Run inside drpai-tvm-v2h. +# Target rootfs sysroot (NOT the x86_64-pokysdk-linux cross-compiler dir). +SR=$(ls -d /opt/*/*/sysroots/*-poky-linux 2>/dev/null | grep -v pokysdk | head -1) +[ -d "$SR" ] || SR=$(ls -d /opt/*/sysroots/*-poky-linux 2>/dev/null | grep -v pokysdk | head -1) +echo "sysroot = $SR" +echo "--- python3 ---"; ls -d "$SR"/usr/lib/python3* 2>/dev/null | head -1 +echo "--- gstreamer core ---"; ls "$SR"/usr/lib/libgstreamer-1.0.so.* 2>/dev/null +grep -h "Version" "$SR"/usr/lib/pkgconfig/gstreamer-1.0.pc 2>/dev/null +echo "--- gst-python loader (libgstpython) ---"; find "$SR" -name 'libgstpython*' 2>/dev/null | head +echo "--- GstAnalytics (lib + typelib) ---" +find "$SR" -iname '*gstanalytics*' 2>/dev/null | head +ls "$SR"/usr/lib/girepository-1.0/ 2>/dev/null | grep -iE 'Analytics|GstApp|GstBase|^Gst-' | head +echo "--- python modules on target: gi / numpy / cairo / cv2 ---" +for m in gi numpy cairo cv2; do + hit=$(find "$SR" -maxdepth 7 -path '*python3*' -iname "${m}" 2>/dev/null | head -1) + echo "$m: ${hit:-MISSING}" +done +echo "--- tvm / mera python runtime on target? ---" +find "$SR" -iname '*tvm*' -o -iname '*mera*' 2>/dev/null | grep -i python | head +echo "--- gstreamer plugins present (count) ---" +ls "$SR"/usr/lib/gstreamer-1.0/*.so 2>/dev/null | wc -l diff --git a/rzv2h/sdk_eval/build_image.sh b/rzv2h/sdk_eval/build_image.sh new file mode 100755 index 0000000..7519a5d --- /dev/null +++ b/rzv2h/sdk_eval/build_image.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build the Renesas DRP-AI TVM (Mera2) Docker image for RZ/V2H. +# +# This is the *faithful* compile/runtime stack (real mera2 + MERA runtime). +# It needs two downloads that require a Renesas account login — put them in +# ./assets first (this script cannot download them for you). Both arrive as +# ZIPs and can be dropped in as-is: +# +# DRP-AI Translator i8 (ZIP, contains DRP-AI_Translator_i8-*-Linux-x86_64-Install) +# https://www.renesas.com/software-tool/drp-ai-translator-i8 (Downloads tab) +# RZ/V2H AI SDK (RTK0EF0180F*SJ.zip) +# https://www.renesas.com/us/en/software-tool/rzv2h-ai-software-development-kit +# +# The repo Dockerfile COPYs every ./*.sh in the context and runs it, plus +# ./DRP-AI_Translator*-Install. So we assemble a CLEAN context holding only: +# Dockerfile + the SDK toolchain installer (.sh, from the AI SDK zip) + the +# Translator installer (from the Translator zip). +set -euo pipefail +cd "$(dirname "$0")" +ASSETS="${ASSETS:-./assets}" +CTX="${CTX:-./context}" +PRODUCT="${PRODUCT:-V2H}" +TAG="${TAG:-drpai-tvm-v2h}" + +mkdir -p "$ASSETS" +TMPS=() +cleanup() { for d in "${TMPS[@]:-}"; do [[ -n "$d" ]] && rm -rf "$d"; done; } +trap cleanup EXIT + +# --- DRP-AI Translator i8: accept an extracted *-Install or the downloaded zip --- +TR=$(ls "$ASSETS"/DRP-AI_Translator*-Linux*-x86_64-Install 2>/dev/null | head -n1 || true) +if [[ -z "$TR" ]]; then + TRZIP=$(ls "$ASSETS"/*[Tt]ranslator*i8*.zip "$ASSETS"/*DRP-AI_Translator*.zip 2>/dev/null | head -n1 || true) + if [[ -n "$TRZIP" ]]; then + t=$(mktemp -d); TMPS+=("$t") + unzip -o -q "$TRZIP" -d "$t" + TR=$(find "$t" -iname "DRP-AI_Translator*-Linux*-x86_64-Install" | head -n1 || true) + fi +fi + +# --- RZ/V2H AI SDK zip (any v6.x build number) --- +ZIP=$(ls "$ASSETS"/RTK0EF0180F*SJ.zip 2>/dev/null | head -n1 || true) + +if [[ -z "$TR" || -z "$ZIP" ]]; then + echo "Missing gated downloads in $ASSETS (Renesas login required):" >&2 + [[ -z "$TR" ]] && echo " - DRP-AI Translator i8 (zip or extracted *-Install)" >&2 + [[ -z "$ZIP" ]] && echo " - RZ/V2H AI SDK (RTK0EF0180F*SJ.zip)" >&2 + exit 1 +fi + +# Clean build context. +rm -rf "$CTX" && mkdir -p "$CTX" +wget -nc https://raw.githubusercontent.com/renesas-rz/rzv_drp-ai_tvm/main/Dockerfile \ + -O "$CTX/Dockerfile" +cp "$TR" "$CTX/" + +# Unzip the AI SDK and extract its Yocto toolchain installer (.sh) into context. +s=$(mktemp -d); TMPS+=("$s") +unzip -o -q "$ZIP" -d "$s" +# The Yocto toolchain installer is the big *toolchain*.sh (e.g. +# ai_sdk_setup/rz-vlp-...-rzv2h-evk-toolchain-5.0.11.sh). Pick the largest +# match so we don't grab a small board/flash helper script by mistake. +SDK_SH=$(find "$s" -iname "*toolchain*.sh" -printf '%s\t%p\n' | sort -rn | head -n1 | cut -f2-) +[[ -n "$SDK_SH" ]] || { echo "No toolchain .sh found inside $ZIP" >&2; exit 1; } +cp "$SDK_SH" "$CTX/" + +echo "Build context ready in $CTX:" +ls -1 "$CTX" +echo +echo "Building image '$TAG' (PRODUCT=$PRODUCT) — builds the TVM fork, takes a while..." +docker build --build-arg PRODUCT="$PRODUCT" -t "$TAG" "$CTX" + +cat </deploy.{so,json,params} — loadable by tvm.contrib.graph_executor, i.e. +# by the drpai_runtime shim's TVM backend. +# +# Run inside the drpai-tvm-v2h container: +# python3 compile_x86_cpu.py [input_name] [C,H,W] +import os +import sys + +import onnx +import tvm +from tvm import relay +from tvm.relay import transform +from tvm.relay.build_module import build as _build, bind_params_by_name +from tvm.relay.param_dict import save_param_dict +from tvm.ir.transform import Sequential, PassContext + +model_file = sys.argv[1] +out_dir = sys.argv[2] +input_name = sys.argv[3] if len(sys.argv) > 3 else "images" +chw = [int(x) for x in (sys.argv[4].split(",") if len(sys.argv) > 4 else [3, 640, 640])] +input_shape = [1] + chw + +os.makedirs(out_dir, exist_ok=True) +print(f"[x86 compile] {model_file} input {input_name}={input_shape} -> {out_dir}") + +onnx_model = onnx.load_model(model_file) +mod, params = relay.frontend.from_onnx(onnx_model, {input_name: input_shape}) +if params: + mod["main"] = bind_params_by_name(mod["main"], params) + +with PassContext(opt_level=3): + mod = Sequential([ + transform.SimplifyInference(), + transform.FoldConstant(), + transform.FoldExplicitPadding(), + transform.BackwardFoldScaleAxis(), + transform.ForwardFoldScaleAxis(), + transform.FoldConstant(), + transform.DynamicToStatic(), + transform.RemoveUnusedFunctions(), + ])(mod) + +target = "llvm" # native host (x86), no aarch64 cross target +with PassContext(opt_level=3): + graph, lib, all_params = _build(mod, target=target, target_host=target, params=params) + +lib.export_library(os.path.join(out_dir, "deploy.so")) # default host compiler -> x86 .so +with open(os.path.join(out_dir, "deploy.json"), "w") as f: + f.write(graph) +with open(os.path.join(out_dir, "deploy.params"), "wb") as f: + f.write(save_param_dict(all_params)) +print(f"[x86 compile finished] -> {out_dir}/deploy.so,deploy.json,deploy.params") diff --git a/rzv2h/sdk_eval/x86_runtime_check.py b/rzv2h/sdk_eval/x86_runtime_check.py new file mode 100644 index 0000000..d1ee523 --- /dev/null +++ b/rzv2h/sdk_eval/x86_runtime_check.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# x86_runtime_check.py — run a real input through the MERA/TVM graph_executor +# (via the drpai_runtime shim's TVM backend) and check parity against the +# known-good ONNX output. Run INSIDE the drpai-tvm-v2h container; needs the +# x86 deploy dir + the pre-saved input/onnx-reference .npy files. +import sys +import numpy as np + +sys.path.insert(0, "/work/rzv2h/emulation") # drpai_runtime shim (TVM backend) +sys.path.insert(0, "/work/plugins/python") # utils.detection_decoder (pure numpy) + +import drpai_runtime +from utils.detection_decoder import decode + +DEPLOY = "/work/rzv2h/yolo11m_x86_cpu" +x = np.load("/work/rzv2h/_x86test_input.npy").astype(np.float32) +ref = np.load("/work/rzv2h/_x86test_onnxout.npy").astype(np.float32).reshape(-1) + +rt = drpai_runtime.Runtime() +assert rt.load(DEPLOY), "drpai_runtime.load failed" +rt.set_input(0, x) +rt.run() +out = np.asarray(rt.get_output(0), dtype=np.float32).reshape(-1) + +n = min(out.size, ref.size) +maxdiff = float(np.max(np.abs(out[:n] - ref[:n]))) if n else float("nan") +print(f"TVM out size={out.size} ref size={ref.size} max|TVM-ONNX|={maxdiff:.3e}") + +tvm_det = decode(out.reshape(1, 84, 8400), "anchor_free")[0] +onnx_det = decode(ref.reshape(1, 84, 8400), "anchor_free")[0] +print(f"detections TVM={len(tvm_det['boxes'])} ONNX={len(onnx_det['boxes'])}") +if len(tvm_det["boxes"]): + print("TVM labels:", sorted(set(int(c) for c in tvm_det["labels"]))) +print("PASS" if maxdiff < 1e-2 and len(tvm_det["boxes"]) == len(onnx_det["boxes"]) else "CHECK") diff --git a/rzv2h/yocto/README.md b/rzv2h/yocto/README.md new file mode 100644 index 0000000..ccfbdb4 --- /dev/null +++ b/rzv2h/yocto/README.md @@ -0,0 +1 @@ +# Custom RZ/V2H image for the gst-python-ml pipeline diff --git a/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc b/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc new file mode 100644 index 0000000..cfa0a3d --- /dev/null +++ b/rzv2h/yocto/meta-gst-python-ml/conf/include/gstreamer-1.24.inc @@ -0,0 +1,25 @@ +# Pin GStreamer to 1.24 across the stack so GstAnalytics is available. +# +# scarthgap's oe-core ships GStreamer 1.22.x. The 1.24 recipes must be present +# in the build (see README: copy the gstreamer1.0* recipes from oe-core +# styhead/master into recipes-multimedia/gstreamer/ of this layer, or layer in +# a newer meta-oe). These PREFERRED_VERSION lines then select them. +# +# require this from local.conf: +# require ${TOPDIR}/../layers/meta-gst-python-ml/conf/include/gstreamer-1.24.inc + +GST_124 ?= "1.24.%" + +PREFERRED_VERSION_gstreamer1.0 = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-base = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-good = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-bad = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-plugins-ugly = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-libav = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-python = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-rtsp-server = "${GST_124}" +PREFERRED_VERSION_gstreamer1.0-vaapi = "${GST_124}" + +# GstAnalytics + the object-detection / tracking metas live in -plugins-bad. +# Make sure analytics isn't disabled by a PACKAGECONFIG override. +PACKAGECONFIG:append:pn-gstreamer1.0-plugins-bad = " analytics" diff --git a/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf b/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf new file mode 100644 index 0000000..262f68a --- /dev/null +++ b/rzv2h/yocto/meta-gst-python-ml/conf/layer.conf @@ -0,0 +1,15 @@ +# meta-gst-python-ml — adds the runtime stack gst-python-ml needs on RZ/V2H. +# +# The RZ/V2H AI SDK v6.00 image is Yocto scarthgap (5.0.11) with GStreamer +# 1.22.x. gst-python-ml requires GStreamer >= 1.24 (for GstAnalytics, the +# metadata type every pyml_* element uses), the gst-python plugin loader, and +# numpy/pycairo/pygobject/opencv. This layer carries those additions. +BBPATH .= ":${LAYERDIR}" +BBFILES += "${LAYERDIR}/recipes-*/*/*.bb ${LAYERDIR}/recipes-*/*/*.bbappend" + +BBFILE_COLLECTIONS += "gst-python-ml" +BBFILE_PATTERN_gst-python-ml = "^${LAYERDIR}/" +BBFILE_PRIORITY_gst-python-ml = "20" + +LAYERDEPENDS_gst-python-ml = "core openembedded-layer" +LAYERSERIES_COMPAT_gst-python-ml = "scarthgap styhead" diff --git a/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb b/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb new file mode 100644 index 0000000..4a9cef9 --- /dev/null +++ b/rzv2h/yocto/meta-gst-python-ml/recipes-core/packagegroups/packagegroup-gst-python-ml.bb @@ -0,0 +1,26 @@ +SUMMARY = "Runtime stack for gst-python-ml on RZ/V2H (GStreamer 1.24 + Python)" +LICENSE = "MIT" + +inherit packagegroup + +RDEPENDS:${PN} = " \ + gstreamer1.0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-libav \ + gstreamer1.0-python \ + python3-core \ + python3-pygobject \ + python3-numpy \ + python3-pycairo \ + python3-opencv \ +" +# Notes: +# - gstreamer1.0-python provides the libgstpython.so plugin loader that runs +# the pyml_* .py elements. It is NOT in the stock AI SDK image. +# - GstAnalytics (used by base_objectdetector / tracker / overlay) ships in +# gstreamer1.0-plugins-bad once GStreamer is >= 1.24 with the analytics +# PACKAGECONFIG enabled (see conf/include/gstreamer-1.24.inc). +# - The DRP-AI MERA/TVM *Python* runtime is not a stock Yocto package; install +# it onto the image separately (see ../README.md "DRP-AI runtime on board"). diff --git a/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb b/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb new file mode 100644 index 0000000..3b3245b --- /dev/null +++ b/rzv2h/yocto/meta-gst-python-ml/recipes-multimedia/gst-python-ml/gst-python-ml_git.bb @@ -0,0 +1,35 @@ +SUMMARY = "gst-python-ml elements (pyml_*) + DRP-AI engine for RZ/V2H" +DESCRIPTION = "Installs the pure-Python GStreamer elements and sets GST_PLUGIN_PATH/PYTHONPATH." +LICENSE = "LGPL-2.1-or-later" +LIC_FILES_CHKSUM = "file://COPYING;md5=" + +# Point this at your gst-python-ml source. Examples: +# SRC_URI = "git://github.com/collabora/gst-python-ml.git;branch=main;protocol=https" +# SRCREV = "" +# or a local checkout via: SRC_URI = "file:///path/to/gst-python-ml" +SRC_URI = "git://github.com/collabora/gst-python-ml.git;branch=master;protocol=https" +SRCREV = "${AUTOREV}" +S = "${WORKDIR}/git" + +# Pure-Python elements: nothing to compile. +do_compile[noexec] = "1" + +PYML_DIR = "${datadir}/gst-python-ml" + +do_install() { + install -d ${D}${PYML_DIR} + cp -r ${S}/plugins ${D}${PYML_DIR}/plugins + + # Environment so GStreamer finds the .py elements and Python finds the pkg. + install -d ${D}${sysconfdir}/profile.d + cat > ${D}${sysconfdir}/profile.d/gst-python-ml.sh < Date: Tue, 16 Jun 2026 02:19:34 +0000 Subject: [PATCH 04/10] Fix split semicolon-joined statements style. Signed-off-by: Marcus Edel --- plugins/python/football_analyzer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/python/football_analyzer.py b/plugins/python/football_analyzer.py index e54873f..b103b63 100644 --- a/plugins/python/football_analyzer.py +++ b/plugins/python/football_analyzer.py @@ -273,7 +273,8 @@ def _minimap_extent(self, tracks, camera_motion): for key in ("players", "referees"): for p in tracks[key][i].values(): x, y = self._ref_bottom_center(p["bbox"], H_inv) - xs.append(x); ys.append(y) + xs.append(x) + ys.append(y) if not xs: return None min_x, max_x = min(xs), max(xs) From d090e87da063ac927b1175acf3c8bf1200431c65 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Tue, 16 Jun 2026 02:29:35 +0000 Subject: [PATCH 05/10] Apply black formatting to football_overlay.py and football_analyzer.py. Signed-off-by: Marcus Edel --- plugins/python/football_analyzer.py | 264 ++++++++++++++++++++-------- plugins/python/football_overlay.py | 224 +++++++++++++++++------ 2 files changed, 362 insertions(+), 126 deletions(-) diff --git a/plugins/python/football_analyzer.py b/plugins/python/football_analyzer.py index b103b63..3958a31 100644 --- a/plugins/python/football_analyzer.py +++ b/plugins/python/football_analyzer.py @@ -56,8 +56,7 @@ def get_bbox_width(bbox): class Tracker: - """Tracker. - """ + """Tracker.""" def __init__(self, model_path): self.model = YOLO(model_path) @@ -80,32 +79,59 @@ def _foreground_mask(self, shape, frame_tracks, dilation=15): mask[y1:y2, x1:x2] = 0 return mask - def get_camera_motion(self, frames, tracks, read_from_stub=False, stub_path=None, - ratio=0.75, ransac_thresh=3.0, min_matches=8): + def get_camera_motion( + self, + frames, + tracks, + read_from_stub=False, + stub_path=None, + ratio=0.75, + ransac_thresh=3.0, + min_matches=8, + ): if read_from_stub and stub_path is not None and os.path.exists(stub_path): - with open(stub_path, 'rb') as f: + with open(stub_path, "rb") as f: return pickle.load(f) cumulative = [np.eye(3, dtype=np.float64)] prev_gray = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY) - prev_mask = self._foreground_mask(frames[0].shape, - {k: tracks[k][0] for k in tracks}) + prev_mask = self._foreground_mask( + frames[0].shape, {k: tracks[k][0] for k in tracks} + ) prev_kp, prev_desc = self.sift.detectAndCompute(prev_gray, prev_mask) for i in range(1, len(frames)): curr_gray = cv2.cvtColor(frames[i], cv2.COLOR_BGR2GRAY) - curr_mask = self._foreground_mask(frames[i].shape, - {k: tracks[k][i] for k in tracks}) + curr_mask = self._foreground_mask( + frames[i].shape, {k: tracks[k][i] for k in tracks} + ) curr_kp, curr_desc = self.sift.detectAndCompute(curr_gray, curr_mask) H_step = np.eye(3, dtype=np.float64) - if prev_desc is not None and curr_desc is not None and len(prev_desc) >= 2 and len(curr_desc) >= 2: + if ( + prev_desc is not None + and curr_desc is not None + and len(prev_desc) >= 2 + and len(curr_desc) >= 2 + ): knn = self.matcher.knnMatch(prev_desc, curr_desc, k=2) - good = [m for pair in knn if len(pair) == 2 for m, n in [pair] if m.distance < ratio * n.distance] + good = [ + m + for pair in knn + if len(pair) == 2 + for m, n in [pair] + if m.distance < ratio * n.distance + ] if len(good) >= min_matches: - pts_prev = np.float32([prev_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) - pts_curr = np.float32([curr_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) - H, _ = cv2.findHomography(pts_prev, pts_curr, cv2.RANSAC, ransac_thresh) + pts_prev = np.float32( + [prev_kp[m.queryIdx].pt for m in good] + ).reshape(-1, 1, 2) + pts_curr = np.float32( + [curr_kp[m.trainIdx].pt for m in good] + ).reshape(-1, 1, 2) + H, _ = cv2.findHomography( + pts_prev, pts_curr, cv2.RANSAC, ransac_thresh + ) if H is not None: H_step = H @@ -113,7 +139,7 @@ def get_camera_motion(self, frames, tracks, read_from_stub=False, stub_path=None prev_kp, prev_desc = curr_kp, curr_desc if stub_path is not None: - with open(stub_path, 'wb') as f: + with open(stub_path, "wb") as f: pickle.dump(cumulative, f) return cumulative @@ -121,13 +147,13 @@ def detect_frames(self, frames): batch_size = 20 detections = [] for i in range(0, len(frames), batch_size): - detections_batch = self.model.predict(frames[i: i + batch_size], conf=0.1) + detections_batch = self.model.predict(frames[i : i + batch_size], conf=0.1) detections += detections_batch return detections def get_object_tracks(self, frames, read_from_stub=False, stub_path=None): if read_from_stub and stub_path is not None and os.path.exists(stub_path): - with open(stub_path, 'rb') as f: + with open(stub_path, "rb") as f: tracks = pickle.load(f) return tracks @@ -161,7 +187,9 @@ def get_object_tracks(self, frames, read_from_stub=False, stub_path=None): for tid, v in class_votes.items() } - for frame_num, (tracked, raw_detections, cls_names, cls_names_inv) in enumerate(per_frame): + for frame_num, (tracked, raw_detections, cls_names, cls_names_inv) in enumerate( + per_frame + ): tracks["players"].append({}) tracks["referees"].append({}) tracks["ball"].append({}) @@ -176,11 +204,11 @@ def get_object_tracks(self, frames, read_from_stub=False, stub_path=None): tracks["referees"][frame_num][track_id] = {"bbox": bbox} for fd in raw_detections: - if fd[3] == cls_names_inv['ball']: + if fd[3] == cls_names_inv["ball"]: tracks["ball"][frame_num][1] = {"bbox": fd[0].tolist()} if stub_path is not None: - with open(stub_path, 'wb') as f: + with open(stub_path, "wb") as f: pickle.dump(tracks, f) return tracks @@ -210,24 +238,37 @@ def draw_ellipse(self, frame, bbox, color, track_id=None): y2_rect = (y2 + rectangle_height // 2) + 15 if track_id is not None: - cv2.rectangle(frame, (int(x1_rect), int(y1_rect)), - (int(x2_rect), int(y2_rect)), color, cv2.FILLED) + cv2.rectangle( + frame, + (int(x1_rect), int(y1_rect)), + (int(x2_rect), int(y2_rect)), + color, + cv2.FILLED, + ) x1_text = x1_rect + 12 if track_id > 99: x1_text -= 10 - cv2.putText(frame, f"{track_id}", - (int(x1_text), int(y1_rect + 15)), - cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2) + cv2.putText( + frame, + f"{track_id}", + (int(x1_text), int(y1_rect + 15)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 0, 0), + 2, + ) return frame def draw_traingle(self, frame, bbox, color): y = int(bbox[1]) x, _ = get_center_of_bbox(bbox) - triangle_points = np.array([ - [x, y], - [x - 10, y - 20], - [x + 10, y - 20], - ]) + triangle_points = np.array( + [ + [x, y], + [x - 10, y - 20], + [x + 10, y - 20], + ] + ) cv2.drawContours(frame, [triangle_points], 0, color, cv2.FILLED) cv2.drawContours(frame, [triangle_points], 0, (0, 0, 0), 2) return frame @@ -260,16 +301,20 @@ def classify_jersey(self, frame, bbox): def _ref_bottom_center(self, bbox, H_inv): xc, _ = get_center_of_bbox(bbox) yb = int(bbox[3]) - pt = cv2.perspectiveTransform( - np.array([[[xc, yb]]], dtype=np.float32), H_inv - )[0][0] + pt = cv2.perspectiveTransform(np.array([[[xc, yb]]], dtype=np.float32), H_inv)[ + 0 + ][0] return float(pt[0]), float(pt[1]) def _minimap_extent(self, tracks, camera_motion): xs, ys = [], [] n = len(tracks["players"]) for i in range(n): - H_inv = np.linalg.inv(camera_motion[i]) if camera_motion is not None else np.eye(3) + H_inv = ( + np.linalg.inv(camera_motion[i]) + if camera_motion is not None + else np.eye(3) + ) for key in ("players", "referees"): for p in tracks[key][i].values(): x, y = self._ref_bottom_center(p["bbox"], H_inv) @@ -316,7 +361,9 @@ def draw_trail(self, frame, points, color): if len(points) < 2: return frame pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) - cv2.polylines(frame, [pts], isClosed=False, color=color, thickness=2, lineType=cv2.LINE_AA) + cv2.polylines( + frame, [pts], isClosed=False, color=color, thickness=2, lineType=cv2.LINE_AA + ) return frame def _point_to_bbox_distance(self, px, py, bbox): @@ -343,7 +390,9 @@ def _ball_contact(self, player_dict, ball_bbox, contact_pad_ratio): def _count_total_contacts(self, tracks, contact_gap_frames, contact_pad_ratio): totals = {} last_contact_frame = {} - for frame_num, (player_dict, ball_dict) in enumerate(zip(tracks["players"], tracks["ball"])): + for frame_num, (player_dict, ball_dict) in enumerate( + zip(tracks["players"], tracks["ball"]) + ): ball = ball_dict.get(1) if ball is None or not player_dict: continue @@ -356,7 +405,9 @@ def _count_total_contacts(self, tracks, contact_gap_frames, contact_pad_ratio): last_contact_frame[tid] = frame_num return totals - def draw_player_hud(self, frame, player_id, contacts, distance_m, color, headshot=None): + def draw_player_hud( + self, frame, player_id, contacts, distance_m, color, headshot=None + ): x, y = 10, 10 bg_color = (131, 41, 92) text_color = (47, 186, 64) @@ -371,23 +422,62 @@ def draw_player_hud(self, frame, player_id, contacts, distance_m, color, headsho cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) if headshot is not None: hy, hx = y + 10, x + 10 - frame[hy:hy + headshot.shape[0], hx:hx + headshot.shape[1]] = headshot - cv2.rectangle(frame, (hx, hy), - (hx + headshot.shape[1], hy + headshot.shape[0]), color, 2) - cv2.putText(frame, "Player #8", (text_x, y + 28), - cv2.FONT_HERSHEY_SIMPLEX, 0.7, text_color, 2) - cv2.putText(frame, f"Ball contacts: {contacts}", (text_x, y + 58), - cv2.FONT_HERSHEY_SIMPLEX, 0.6, text_color, 1) - cv2.putText(frame, f"Distance: {distance_m:.1f} m", (text_x, y + 85), - cv2.FONT_HERSHEY_SIMPLEX, 0.6, text_color, 1) + frame[hy : hy + headshot.shape[0], hx : hx + headshot.shape[1]] = headshot + cv2.rectangle( + frame, + (hx, hy), + (hx + headshot.shape[1], hy + headshot.shape[0]), + color, + 2, + ) + cv2.putText( + frame, + "Player #8", + (text_x, y + 28), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + text_color, + 2, + ) + cv2.putText( + frame, + f"Ball contacts: {contacts}", + (text_x, y + 58), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + text_color, + 1, + ) + cv2.putText( + frame, + f"Distance: {distance_m:.1f} m", + (text_x, y + 85), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + text_color, + 1, + ) return frame - def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_length=30, - contact_gap_frames=5, contact_pad_ratio=0.25, - player_height_m=1.8, headshot_path=None, headshot_size=90, - logo_path=None, logo_height=80, logo_margin=15, - trail_smooth_window=11, - show_minimap=True, minimap_size=(320, 200), minimap_margin=15): + def draw_annotations( + self, + video_frames, + tracks, + camera_motion=None, + trail_length=30, + contact_gap_frames=5, + contact_pad_ratio=0.25, + player_height_m=1.8, + headshot_path=None, + headshot_size=90, + logo_path=None, + logo_height=80, + logo_margin=15, + trail_smooth_window=11, + show_minimap=True, + minimap_size=(320, 200), + minimap_margin=15, + ): output_video_frames = [] player_trails = {} team_votes = {} @@ -398,19 +488,25 @@ def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_lengt for frame_players in tracks["players"]: for tid in frame_players: frames_count[tid] = frames_count.get(tid, 0) + 1 - total_contacts = self._count_total_contacts(tracks, contact_gap_frames, contact_pad_ratio) + total_contacts = self._count_total_contacts( + tracks, contact_gap_frames, contact_pad_ratio + ) - heights = [p["bbox"][3] - p["bbox"][1] - for frame_players in tracks["players"] - for p in frame_players.values() - if p["bbox"][3] > p["bbox"][1]] + heights = [ + p["bbox"][3] - p["bbox"][1] + for frame_players in tracks["players"] + for p in frame_players.values() + if p["bbox"][3] > p["bbox"][1] + ] px_per_meter = float(np.median(heights)) / player_height_m if heights else 1.0 headshot = None if headshot_path is not None and os.path.exists(headshot_path): img = cv2.imread(headshot_path) if img is not None: - headshot = cv2.resize(img, (headshot_size, headshot_size), interpolation=cv2.INTER_AREA) + headshot = cv2.resize( + img, (headshot_size, headshot_size), interpolation=cv2.INTER_AREA + ) logo_bgr, logo_alpha = None, None if logo_path is not None and os.path.exists(logo_path): @@ -418,12 +514,16 @@ def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_lengt if img is not None: scale = logo_height / img.shape[0] new_w = max(1, int(round(img.shape[1] * scale))) - img = cv2.resize(img, (new_w, logo_height), interpolation=cv2.INTER_LANCZOS4) + img = cv2.resize( + img, (new_w, logo_height), interpolation=cv2.INTER_LANCZOS4 + ) if img.ndim == 3 and img.shape[2] == 4: logo_bgr = img[..., :3] logo_alpha = (img[..., 3:4].astype(np.float32)) / 255.0 else: - logo_bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + logo_bgr = ( + img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + ) minimap_bg, minimap_extent = None, None if show_minimap: @@ -432,8 +532,10 @@ def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_lengt minimap_bg = self._make_minimap_bg(minimap_size[0], minimap_size[1]) if total_contacts: - focal_tid = max(total_contacts, - key=lambda t: (total_contacts[t], frames_count.get(t, 0))) + focal_tid = max( + total_contacts, + key=lambda t: (total_contacts[t], frames_count.get(t, 0)), + ) elif frames_count: focal_tid = max(frames_count, key=frames_count.get) else: @@ -468,7 +570,9 @@ def draw_annotations(self, video_frames, tracks, camera_motion=None, trail_lengt if track_id in last_ref_pt: dx = ref_tuple[0] - last_ref_pt[track_id][0] dy = ref_tuple[1] - last_ref_pt[track_id][1] - player_distance[track_id] = player_distance.get(track_id, 0.0) + float(np.hypot(dx, dy)) + player_distance[track_id] = player_distance.get( + track_id, 0.0 + ) + float(np.hypot(dx, dy)) last_ref_pt[track_id] = ref_tuple vote = self.classify_jersey(frame, player["bbox"]) @@ -497,7 +601,11 @@ def color_for(track_id): counts = team_votes.get(track_id) if not counts or (counts["red"] == 0 and counts["blue"] == 0): return default_color - return team_bgr["red"] if counts["red"] >= counts["blue"] else team_bgr["blue"] + return ( + team_bgr["red"] + if counts["red"] >= counts["blue"] + else team_bgr["blue"] + ) for track_id, ref_points in player_trails.items(): smoothed_ref = self._smooth_points(ref_points, trail_smooth_window) @@ -533,7 +641,10 @@ def color_for(track_id): x1, y1 = x0 + lw, y0 + lh if logo_alpha is not None: roi = frame[y0:y1, x0:x1].astype(np.float32) - blended = roi * (1.0 - logo_alpha) + logo_bgr.astype(np.float32) * logo_alpha + blended = ( + roi * (1.0 - logo_alpha) + + logo_bgr.astype(np.float32) * logo_alpha + ) frame[y0:y1, x0:x1] = blended.astype(np.uint8) else: frame[y0:y1, x0:x1] = logo_bgr @@ -543,14 +654,18 @@ def color_for(track_id): mm_w, mm_h = minimap_size for tid, player in player_dict.items(): rx, ry = self._ref_bottom_center(player["bbox"], H_inv) - mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, rx, ry) + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, rx, ry + ) dot_color = color_for(tid) radius = 6 if tid == focal_tid else 4 cv2.circle(mm, (mx, my), radius, dot_color, cv2.FILLED) cv2.circle(mm, (mx, my), radius, (0, 0, 0), 1) for referee in referee_dict.values(): rx, ry = self._ref_bottom_center(referee["bbox"], H_inv) - mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, rx, ry) + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, rx, ry + ) cv2.circle(mm, (mx, my), 3, (0, 255, 255), cv2.FILLED) cv2.circle(mm, (mx, my), 3, (0, 0, 0), 1) ball = ball_dict.get(1) @@ -559,14 +674,15 @@ def color_for(track_id): bref = cv2.perspectiveTransform( np.array([[[bx, by]]], dtype=np.float32), H_inv )[0][0] - mx, my = self._project_to_minimap(minimap_extent, mm_w, mm_h, - float(bref[0]), float(bref[1])) + mx, my = self._project_to_minimap( + minimap_extent, mm_w, mm_h, float(bref[0]), float(bref[1]) + ) cv2.circle(mm, (mx, my), 4, (0, 255, 0), cv2.FILLED) cv2.circle(mm, (mx, my), 4, (0, 0, 0), 1) fh, fw = frame.shape[:2] x0 = max(0, fw - mm_w - minimap_margin) y0 = max(0, fh - mm_h - minimap_margin) - frame[y0:y0 + mm_h, x0:x0 + mm_w] = mm + frame[y0 : y0 + mm_h, x0 : x0 + mm_w] = mm output_video_frames.append(frame) @@ -684,9 +800,11 @@ def do_transform_ip(self, buf): self.logger.error("Failed to map incoming buffer for read") return Gst.FlowReturn.ERROR try: - frame = np.frombuffer(mapinfo.data, dtype=np.uint8).reshape( - self._height, self._width, 3 - ).copy() + frame = ( + np.frombuffer(mapinfo.data, dtype=np.uint8) + .reshape(self._height, self._width, 3) + .copy() + ) finally: buf.unmap(mapinfo) diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py index cdcda9e..94f372b 100644 --- a/plugins/python/football_overlay.py +++ b/plugins/python/football_overlay.py @@ -30,7 +30,14 @@ gi.require_version("GstVideo", "1.0") gi.require_version("GstAnalytics", "1.0") gi.require_version("GLib", "2.0") - from gi.repository import Gst, GstBase, GstVideo, GstAnalytics, GObject, GLib # noqa: E402 + from gi.repository import ( + Gst, + GstBase, + GstVideo, + GstAnalytics, + GObject, + GLib, + ) # noqa: E402 from log.logger_factory import LoggerFactory # noqa: E402 @@ -109,58 +116,109 @@ class FootballOverlay(GstBase.BaseTransform): __gsttemplates__ = (src_template, sink_template) show_labels = GObject.Property( - type=bool, default=True, nick="Show Labels", + type=bool, + default=True, + nick="Show Labels", blurb="Draw the class name above each object", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) show_ids = GObject.Property( - type=bool, default=True, nick="Show Track IDs", + type=bool, + default=True, + nick="Show Track IDs", blurb="Draw the track-id badge under each tracked object", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) trails = GObject.Property( - type=bool, default=True, nick="Show Trails", + type=bool, + default=True, + nick="Show Trails", blurb="Draw a fading motion trail behind each tracked object", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) trail_length = GObject.Property( - type=int, default=30, minimum=2, maximum=300, nick="Trail Length", + type=int, + default=30, + minimum=2, + maximum=300, + nick="Trail Length", blurb="Number of recent positions kept in each motion trail", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) show_hud = GObject.Property( - type=bool, default=True, nick="Show HUD", + type=bool, + default=True, + nick="Show HUD", blurb="Draw the focal-player HUD (headshot, label, contacts, distance)", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) headshot_path = GObject.Property( - type=str, default="data/Chinedu-Obasi_2684938.jpg", nick="Headshot Path", + type=str, + default="data/Chinedu-Obasi_2684938.jpg", + nick="Headshot Path", blurb="Image shown in the HUD (empty to disable)", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) headshot_size = GObject.Property( - type=int, default=90, minimum=16, maximum=512, nick="Headshot Size", + type=int, + default=90, + minimum=16, + maximum=512, + nick="Headshot Size", blurb="Headshot square size in pixels", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) player_label = GObject.Property( - type=str, default="Player #8", nick="Player Label", + type=str, + default="Player #8", + nick="Player Label", blurb="Static label drawn in the HUD", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) contact_pad_ratio = GObject.Property( - type=float, default=0.25, minimum=0.0, maximum=5.0, nick="Contact Pad Ratio", + type=float, + default=0.25, + minimum=0.0, + maximum=5.0, + nick="Contact Pad Ratio", blurb="Ball counts as a contact within this fraction of the player box size", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) contact_gap_frames = GObject.Property( - type=int, default=5, minimum=0, maximum=1000, nick="Contact Gap Frames", + type=int, + default=5, + minimum=0, + maximum=1000, + nick="Contact Gap Frames", blurb="Min frames between counted contacts for the same player", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) player_height = GObject.Property( - type=float, default=1.8, minimum=0.1, maximum=10.0, nick="Player Height (m)", + type=float, + default=1.8, + minimum=0.1, + maximum=10.0, + nick="Player Height (m)", blurb="Assumed real-world height used to convert pixels to metres", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) min_confidence = GObject.Property( - type=float, default=0.0, minimum=0.0, maximum=1.0, nick="Min Confidence", + type=float, + default=0.0, + minimum=0.0, + maximum=1.0, + nick="Min Confidence", blurb="Skip detections whose confidence is below this threshold", - flags=GObject.ParamFlags.READWRITE) + flags=GObject.ParamFlags.READWRITE, + ) class_names = GObject.Property( - type=str, default="", nick="Class Names", + type=str, + default="", + nick="Class Names", blurb="Comma-separated names to map numeric labels (label_N) from the " - "onnx/objectdetector path, e.g. 'ball,goalkeeper,player,referee'", - flags=GObject.ParamFlags.READWRITE) + "onnx/objectdetector path, e.g. 'ball,goalkeeper,player,referee'", + flags=GObject.ParamFlags.READWRITE, + ) def __init__(self): super().__init__() @@ -229,10 +287,14 @@ def _read_metadata(self, buf): if not presence: continue label, track_id = self._parse_label(full_label) - entries.append({ - "label": label.lower(), "track_id": track_id, - "confidence": score, "box": (x, y, x + w, y + h), - }) + entries.append( + { + "label": label.lower(), + "track_id": track_id, + "confidence": score, + "box": (x, y, x + w, y + h), + } + ) return entries @staticmethod @@ -283,9 +345,10 @@ def _update_tracks(self, entries): self._heights = self._heights[-600:] prev = self._last_pt.get(tid) if prev is not None: - self._distance_px[tid] = self._distance_px.get(tid, 0.0) + ( - (foot[0] - prev[0]) ** 2 + (foot[1] - prev[1]) ** 2 - ) ** 0.5 + self._distance_px[tid] = ( + self._distance_px.get(tid, 0.0) + + ((foot[0] - prev[0]) ** 2 + (foot[1] - prev[1]) ** 2) ** 0.5 + ) self._last_pt[tid] = foot trail = self._trail.setdefault(tid, []) trail.append(foot) @@ -311,6 +374,7 @@ def _px_per_meter(self): if not self._heights: return None import numpy as np + return float(np.median(self._heights)) / max(0.1, self.player_height) def _focal_track(self): @@ -318,8 +382,10 @@ def _focal_track(self): if not keys: return None if any(self._contacts.values()): - return max(keys, key=lambda t: (self._contacts.get(t, 0), - self._frames_seen.get(t, 0))) + return max( + keys, + key=lambda t: (self._contacts.get(t, 0), self._frames_seen.get(t, 0)), + ) return max(keys, key=lambda t: self._frames_seen.get(t, 0)) def _c(self, rgba): @@ -366,9 +432,17 @@ def _draw_ellipse(self, cv2, frame, box, rgba, track_id): x_center = int((x1 + x2) / 2) width = max(1, int(x2 - x1)) color = self._c(rgba) - cv2.ellipse(frame, (x_center, y_bottom), - (width, max(1, int(0.35 * width))), 0.0, -45, 235, - color, 2, cv2.LINE_AA) + cv2.ellipse( + frame, + (x_center, y_bottom), + (width, max(1, int(0.35 * width))), + 0.0, + -45, + 235, + color, + 2, + cv2.LINE_AA, + ) if self.show_ids and track_id is not None: rect_w, rect_h = 40, 18 x1r = x_center - rect_w // 2 @@ -377,9 +451,16 @@ def _draw_ellipse(self, cv2, frame, box, rgba, track_id): y2r = y_bottom + rect_h // 2 + 15 cv2.rectangle(frame, (x1r, y1r), (x2r, y2r), color, cv2.FILLED) tx = x1r + 12 - (10 if track_id > 99 else 0) - cv2.putText(frame, str(track_id), (tx, y1r + 14), - cv2.FONT_HERSHEY_SIMPLEX, 0.5, self._c(_BLACK_RGBA), 2, - cv2.LINE_AA) + cv2.putText( + frame, + str(track_id), + (tx, y1r + 14), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + self._c(_BLACK_RGBA), + 2, + cv2.LINE_AA, + ) def _draw_triangle(self, cv2, np, frame, box, rgba): x1, y1, x2, y2 = box @@ -391,8 +472,16 @@ def _draw_triangle(self, cv2, np, frame, box, rgba): def _draw_label(self, cv2, frame, box, label, rgba): x1, y1, _, _ = box - cv2.putText(frame, label, (int(x1), max(12, int(y1) - 6)), - cv2.FONT_HERSHEY_SIMPLEX, 0.5, self._c(rgba), 1, cv2.LINE_AA) + cv2.putText( + frame, + label, + (int(x1), max(12, int(y1) - 6)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + self._c(rgba), + 1, + cv2.LINE_AA, + ) def _draw_hud(self, cv2, frame, contacts, distance_m, rgba, headshot): font = cv2.FONT_HERSHEY_SIMPLEX @@ -412,12 +501,32 @@ def _draw_hud(self, cv2, frame, contacts, distance_m, rgba, headshot): hh = min(hh, fh - hy) hw = min(hw, fw - hx) if hh > 0 and hw > 0: - frame[hy:hy + hh, hx:hx + hw] = headshot[:hh, :hw] + frame[hy : hy + hh, hx : hx + hw] = headshot[:hh, :hw] cv2.rectangle(frame, (hx, hy), (hx + hw, hy + hh), self._c(rgba), 2) tc = self._c(_HUD_TEXT_RGBA) - cv2.putText(frame, self.player_label, (text_x, y + 28), font, 0.7, tc, 2, cv2.LINE_AA) - cv2.putText(frame, f"Ball contacts: {contacts}", (text_x, y + 58), font, 0.6, tc, 1, cv2.LINE_AA) - cv2.putText(frame, f"Distance: {distance_m:.1f} m", (text_x, y + 85), font, 0.6, tc, 1, cv2.LINE_AA) + cv2.putText( + frame, self.player_label, (text_x, y + 28), font, 0.7, tc, 2, cv2.LINE_AA + ) + cv2.putText( + frame, + f"Ball contacts: {contacts}", + (text_x, y + 58), + font, + 0.6, + tc, + 1, + cv2.LINE_AA, + ) + cv2.putText( + frame, + f"Distance: {distance_m:.1f} m", + (text_x, y + 85), + font, + 0.6, + tc, + 1, + cv2.LINE_AA, + ) def do_transform_ip(self, buf): try: @@ -447,8 +556,12 @@ def do_transform_ip(self, buf): if self.trails: for tid in active: self._draw_trail( - cv2, np, frame, self._trail.get(tid, []), - self._color_for(self._track_label.get(tid, ""), tid)) + cv2, + np, + frame, + self._trail.get(tid, []), + self._color_for(self._track_label.get(tid, ""), tid), + ) for e in entries: label = e["label"] @@ -465,9 +578,14 @@ def do_transform_ip(self, buf): focal = self._focal_track() if focal is not None: ppm = self._px_per_meter() - dist_m = (self._distance_px.get(focal, 0.0) / ppm) if ppm else 0.0 + dist_m = ( + (self._distance_px.get(focal, 0.0) / ppm) if ppm else 0.0 + ) self._draw_hud( - cv2, frame, self._contacts.get(focal, 0), dist_m, + cv2, + frame, + self._contacts.get(focal, 0), + dist_m, self._color_for(self._track_label.get(focal, ""), focal), self._load_headshot(cv2, np), ) From 809951a4fb46c3fabad654fdadf4433966efd503 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Wed, 17 Jun 2026 03:18:54 +0000 Subject: [PATCH 06/10] Improve overlay settings. Signed-off-by: Marcus Edel --- demo/football/run.sh | 7 +- plugins/python/base_objectdetector.py | 100 ++++++++++------- plugins/python/football_analyzer.py | 7 +- plugins/python/football_overlay.py | 149 +++++++++++++++++++++++--- plugins/python/tracker.py | 28 ++++- 5 files changed, 232 insertions(+), 59 deletions(-) diff --git a/demo/football/run.sh b/demo/football/run.sh index f9b316d..9ef5a0c 100755 --- a/demo/football/run.sh +++ b/demo/football/run.sh @@ -15,16 +15,17 @@ source .venv/bin/activate export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" BACKEND="${BACKEND:-pt}" +INTERVAL="${INTERVAL:-3}" # run detection every Nth frame; tracker/overlay stay per-frame CLASSES="ball,goalkeeper,player,referee" TRACK="pyml_tracker tracker-type=bytetrack" -OVERLAY="pyml_football_overlay class-names=$CLASSES show-ids=false show-labels=false" +OVERLAY="pyml_football_overlay class-names=$CLASSES team-colors=true trails=false show-ids=false show-labels=false" if [[ "$BACKEND" == "fp16" ]]; then export LD_LIBRARY_PATH="$(python -c "import os,nvidia,glob;b=os.path.dirname(nvidia.__file__);print(':'.join(sorted(set(glob.glob(b+'/*/lib')))))"):${LD_LIBRARY_PATH:-}" - DETECT="pyml_objectdetector engine-name=onnx model-name=models/football/football_fp16.onnx device=cuda:0 input-format=nchw post-process=anchor_free" + DETECT="pyml_objectdetector engine-name=onnx model-name=models/football/football_fp16.onnx device=cuda:0 input-format=nchw post-process=anchor_free interval=$INTERVAL" IN_FMT="RGB"; FORCE_SQUARE=1 else - DETECT="pyml_yolo model-name=models/football/football device=cuda:0" + DETECT="pyml_yolo model-name=models/football/football device=cuda:0 interval=$INTERVAL" IN_FMT="RGBA"; FORCE_SQUARE=0 fi diff --git a/plugins/python/base_objectdetector.py b/plugins/python/base_objectdetector.py index 84f2f2d..5b14f02 100644 --- a/plugins/python/base_objectdetector.py +++ b/plugins/python/base_objectdetector.py @@ -46,6 +46,11 @@ def __init__(self): self.metadata = Metadata("si") self.logger.info("Initialized BaseObjectDetector") self.__track = False + self.__interval = 1 + self._det_counter = 0 + self._cached_results = None + self._cached_num_sources = 1 + self._cached_id = None @GObject.Property(type=bool, default=False) def track(self): @@ -60,6 +65,17 @@ def track(self, value): if self.engine: self.engine.track = value + @GObject.Property(type=int, default=1, minimum=1, maximum=10000) + def interval(self): + "Run detection every Nth frame and re-attach the previous detections on " + "the frames in between (N=1 runs detection every frame). Lets downstream " + "tracking/overlay stay per-frame while detection runs at a lower rate." + return self.__interval + + @interval.setter + def interval(self, value): + self.__interval = max(1, int(value)) + def do_forward(self, frames): self.logger.info( f"Forward called with frames shape: {frames.shape if frames is not None else 'None'}" @@ -77,47 +93,39 @@ def do_transform_ip(self, buf): """ self.logger.info(f"Transforming buffer: {hex(id(buf))}") try: - # Use MuxedBufferProcessor to extract frames and metadata - muxed_processor = MuxedBufferProcessor( - self.logger, - self.width, - self.height, - self.framerate_num, - self.framerate_denom, - ) - frames, id_str, num_sources, format = muxed_processor.extract_frames( - buf, self.sinkpad - ) - if frames is None: - self.logger.error("Failed to extract frames") - return Gst.FlowReturn.ERROR - - # Process frames (single or batch) - results = self.do_forward(frames) - if results is None: - self.logger.error("Inference returned None") - return Gst.FlowReturn.ERROR - - # Handle single-frame case - if num_sources == 1: - self.do_decode(buf, results, stream_idx=0) - # Handle batch case - else: - self.logger.info( - f"Processing batch with ID={id_str}, num_sources={num_sources}" + run_detect = (self._det_counter % self.__interval) == 0 + self._det_counter += 1 + + if run_detect: + # Use MuxedBufferProcessor to extract frames and metadata + muxed_processor = MuxedBufferProcessor( + self.logger, + self.width, + self.height, + self.framerate_num, + self.framerate_denom, + ) + frames, id_str, num_sources, format = muxed_processor.extract_frames( + buf, self.sinkpad ) - results_list = results if isinstance(results, list) else [results] - if len(results_list) != num_sources: - self.logger.error( - f"Expected {num_sources} results, got {len(results_list)}" - ) + if frames is None: + self.logger.error("Failed to extract frames") return Gst.FlowReturn.ERROR - for idx, result in enumerate(results_list): - if result is None: - self.logger.warning(f"Frame {idx} result is None") - continue - self.do_decode(buf, result, stream_idx=idx) + results = self.do_forward(frames) + if results is None: + self.logger.error("Inference returned None") + return Gst.FlowReturn.ERROR + + self._cached_results = results + self._cached_num_sources = num_sources + self._decode_results(buf, results, num_sources) + elif self._cached_results is not None: + # Skip inference on this frame and re-attach the previous + # detections so downstream tracking/overlay stay per-frame. + self._decode_results( + buf, self._cached_results, self._cached_num_sources + ) attached_meta = GstAnalytics.buffer_get_analytics_relation_meta(buf) if attached_meta: @@ -132,6 +140,22 @@ def do_transform_ip(self, buf): self.logger.error(f"Transform error: {e}\n{traceback.format_exc()}") return Gst.FlowReturn.ERROR + def _decode_results(self, buf, results, num_sources): + if num_sources == 1: + self.do_decode(buf, results, stream_idx=0) + else: + results_list = results if isinstance(results, list) else [results] + if len(results_list) != num_sources: + self.logger.error( + f"Expected {num_sources} results, got {len(results_list)}" + ) + return + for idx, result in enumerate(results_list): + if result is None: + self.logger.warning(f"Frame {idx} result is None") + continue + self.do_decode(buf, result, stream_idx=idx) + def do_decode(self, buf, output, stream_idx=0): self.logger.info( f"Decoding for stream {stream_idx}: {output} (type: {type(output)})" diff --git a/plugins/python/football_analyzer.py b/plugins/python/football_analyzer.py index 3958a31..545be61 100644 --- a/plugins/python/football_analyzer.py +++ b/plugins/python/football_analyzer.py @@ -30,6 +30,11 @@ gi.require_version("GstVideo", "1.0") from gi.repository import Gst, GstBase, GstVideo, GObject # noqa: E402 + # Define caps before the optional heavy imports so the element's pad + # templates still resolve when an optional dep (e.g. supervision) is missing; + # only registration is then skipped (CAN_REGISTER_ELEMENT=False). + VIDEO_CAPS = Gst.Caps.from_string("video/x-raw, format=BGR") + import cv2 import numpy as np import supervision as sv @@ -37,8 +42,6 @@ from log.logger_factory import LoggerFactory # noqa: E402 - VIDEO_CAPS = Gst.Caps.from_string("video/x-raw, format=BGR") - except ImportError as e: CAN_REGISTER_ELEMENT = False GlobalLogger().warning( diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py index 94f372b..928e75a 100644 --- a/plugins/python/football_overlay.py +++ b/plugins/python/football_overlay.py @@ -75,6 +75,8 @@ _REFEREE_RGBA = (255, 215, 0, 255) _BALL_RGBA = (0, 230, 0, 255) _PLAYER_RGBA = (0, 200, 255, 255) +_RED_TEAM_RGBA = (255, 40, 40, 255) +_BLUE_TEAM_RGBA = (40, 90, 255, 255) _DEFAULT_RGBA = (235, 235, 235, 255) _BLACK_RGBA = (0, 0, 0, 255) _HUD_BG_RGBA = (92, 41, 131, 255) @@ -145,6 +147,14 @@ class FootballOverlay(GstBase.BaseTransform): blurb="Number of recent positions kept in each motion trail", flags=GObject.ParamFlags.READWRITE, ) + show_ball = GObject.Property( + type=bool, + default=False, + nick="Show Ball", + blurb="Draw the marker on the ball (the ball is still tracked for " + "contact counting either way)", + flags=GObject.ParamFlags.READWRITE, + ) show_hud = GObject.Property( type=bool, default=True, @@ -219,6 +229,14 @@ class FootballOverlay(GstBase.BaseTransform): "onnx/objectdetector path, e.g. 'ball,goalkeeper,player,referee'", flags=GObject.ParamFlags.READWRITE, ) + team_colors = GObject.Property( + type=bool, + default=True, + nick="Team Colors", + blurb="Colour players by jersey team (red/blue, per-track majority vote); " + "off draws all players one colour", + flags=GObject.ParamFlags.READWRITE, + ) def __init__(self): super().__init__() @@ -232,13 +250,18 @@ def __init__(self): self._last_pt = {} self._distance_px = {} self._heights = [] + self._widths = [] + self._ell_w = {} # track_id -> smoothed ellipse half-width (px) self._contacts = {} self._last_contact_frame = {} self._frames_seen = {} self._track_label = {} + self._class_votes = {} # track_id -> {label: count}, for stable class + self._team_votes = {} # track_id -> {"red": n, "blue": n}, jersey team self._frame = 0 self._headshot = None self._headshot_loaded = False + self._inv_order = [0, 1, 2, 3] # buffer-channel -> logical RGBA index def do_set_caps(self, incaps, outcaps): info = GstVideo.VideoInfo.new_from_caps(incaps) @@ -246,6 +269,9 @@ def do_set_caps(self, incaps, outcaps): self.height = info.height fmt = info.finfo.name if info.finfo else "RGBA" self._order = _FORMAT_ORDER.get(fmt, _FORMAT_ORDER["RGBA"]) + # buffer channel j holds logical[self._order[j]]; invert so we can pull + # logical R,G,B out of the buffer for jersey colour classification. + self._inv_order = [self._order.index(c) for c in range(4)] self._headshot_loaded = False # re-load in the new channel order self.logger.info(f"FootballOverlay caps: {fmt} {self.width}x{self.height}") return True @@ -326,16 +352,25 @@ def _update_tracks(self, entries): active = set() players = {} ball_box = None + # Accumulate per-track class votes first so the stable label below + # already reflects this frame. for e in entries: tid = e["track_id"] if tid is None: continue - if _is_ball(e["label"]): + v = self._class_votes.setdefault(tid, {}) + v[e["label"]] = v.get(e["label"], 0) + 1 + for e in entries: + tid = e["track_id"] + if tid is None: + continue + label = self._stable_label(tid, e["label"]) + if _is_ball(label): ball_box = e["box"] continue active.add(tid) players[tid] = e["box"] - self._track_label[tid] = e["label"] + self._track_label[tid] = label self._frames_seen[tid] = self._frames_seen.get(tid, 0) + 1 x1, y1, x2, y2 = e["box"] foot = (int((x1 + x2) / 2), int(y2)) @@ -343,6 +378,7 @@ def _update_tracks(self, entries): self._heights.append(y2 - y1) if len(self._heights) > 600: self._heights = self._heights[-600:] + self._update_ellipse_width(tid, x2 - x1) prev = self._last_pt.get(tid) if prev is not None: self._distance_px[tid] = ( @@ -368,8 +404,30 @@ def _update_tracks(self, entries): if tid not in active: del self._trail[tid] self._last_pt.pop(tid, None) + self._ell_w.pop(tid, None) return active + def _update_ellipse_width(self, track_id, raw_w): + # Smooth (and outlier-reject) the per-track ellipse width so a single + # oversized box -- two players merged, or a drifting keep-alive + # prediction -- can't balloon the circle for one frame. + if raw_w <= 0: + return + if self._widths: + self._widths.append(raw_w) + if len(self._widths) > 600: + self._widths = self._widths[-600:] + srt = sorted(self._widths) + med = srt[len(srt) // 2] + clamped = min(max(raw_w, 0.5 * med), 1.8 * med) + else: + self._widths.append(raw_w) + clamped = raw_w + prev = self._ell_w.get(track_id) + # EMA: fast enough to follow real perspective changes, slow enough to + # damp single-frame spikes. + self._ell_w[track_id] = clamped if prev is None else 0.4 * clamped + 0.6 * prev + def _px_per_meter(self): if not self._heights: return None @@ -388,6 +446,15 @@ def _focal_track(self): ) return max(keys, key=lambda t: self._frames_seen.get(t, 0)) + def _stable_label(self, track_id, fallback=""): + # Majority-voted class over the track's history — smooths frame-to-frame + # misclassifications (e.g. a player briefly tagged 'referee'), so the + # gold referee marking doesn't flicker. + votes = self._class_votes.get(track_id) + if not votes: + return fallback + return max(votes, key=votes.get) + def _c(self, rgba): return tuple(rgba[i] for i in self._order) @@ -396,8 +463,41 @@ def _color_for(self, label, track_id): return _REFEREE_RGBA if _is_ball(label): return _BALL_RGBA + if self.team_colors and track_id is not None: + c = self._team_votes.get(track_id) + if c and (c.get("red", 0) or c.get("blue", 0)): + return _RED_TEAM_RGBA if c["red"] >= c["blue"] else _BLUE_TEAM_RGBA + # Team not decided yet (or unclassifiable kit, e.g. goalkeeper): + # draw nothing rather than flashing the default cyan. + return None return _PLAYER_RGBA + def _classify_jersey(self, cv2, np, frame, box): + # Dominant jersey hue in the torso patch -> "red"/"blue"/None (HSV), + # ported from football_analyzer.classify_jersey. + x1, y1, x2, y2 = (int(v) for v in box) + h_box, w_box = y2 - y1, x2 - x1 + if h_box <= 0 or w_box <= 0: + return None + jy1, jy2 = y1 + int(0.15 * h_box), y1 + int(0.55 * h_box) + jx1, jx2 = x1 + int(0.25 * w_box), x1 + int(0.75 * w_box) + H, W = frame.shape[:2] + jy1, jy2 = max(0, jy1), min(H, jy2) + jx1, jx2 = max(0, jx1), min(W, jx2) + if jy2 - jy1 < 3 or jx2 - jx1 < 3: + return None + # logical RGB from the buffer's channel order, then HSV + rgb = np.ascontiguousarray(frame[jy1:jy2, jx1:jx2][:, :, self._inv_order[:3]]) + hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) + s_v = (hsv[..., 1] > 80) & (hsv[..., 2] > 50) + h = hsv[..., 0] + red = (((h <= 10) | (h >= 170)) & s_v).sum() + blue = ((h >= 100) & (h <= 130) & s_v).sum() + min_pixels = max(20, int(0.02 * rgb.shape[0] * rgb.shape[1])) + if red < min_pixels and blue < min_pixels: + return None + return "red" if red >= blue else "blue" + def _load_headshot(self, cv2, np): if self._headshot_loaded: return self._headshot @@ -430,7 +530,10 @@ def _draw_ellipse(self, cv2, frame, box, rgba, track_id): x1, y1, x2, y2 = box y_bottom = int(y2) x_center = int((x1 + x2) / 2) - width = max(1, int(x2 - x1)) + # Prefer the per-track smoothed width so the ellipse stays stable even + # when a single detection box is momentarily oversized. + smoothed = self._ell_w.get(track_id) + width = max(1, int(smoothed if smoothed is not None else x2 - x1)) color = self._c(rgba) cv2.ellipse( frame, @@ -553,23 +656,39 @@ def do_transform_ip(self, buf): mapinfo.data, dtype=np.uint8, count=self.height * self.width * 4 ).reshape(self.height, self.width, 4) + # Jersey team voting first, so trails/ellipses use this frame's vote. + if self.team_colors: + for e in entries: + tid = e["track_id"] + if tid is None: + continue + lab = self._stable_label(tid, e["label"]) + if _is_ball(lab) or _is_referee(lab): + continue + vote = self._classify_jersey(cv2, np, frame, e["box"]) + if vote: + tv = self._team_votes.setdefault(tid, {"red": 0, "blue": 0}) + tv[vote] += 1 + if self.trails: for tid in active: - self._draw_trail( - cv2, - np, - frame, - self._trail.get(tid, []), - self._color_for(self._track_label.get(tid, ""), tid), - ) + rgba = self._color_for(self._track_label.get(tid, ""), tid) + if rgba is None: + continue + self._draw_trail(cv2, np, frame, self._trail.get(tid, []), rgba) for e in entries: - label = e["label"] box = e["box"] + # Style by the majority-voted class (stable), not this + # frame's possibly-flickering label. + label = self._stable_label(e["track_id"], e["label"]) if _is_ball(label): - self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) + if self.show_ball: + self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) continue rgba = self._color_for(label, e["track_id"]) + if rgba is None: + continue self._draw_ellipse(cv2, frame, box, rgba, e["track_id"]) if self.show_labels: self._draw_label(cv2, frame, box, label, rgba) @@ -581,12 +700,16 @@ def do_transform_ip(self, buf): dist_m = ( (self._distance_px.get(focal, 0.0) / ppm) if ppm else 0.0 ) + hud_rgba = ( + self._color_for(self._track_label.get(focal, ""), focal) + or _DEFAULT_RGBA + ) self._draw_hud( cv2, frame, self._contacts.get(focal, 0), dist_m, - self._color_for(self._track_label.get(focal, ""), focal), + hud_rgba, self._load_headshot(cv2, np), ) finally: diff --git a/plugins/python/tracker.py b/plugins/python/tracker.py index ad0db4d..cca80fd 100644 --- a/plugins/python/tracker.py +++ b/plugins/python/tracker.py @@ -130,10 +130,14 @@ def iou_batch(bb_det, bb_trk): class SortTracker: """SORT/ByteTrack multi-object tracker using IoU + Kalman filtering.""" - def __init__(self, max_age=30, min_hits=3, iou_threshold=0.3): + def __init__(self, max_age=30, min_hits=3, iou_threshold=0.3, keep_alive=2): self.max_age = max_age self.min_hits = min_hits self.iou_threshold = iou_threshold + # Keep emitting a confirmed track (with its Kalman-predicted box) for up + # to keep_alive frames after a missed detection — bridges flicker so the + # overlay doesn't blink when the detector drops a box for a frame or two. + self.keep_alive = keep_alive self.trackers = [] def update(self, detections): @@ -191,10 +195,11 @@ def update(self, detections): t for t in self.trackers if t.time_since_update <= self.max_age ] - # Return confirmed tracks + # Return confirmed tracks, including ones that missed a detection this + # frame (predicted box) for up to keep_alive frames — prevents flicker. results = [] for trk in self.trackers: - if trk.hits >= self.min_hits and trk.time_since_update == 0: + if trk.hits >= self.min_hits and trk.time_since_update <= self.keep_alive: results.append((trk.id, trk.get_bbox(), trk.label_quark)) return results @@ -268,6 +273,17 @@ class TrackerTransform(GstBase.BaseTransform): flags=GObject.ParamFlags.READWRITE, ) + keep_alive = GObject.Property( + type=int, + default=2, + minimum=0, + maximum=1000, + nick="Keep Alive", + blurb="Frames to keep emitting a confirmed track (Kalman-predicted box) " + "after a missed detection; bridges flicker (0 = only matched frames)", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) @@ -281,6 +297,7 @@ def _ensure_tracker(self): max_age=self.max_age, min_hits=self.min_hits, iou_threshold=self.iou_threshold, + keep_alive=self.keep_alive, ) return self._tracker @@ -351,6 +368,8 @@ def do_get_property(self, prop): return self.min_hits elif prop.name == "iou-threshold": return self.iou_threshold + elif prop.name == "keep-alive": + return self.keep_alive else: raise AttributeError(f"Unknown property {prop.name}") @@ -367,6 +386,9 @@ def do_set_property(self, prop, value): elif prop.name == "iou-threshold": self.iou_threshold = value self._tracker = None + elif prop.name == "keep-alive": + self.keep_alive = value + self._tracker = None else: raise AttributeError(f"Unknown property {prop.name}") From f43da7dc3f02ae8e60ec914e80f67012e45f0b30 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Wed, 17 Jun 2026 03:49:35 +0000 Subject: [PATCH 07/10] Add buffer to combat slow inference. Signed-off-by: Marcus Edel --- demo/football/run.sh | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/demo/football/run.sh b/demo/football/run.sh index 9ef5a0c..adfbff2 100755 --- a/demo/football/run.sh +++ b/demo/football/run.sh @@ -6,7 +6,8 @@ # # Usage: # demo/football/run.sh [INPUT.mp4] [OUTPUT.mp4] [WxH] # file -> annotated mp4 -# demo/football/run.sh camera [/dev/videoN] [WxH] # live -> on-screen +# demo/football/run.sh display [INPUT.mp4] [WxH] # file -> live on-screen +# demo/football/run.sh camera [/dev/videoN] [WxH] # live camera -> on-screen set -euo pipefail REPO="$(cd "$(dirname "$0")/../.." && pwd)" @@ -32,6 +33,20 @@ fi POST_DETECT="$TRACK" [[ "$IN_FMT" == "RGB" ]] && POST_DETECT="$TRACK ! videoconvert ! video/x-raw,format=RGBA" +# A queue at each stage boundary turns the serial chain into a threaded +# pipeline: while inference runs on frame N, the sink renders N-1 and the +# decoder reads N+1. Nothing is dropped (leaky=no, the default). +Q="queue max-size-buffers=8 max-size-time=0 max-size-bytes=0" +# Pre-roll buffer before the display sink: build a head start of processed +# frames so real-time playback (sync=true) rides out per-frame inference +# jitter without stuttering. Smooths jitter, not a sustained throughput +# deficit -- if inference can't keep up on average, playback just lags +# (still no drops). Lower INTERVAL/raise the head start if it falls behind. +PREROLL="queue max-size-buffers=600 max-size-time=0 max-size-bytes=0 min-threshold-buffers=30" + +# detector -> tracker -> overlay, with a thread boundary at each hop. +CHAIN="$Q ! $DETECT ! $Q ! $POST_DETECT ! $Q ! $OVERLAY" + MODE="${1:-file}" if [[ "$MODE" == "camera" ]]; then DEV="${2:-/dev/video0}"; SIZE="${3:-1280x720}" @@ -41,8 +56,20 @@ if [[ "$MODE" == "camera" ]]; then exec gst-launch-1.0 -e \ v4l2src device="$DEV" ! videoconvert ! videoscale \ ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ - ! $DETECT ! $POST_DETECT ! $OVERLAY \ - ! videoconvert ! autovideosink sync=false + ! $CHAIN \ + ! $Q ! videoconvert ! autovideosink sync=false +elif [[ "$MODE" == "display" ]]; then + IN="${2:-data/soccer_tracking.mp4}" + SIZE="${3:-1280x720}" + [[ "$FORCE_SQUARE" == "1" ]] && SIZE="640x640" + W="${SIZE%x*}"; H="${SIZE#*x}" + [[ -f "$IN" ]] || { echo "input not found: $IN" >&2; exit 1; } + echo "[$BACKEND] '$IN' @ ${W}x${H} -> live display (real-time, sync=true)" + exec gst-launch-1.0 -e \ + filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ + ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ + ! $CHAIN \ + ! $PREROLL ! videoconvert ! autovideosink sync=true else IN="${1:-data/soccer_tracking.mp4}" OUT="${2:-demo/football/out.mp4}" @@ -54,7 +81,7 @@ else gst-launch-1.0 -e \ filesrc location="$IN" ! decodebin ! videoconvert ! videoscale \ ! "video/x-raw,width=${W},height=${H},format=${IN_FMT}" \ - ! $DETECT ! $POST_DETECT ! $OVERLAY \ - ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" + ! $CHAIN \ + ! $Q ! videoconvert ! openh264enc ! h264parse ! mp4mux ! filesink location="$OUT" echo "Done: $OUT" fi From 32e846cbf9e6fdfd58270524b809cd5bd384831e Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Wed, 17 Jun 2026 03:52:00 +0000 Subject: [PATCH 08/10] Add README with some settings. Signed-off-by: Marcus Edel --- demo/football/README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/demo/football/README.md b/demo/football/README.md index d32e9fa..7a33266 100644 --- a/demo/football/README.md +++ b/demo/football/README.md @@ -3,8 +3,12 @@ Real-time football broadcast overlay: **detection → tracking → overlay** (`pyml_yolo`/`pyml_objectdetector` -> `pyml_tracker` -> `pyml_football_overlay`). -The overlay draws a foot ellipse per subject (players one colour, referee gold), -a green triangle on the ball, motion trails, and a focal-player HUD + ball contacts + distance. +The overlay draws a foot ellipse per player coloured by team (red/blue, voted +from jersey hue), a gold ellipse for referees, motion trails (off by default), +and a focal-player HUD with headshot, ball contacts, and distance travelled. +Players whose team isn't decided yet (and unclassifiable kits, e.g. the +goalkeeper) are left unmarked rather than drawn in a placeholder colour. The +ball is tracked for contact counting but its marker is off by default. ## Run @@ -13,6 +17,25 @@ a green triangle on the ball, motion trails, and a focal-player HUD + ball conta demo/football/run.sh demo/football/run.sh 08fd33_4.mp4 demo/football/out.mp4 1280x720 +# file -> live on-screen +demo/football/run.sh display +demo/football/run.sh display 08fd33_4.mp4 1280x720 + # live camera -> on-screen demo/football/run.sh camera /dev/video0 ``` + +## Environment knobs + +| Var | Default | Meaning | +|------------|---------|---------| +| `BACKEND` | `pt` | `pt` = PyTorch `pyml_yolo`; `fp16` = ONNX FP16 via `pyml_objectdetector` (CUDA). | +| `INTERVAL` | `3` | Run detection every Nth frame; the tracker/overlay still update every frame, so it stays smooth at ~N× less inference cost. The main real-time lever. | + +```bash +BACKEND=fp16 demo/football/run.sh display # faster inference path +INTERVAL=5 demo/football/run.sh display # detect every 5th frame +INTERVAL=1 demo/football/run.sh # detect every frame (max accuracy) +``` + + From bea1428b78f6103590c50e9c569f759f961de683 Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Wed, 17 Jun 2026 19:20:09 +0000 Subject: [PATCH 09/10] Switch from tracking overlay to detection overlay and apply ema filter. Signed-off-by: Marcus Edel --- demo/football/run.sh | 16 +- plugins/python/football_overlay.py | 323 ++++++++++++++++++++++++++--- plugins/python/tracker.py | 237 ++++++++++++++++++--- plugins/python/yolo.py | 54 ++++- 4 files changed, 574 insertions(+), 56 deletions(-) diff --git a/demo/football/run.sh b/demo/football/run.sh index adfbff2..0be9a15 100755 --- a/demo/football/run.sh +++ b/demo/football/run.sh @@ -17,16 +17,26 @@ export GST_PLUGIN_PATH="$REPO/plugins:${GST_PLUGIN_PATH:-}" BACKEND="${BACKEND:-pt}" INTERVAL="${INTERVAL:-3}" # run detection every Nth frame; tracker/overlay stay per-frame +CONF="${CONF:-0.1}" # detector confidence threshold (low = more detections) +IOU="${IOU:-0.7}" # NMS IoU (ultralytics/football_analyzer default) +NEWTRACK="${NEWTRACK:-0.25}" # min confidence to START a new track (ByteTrack gate; kills ghosts) +DRAWCONF="${DRAWCONF:-0}" # min confidence to DRAW a detection (0 = draw all; raise to trim weak boxes) +MERGE="${MERGE:-0.5}" # collapse overlapping boxes (lower=merge more; 0 disables) so one player=one circle +SMOOTH="${SMOOTH:-0.6}" # temporal EMA on circle positions (0=off, higher=smoother but more lag) CLASSES="ball,goalkeeper,player,referee" -TRACK="pyml_tracker tracker-type=bytetrack" -OVERLAY="pyml_football_overlay class-names=$CLASSES team-colors=true trails=false show-ids=false show-labels=false" +TRACK="pyml_tracker tracker-type=bytetrack new-track-confidence=$NEWTRACK" +# Detection-based overlay: circles sit on the raw per-frame detections (no +# tracking drift/phantoms/doubles); merge collapses overlaps and +# position-smoothing low-passes the positions. DRAWCONF defaults 0 so no +# detection is hidden; the tracker still runs so the HUD keeps its stats. +OVERLAY="pyml_football_overlay class-names=$CLASSES team-colors=true trails=false show-ids=false show-labels=false draw-from-detections=true min-confidence=$DRAWCONF merge-iou=$MERGE position-smoothing=$SMOOTH highlight-focal=false" if [[ "$BACKEND" == "fp16" ]]; then export LD_LIBRARY_PATH="$(python -c "import os,nvidia,glob;b=os.path.dirname(nvidia.__file__);print(':'.join(sorted(set(glob.glob(b+'/*/lib')))))"):${LD_LIBRARY_PATH:-}" DETECT="pyml_objectdetector engine-name=onnx model-name=models/football/football_fp16.onnx device=cuda:0 input-format=nchw post-process=anchor_free interval=$INTERVAL" IN_FMT="RGB"; FORCE_SQUARE=1 else - DETECT="pyml_yolo model-name=models/football/football device=cuda:0 interval=$INTERVAL" + DETECT="pyml_yolo model-name=models/football/football device=cuda:0 interval=$INTERVAL confidence=$CONF nms-iou=$IOU" IN_FMT="RGBA"; FORCE_SQUARE=0 fi diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py index 928e75a..79f7f4d 100644 --- a/plugins/python/football_overlay.py +++ b/plugins/python/football_overlay.py @@ -81,6 +81,7 @@ _BLACK_RGBA = (0, 0, 0, 255) _HUD_BG_RGBA = (92, 41, 131, 255) _HUD_TEXT_RGBA = (64, 186, 47, 255) +_HIGHLIGHT_RGBA = (255, 255, 255, 255) def _is_ball(label): @@ -237,6 +238,56 @@ class FootballOverlay(GstBase.BaseTransform): "off draws all players one colour", flags=GObject.ParamFlags.READWRITE, ) + draw_from_detections = GObject.Property( + type=bool, + default=False, + nick="Draw From Detections", + blurb="Draw ellipses on the raw per-frame detection boxes instead of the " + "tracker's boxes -- no Kalman drift, coasted phantoms or track-split " + "doubles. Team colour is then classified per frame; the HUD still uses " + "tracker metadata if present", + flags=GObject.ParamFlags.READWRITE, + ) + merge_iou = GObject.Property( + type=float, + default=0.5, + minimum=0.0, + maximum=1.0, + nick="Merge IoU", + blurb="Collapse overlapping boxes (across classes) into one before " + "drawing, so one player isn't circled twice; a box is merged when its " + "IoU or containment with a kept box exceeds this (0 disables)", + flags=GObject.ParamFlags.READWRITE, + ) + position_smoothing = GObject.Property( + type=float, + default=0.5, + minimum=0.0, + maximum=0.95, + nick="Position Smoothing", + blurb="Temporal EMA on drawn box positions (0=off, higher=smoother but " + "more lag). Boxes are associated frame-to-frame by proximity, so this " + "damps detection jitter and the steps from a detection interval > 1", + flags=GObject.ParamFlags.READWRITE, + ) + highlight_focal = GObject.Property( + type=bool, + default=True, + nick="Highlight Focal Player", + blurb="Mark the focal player (the one shown in the HUD) on the pitch " + "with a chevron above their head and a bolder ellipse", + flags=GObject.ParamFlags.READWRITE, + ) + focal_track_id = GObject.Property( + type=int, + default=-1, + minimum=-1, + maximum=100000, + nick="Focal Track ID", + blurb="Pin the focal/highlighted player to this track id; -1 = auto " + "(the player tracked the most, with hysteresis so it stays stable)", + flags=GObject.ParamFlags.READWRITE, + ) def __init__(self): super().__init__() @@ -259,9 +310,13 @@ def __init__(self): self._class_votes = {} # track_id -> {label: count}, for stable class self._team_votes = {} # track_id -> {"red": n, "blue": n}, jersey team self._frame = 0 + self._focal = None # current focal track id (sticky, for hysteresis) self._headshot = None self._headshot_loaded = False self._inv_order = [0, 1, 2, 3] # buffer-channel -> logical RGBA index + # Position-smoothing slots: {"box": np[x1,y1,x2,y2]} kept across frames + # and matched by proximity, so the drawn ellipse can be low-passed. + self._smooth_slots = [] def do_set_caps(self, incaps, outcaps): info = GstVideo.VideoInfo.new_from_caps(incaps) @@ -347,7 +402,7 @@ def _ball_contact(self, players, ball_box): return None return best_tid - def _update_tracks(self, entries): + def _update_tracks(self, entries, det_ball_box=None): self._frame += 1 active = set() players = {} @@ -391,6 +446,10 @@ def _update_tracks(self, entries): if len(trail) > self.trail_length: del trail[: -self.trail_length] + # Fall back to the detected ball if no tracked ball this frame. + if ball_box is None: + ball_box = det_ball_box + # Ball contacts (debounced per player), like football_analyzer. if ball_box is not None and players: tid = self._ball_contact(players, ball_box) @@ -436,15 +495,35 @@ def _px_per_meter(self): return float(np.median(self._heights)) / max(0.1, self.player_height) def _focal_track(self): + # Pin to an explicit track id if requested. + if self.focal_track_id >= 0: + return ( + self.focal_track_id + if self.focal_track_id in self._frames_seen + else self._focal + ) keys = set(self._frames_seen) if not keys: return None - if any(self._contacts.values()): - return max( - keys, - key=lambda t: (self._contacts.get(t, 0), self._frames_seen.get(t, 0)), - ) - return max(keys, key=lambda t: self._frames_seen.get(t, 0)) + + # Rank by ball contacts (the player most involved with the ball), with + # frames-seen as a tiebreak / pre-contact fallback (before anyone has + # touched the ball, the most-tracked player is shown). + def score(t): + return (self._contacts.get(t, 0), self._frames_seen.get(t, 0)) + + best = max(keys, key=score) + # Stability: keep the current focal unless a challenger has *strictly + # more* contacts, so the highlight/HUD don't flip on ties or noise. + cur = self._focal + if ( + cur is not None + and cur in keys + and self._contacts.get(best, 0) <= self._contacts.get(cur, 0) + ): + best = cur + self._focal = best + return best def _stable_label(self, track_id, fallback=""): # Majority-voted class over the track's history — smooths frame-to-frame @@ -472,6 +551,112 @@ def _color_for(self, label, track_id): return None return _PLAYER_RGBA + @staticmethod + def _overlap(a, b): + # max(IoU, intersection-over-smaller-area): catches both heavy overlap + # and a small duplicate box sitting inside a larger one. + ax1, ay1, ax2, ay2 = a + bx1, by1, bx2, by2 = b + iw = max(0.0, min(ax2, bx2) - max(ax1, bx1)) + ih = max(0.0, min(ay2, by2) - max(ay1, by1)) + inter = iw * ih + if inter <= 0.0: + return 0.0 + area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) + area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) + union = area_a + area_b - inter + iou = inter / union if union > 0.0 else 0.0 + smaller = min(area_a, area_b) + contain = inter / smaller if smaller > 0.0 else 0.0 + return max(iou, contain) + + def _merge_overlaps(self, entries): + # Class-agnostic greedy suppression: keep the most confident box, drop + # any later box that overlaps it past merge_iou. Collapses a player + # circled twice (e.g. player+goalkeeper on one person) into one. The + # ball is never merged against players. + if self.merge_iou <= 0.0 or len(entries) < 2: + return entries + ordered = sorted(entries, key=lambda e: e["confidence"], reverse=True) + kept = [] + for e in ordered: + if _is_ball(e["label"]): + kept.append(e) + continue + if any( + not _is_ball(k["label"]) + and self._overlap(e["box"], k["box"]) >= self.merge_iou + for k in kept + ): + continue + kept.append(e) + return kept + + def _smooth_boxes(self, np, entries): + # Temporal EMA on the boxes we're about to draw. Each box is matched to + # the nearest slot from last frame (by centre, within a size-relative + # gate) and pulled toward the new detection; slots not matched this + # frame are dropped (no phantoms). Damps jitter and interval steps. The + # ball is passed through unsmoothed so it never lags. + a = float(self.position_smoothing) + if a <= 0.0 or not entries: + return entries + used = set() + out = [] + for e in entries: + if _is_ball(e["label"]): + out.append(e) + continue + box = np.array(e["box"], dtype=np.float64) + cx, cy = (box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0 + # Generous gate so a coherent interval-step jump still associates + # (and glides) without grabbing a different nearby player. + gate = 1.5 * max(box[2] - box[0], box[3] - box[1], 1.0) + best, best_d = None, gate + for idx, slot in enumerate(self._smooth_slots): + if idx in used: + continue + sb = slot["box"] + d = ( + ((sb[0] + sb[2]) / 2.0 - cx) ** 2 + + ((sb[1] + sb[3]) / 2.0 - cy) ** 2 + ) ** 0.5 + if d < best_d: + best, best_d = idx, d + if best is None: + self._smooth_slots.append({"box": box.copy()}) + used.add(len(self._smooth_slots) - 1) + smoothed = box + else: + used.add(best) + slot = self._smooth_slots[best] + slot["box"] = a * slot["box"] + (1.0 - a) * box + smoothed = slot["box"] + ne = dict(e) + ne["box"] = ( + float(smoothed[0]), + float(smoothed[1]), + float(smoothed[2]), + float(smoothed[3]), + ) + out.append(ne) + self._smooth_slots = [s for i, s in enumerate(self._smooth_slots) if i in used] + return out + + def _detection_color(self, cv2, np, frame, label, box): + # Colour a raw detection box (no track id): referee gold, otherwise the + # jersey team classified from this frame. Returns None for an + # undecided kit (e.g. goalkeeper) so it isn't drawn, matching the + # track-mode behaviour. + if _is_referee(label): + return _REFEREE_RGBA + if not self.team_colors: + return _PLAYER_RGBA + vote = self._classify_jersey(cv2, np, frame, box) + if vote is None: + return None + return _RED_TEAM_RGBA if vote == "red" else _BLUE_TEAM_RGBA + def _classify_jersey(self, cv2, np, frame, box): # Dominant jersey hue in the torso patch -> "red"/"blue"/None (HSV), # ported from football_analyzer.classify_jersey. @@ -573,6 +758,38 @@ def _draw_triangle(self, cv2, np, frame, box, rgba): cv2.drawContours(frame, [pts], 0, self._c(rgba), cv2.FILLED) cv2.drawContours(frame, [pts], 0, self._c(_BLACK_RGBA), 2) + def _draw_focal_marker(self, cv2, np, frame, box): + # Broadcast-style "selected player" chevron floating above the head, + # plus a bolder ellipse, to flag the focal (HUD) player on the pitch. + x1, y1, x2, y2 = box + cx = int((x1 + x2) / 2) + tip_y = int(y1) - 10 + s = 16 + pts = np.array( + [ + [cx, tip_y], + [cx - s, tip_y - int(s * 1.5)], + [cx + s, tip_y - int(s * 1.5)], + ], + dtype=np.int32, + ) + cv2.drawContours(frame, [pts], 0, self._c(_HIGHLIGHT_RGBA), cv2.FILLED) + cv2.drawContours(frame, [pts], 0, self._c(_BLACK_RGBA), 2) + # Bolder ring at the feet to reinforce the selection. + x_center = int((x1 + x2) / 2) + width = max(1, int(x2 - x1)) + cv2.ellipse( + frame, + (x_center, int(y2)), + (width, max(1, int(0.35 * width))), + 0.0, + -45, + 235, + self._c(_HIGHLIGHT_RGBA), + 4, + cv2.LINE_AA, + ) + def _draw_label(self, cv2, frame, box, label, rgba): x1, y1, _, _ = box cv2.putText( @@ -635,14 +852,42 @@ def do_transform_ip(self, buf): try: import numpy as np - entries = self._read_metadata(buf) - if self.min_confidence > 0.0: - entries = [e for e in entries if e["confidence"] >= self.min_confidence] - if any(e["track_id"] is not None for e in entries): - entries = [e for e in entries if e["track_id"] is not None] + all_entries = self._read_metadata(buf) + # The buffer carries both the detector's boxes (track_id None) and + # the tracker's boxes (track_id set). Tracking state/HUD always use + # the tracked entries; what we *draw* depends on draw_from_detections. + track_entries = [e for e in all_entries if e["track_id"] is not None] + det_entries = [e for e in all_entries if e["track_id"] is None] + + # Ball position for contact counting: prefer a tracked ball, else + # fall back to the strongest ball *detection* (the ball is small and + # fast, so it often isn't tracked) -- so contacts still get counted. + det_ball_box = None + best_ball = -1.0 + for e in det_entries: + if _is_ball(e["label"]) and e["confidence"] > best_ball: + best_ball, det_ball_box = e["confidence"], e["box"] + + # Per-track state (votes, contacts, distance, focal) from the tracker. + active = self._update_tracks( + track_entries if track_entries else all_entries, det_ball_box + ) - active = self._update_tracks(entries) - if not entries: + if self.draw_from_detections: + draw_entries = det_entries + else: + draw_entries = track_entries if track_entries else det_entries + # min-confidence gates only what we *draw* (tracks carry conf 1.0, so + # they're unaffected); the contact math above used the raw detections. + if self.min_confidence > 0.0: + draw_entries = [ + e for e in draw_entries if e["confidence"] >= self.min_confidence + ] + # Collapse overlapping boxes so one player isn't circled twice, + # then low-pass the positions so the circle glides. + draw_entries = self._merge_overlaps(draw_entries) + draw_entries = self._smooth_boxes(np, draw_entries) + if not all_entries: return Gst.FlowReturn.OK import cv2 @@ -656,12 +901,11 @@ def do_transform_ip(self, buf): mapinfo.data, dtype=np.uint8, count=self.height * self.width * 4 ).reshape(self.height, self.width, 4) - # Jersey team voting first, so trails/ellipses use this frame's vote. + # Jersey team voting first, so trails/ellipses use this frame's + # vote (track mode; detection mode classifies per box at draw). if self.team_colors: - for e in entries: + for e in track_entries: tid = e["track_id"] - if tid is None: - continue lab = self._stable_label(tid, e["label"]) if _is_ball(lab) or _is_referee(lab): continue @@ -677,19 +921,50 @@ def do_transform_ip(self, buf): continue self._draw_trail(cv2, np, frame, self._trail.get(tid, []), rgba) - for e in entries: + # Which drawn box is the focal (HUD) player? Match the focal + # track's box to the nearest drawn box so we can highlight it + # even when drawing from detections (no track id on the box). + focal_idx = None + if self.highlight_focal: + focal_tid = self._focal_track() + focal_box = None + if focal_tid is not None: + for t in track_entries: + if t["track_id"] == focal_tid: + focal_box = t["box"] + break + if focal_box is not None: + best = 0.0 + for i, e in enumerate(draw_entries): + if _is_ball(e["label"]): + continue + ov = self._overlap(e["box"], focal_box) + if ov > best: + best, focal_idx = ov, i + + for i, e in enumerate(draw_entries): box = e["box"] - # Style by the majority-voted class (stable), not this - # frame's possibly-flickering label. - label = self._stable_label(e["track_id"], e["label"]) + tid = e["track_id"] + if tid is not None: + # Track mode: style by the majority-voted class (stable), + # not this frame's possibly-flickering label. + label = self._stable_label(tid, e["label"]) + else: + # Detection mode: the raw per-frame class. + label = e["label"] if _is_ball(label): if self.show_ball: self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) continue - rgba = self._color_for(label, e["track_id"]) + if tid is not None: + rgba = self._color_for(label, tid) + else: + rgba = self._detection_color(cv2, np, frame, label, box) if rgba is None: continue - self._draw_ellipse(cv2, frame, box, rgba, e["track_id"]) + self._draw_ellipse(cv2, frame, box, rgba, tid) + if i == focal_idx: + self._draw_focal_marker(cv2, np, frame, box) if self.show_labels: self._draw_label(cv2, frame, box, label, rgba) diff --git a/plugins/python/tracker.py b/plugins/python/tracker.py index cca80fd..7ff3520 100644 --- a/plugins/python/tracker.py +++ b/plugins/python/tracker.py @@ -130,16 +130,137 @@ def iou_batch(bb_det, bb_trk): class SortTracker: """SORT/ByteTrack multi-object tracker using IoU + Kalman filtering.""" - def __init__(self, max_age=30, min_hits=3, iou_threshold=0.3, keep_alive=2): + def __init__( + self, + max_age=30, + min_hits=3, + iou_threshold=0.3, + keep_alive=2, + new_track_conf=0.25, + camera_motion=True, + dup_iou=0.8, + ): self.max_age = max_age self.min_hits = min_hits self.iou_threshold = iou_threshold + # ByteTrack-style activation gate: a brand-new track is only started + # from a confident detection. Weak/ghost boxes can still *continue* an + # existing track (matched above) but won't spawn phantom circles. + self.new_track_conf = new_track_conf # Keep emitting a confirmed track (with its Kalman-predicted box) for up # to keep_alive frames after a missed detection — bridges flicker so the # overlay doesn't blink when the detector drops a box for a frame or two. self.keep_alive = keep_alive + # Camera-motion compensation: estimate the global image shift from the + # tracks that matched, then re-try matching the leftovers with their + # predictions shifted by it. Re-attaches players during a pan instead of + # leaving the old track behind and spawning a duplicate. + self.camera_motion = camera_motion + # Two confirmed tracks overlapping more than this IoU are duplicates; + # the weaker one is dropped (ByteTrack's remove_duplicate_stracks). + self.dup_iou = dup_iou self.trackers = [] + @staticmethod + def _center(bbox): + return (bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0) + + def _estimate_motion(self, matches, predicted, det_bboxes): + """Fit a global 2D similarity transform (translation + uniform scale + + rotation) mapping each matched track's predicted centre to its observed + centre. Returns a callable box->warped-box, or None if it can't be + estimated. Uses RANSAC (via OpenCV) so players moving against the camera + consensus are rejected as outliers; falls back to a robust median + translation if OpenCV is unavailable or the fit is degenerate.""" + import numpy as np + + if len(matches) < 3: + return None + src = np.array( + [self._center(predicted[ti]) for _, ti in matches], dtype=np.float32 + ) + dst = np.array( + [self._center(det_bboxes[di]) for di, _ in matches], dtype=np.float32 + ) + + M = None + try: + import cv2 + + M, _ = cv2.estimateAffinePartial2D( + src, dst, method=cv2.RANSAC, ransacReprojThreshold=5.0 + ) + except Exception: + M = None + + if M is not None: + scale = float(np.hypot(M[0, 0], M[0, 1])) + # Reject implausible fits (e.g. from too few/noisy correspondences). + if 0.5 <= scale <= 2.0: + + def warp(box): + cx, cy = self._center(box) + ncx = M[0, 0] * cx + M[0, 1] * cy + M[0, 2] + ncy = M[1, 0] * cx + M[1, 1] * cy + M[1, 2] + nw, nh = box[2] * scale, box[3] * scale + return np.array([ncx - nw / 2.0, ncy - nh / 2.0, nw, nh]) + + return warp + + # Fallback: robust median translation (pan/tilt only). + delta = dst - src + tx, ty = float(np.median(delta[:, 0])), float(np.median(delta[:, 1])) + if abs(tx) < 1.0 and abs(ty) < 1.0: + return None + return lambda box: np.array([box[0] + tx, box[1] + ty, box[2], box[3]]) + + def _associate(self, det_bboxes, det_idxs, trk_idxs, trk_boxes, detections): + """Hungarian-match a subset of detections to a subset of trackers, + applying updates to matched trackers. Returns list of (det_i, trk_i).""" + from scipy.optimize import linear_sum_assignment + + if not det_idxs or not trk_idxs: + return [] + dets = [det_bboxes[d] for d in det_idxs] + trks = [trk_boxes[t] for t in trk_idxs] + iou_matrix = iou_batch(dets, trks) + if iou_matrix.size == 0: + return [] + cost = 1.0 - iou_matrix + row_ind, col_ind = linear_sum_assignment(cost) + matches = [] + for r, c in zip(row_ind, col_ind): + if iou_matrix[r, c] >= self.iou_threshold: + di, ti = det_idxs[r], trk_idxs[c] + self.trackers[ti].update(detections[di][:4]) + self.trackers[ti].label_quark = detections[di][5] + matches.append((di, ti)) + return matches + + def _suppress_duplicates(self): + """Drop the weaker of any two confirmed tracks sitting on the same box.""" + n = len(self.trackers) + if n < 2: + return + boxes = [t.get_bbox() for t in self.trackers] + iou_matrix = iou_batch(boxes, boxes) + remove = set() + for i in range(n): + if i in remove: + continue + for j in range(i + 1, n): + if j in remove: + continue + if iou_matrix[i, j] > self.dup_iou: + ti, tj = self.trackers[i], self.trackers[j] + # Keep the better track: matched more recently, then more + # hits; drop the other (usually the freshly-spawned dup). + ki = (ti.time_since_update, -ti.hits) + kj = (tj.time_since_update, -tj.hits) + remove.add(j if ki <= kj else i) + if remove: + self.trackers = [t for k, t in enumerate(self.trackers) if k not in remove] + def update(self, detections): """ Update tracks with new detections. @@ -151,49 +272,60 @@ def update(self, detections): list of (track_id, bbox, label_quark) for confirmed tracks """ import numpy as np - from scipy.optimize import linear_sum_assignment # Predict new locations for existing tracks - predicted = [] to_remove = [] for i, trk in enumerate(self.trackers): - pred = trk.predict() - if np.any(np.isnan(pred)): + if np.any(np.isnan(trk.predict())): to_remove.append(i) - else: - predicted.append(pred) for i in reversed(to_remove): self.trackers.pop(i) - # Build cost matrix using IoU det_bboxes = [d[:4] for d in detections] if len(detections) > 0 else [] - iou_matrix = iou_batch(det_bboxes, predicted) - cost_matrix = 1.0 - iou_matrix - - # Hungarian assignment - matched_det = set() - matched_trk = set() - if cost_matrix.size > 0: - row_ind, col_ind = linear_sum_assignment(cost_matrix) - for r, c in zip(row_ind, col_ind): - if iou_matrix[r, c] >= self.iou_threshold: - matched_det.add(r) - matched_trk.add(c) - self.trackers[c].update(detections[r][:4]) - # Store latest label quark on tracker - self.trackers[c].label_quark = detections[r][5] - - # Create new tracks for unmatched detections - for d_idx in range(len(detections)): + n_det = len(det_bboxes) + n_trk = len(self.trackers) + # Predicted box per tracker, captured before any update this frame. + predicted = [self.trackers[i].get_bbox() for i in range(n_trk)] + + # 1) First association on the raw predictions. + matches = self._associate( + det_bboxes, list(range(n_det)), list(range(n_trk)), predicted, detections + ) + matched_det = {di for di, _ in matches} + matched_trk = {ti for _, ti in matches} + + # 2) Camera-motion compensation: fit a global image transform (pan, zoom + # and rotation) from the tracks that matched, apply it to the leftover + # predictions, and re-match. This recovers tracks during camera moves + # instead of leaving them behind and spawning duplicates. + if self.camera_motion: + warp = self._estimate_motion(matches, predicted, det_bboxes) + if warp is not None: + rem_trk = [i for i in range(n_trk) if i not in matched_trk] + rem_det = [d for d in range(n_det) if d not in matched_det] + if rem_trk and rem_det: + shifted = {i: warp(predicted[i]) for i in rem_trk} + m2 = self._associate( + det_bboxes, rem_det, rem_trk, shifted, detections + ) + matched_det.update(di for di, _ in m2) + + # Create new tracks for unmatched detections, but only from confident + # ones (ByteTrack activation gate) so weak/ghost boxes don't start a + # phantom track that gets drawn as a stray circle. + for d_idx in range(n_det): if d_idx not in matched_det: + if detections[d_idx][4] < self.new_track_conf: + continue trk = KalmanBoxTracker(detections[d_idx][:4]) trk.label_quark = detections[d_idx][5] self.trackers.append(trk) - # Remove dead tracks + # Remove dead tracks, then drop duplicate tracks sitting on one object. self.trackers = [ t for t in self.trackers if t.time_since_update <= self.max_age ] + self._suppress_duplicates() # Return confirmed tracks, including ones that missed a detection this # frame (predicted box) for up to keep_alive frames — prevents flicker. @@ -284,6 +416,39 @@ class TrackerTransform(GstBase.BaseTransform): flags=GObject.ParamFlags.READWRITE, ) + new_track_confidence = GObject.Property( + type=float, + default=0.25, + minimum=0.0, + maximum=1.0, + nick="New Track Confidence", + blurb="Minimum detection confidence to START a new track (ByteTrack " + "activation gate); weak boxes still continue existing tracks but " + "won't spawn phantom/duplicate circles", + flags=GObject.ParamFlags.READWRITE, + ) + + camera_motion = GObject.Property( + type=bool, + default=True, + nick="Camera Motion Compensation", + blurb="Estimate the global image shift from matched tracks and re-match " + "leftovers shifted by it, so a panning camera re-attaches players " + "instead of leaving the old track behind and spawning a duplicate", + flags=GObject.ParamFlags.READWRITE, + ) + + duplicate_iou = GObject.Property( + type=float, + default=0.8, + minimum=0.0, + maximum=1.0, + nick="Duplicate IoU", + blurb="Two confirmed tracks overlapping more than this are treated as " + "duplicates and the weaker one is dropped", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.logger = LoggerFactory.get(LoggerFactory.LOGGER_TYPE_GST) @@ -298,6 +463,9 @@ def _ensure_tracker(self): min_hits=self.min_hits, iou_threshold=self.iou_threshold, keep_alive=self.keep_alive, + new_track_conf=self.new_track_confidence, + camera_motion=self.camera_motion, + dup_iou=self.duplicate_iou, ) return self._tracker @@ -370,6 +538,12 @@ def do_get_property(self, prop): return self.iou_threshold elif prop.name == "keep-alive": return self.keep_alive + elif prop.name == "new-track-confidence": + return self.new_track_confidence + elif prop.name == "camera-motion": + return self.camera_motion + elif prop.name == "duplicate-iou": + return self.duplicate_iou else: raise AttributeError(f"Unknown property {prop.name}") @@ -389,6 +563,15 @@ def do_set_property(self, prop, value): elif prop.name == "keep-alive": self.keep_alive = value self._tracker = None + elif prop.name == "new-track-confidence": + self.new_track_confidence = value + self._tracker = None + elif prop.name == "camera-motion": + self.camera_motion = value + self._tracker = None + elif prop.name == "duplicate-iou": + self.duplicate_iou = value + self._tracker = None else: raise AttributeError(f"Unknown property {prop.name}") diff --git a/plugins/python/yolo.py b/plugins/python/yolo.py index dfe397d..2140c74 100644 --- a/plugins/python/yolo.py +++ b/plugins/python/yolo.py @@ -160,6 +160,9 @@ def do_forward(self, frames): ) end_pre = time.time() + conf = getattr(self, "conf", 0.25) + iou = getattr(self, "iou", 0.5) + agnostic = getattr(self, "agnostic_nms", True) if self.track: # Ensure tracker persists across batches results = self.execute_with_stream( @@ -167,14 +170,23 @@ def do_forward(self, frames): source=img_list, persist=True, imgsz=640, - conf=0.1, + conf=conf, + iou=iou, + agnostic_nms=agnostic, verbose=True, tracker="botsort.yaml", ) ) else: results = self.execute_with_stream( - lambda: model(img_list, imgsz=640, conf=0.1, verbose=True) + lambda: model( + img_list, + imgsz=640, + conf=conf, + iou=iou, + agnostic_nms=agnostic, + verbose=True, + ) ) end_inf = time.time() @@ -205,6 +217,36 @@ class YOLOTransform(BaseObjectDetector): "Aaron Boxer ", ) + confidence = GObject.Property( + type=float, + default=0.1, + minimum=0.0, + maximum=1.0, + nick="Confidence Threshold", + blurb="Minimum detection confidence (matches football_analyzer); kept " + "low on purpose so the tracker can use weak boxes to continue tracks " + "-- the tracker's new-track-confidence gates phantom tracks", + flags=GObject.ParamFlags.READWRITE, + ) + nms_iou = GObject.Property( + type=float, + default=0.7, + minimum=0.0, + maximum=1.0, + nick="NMS IoU", + blurb="NMS IoU threshold (matches football_analyzer's default); lower " + "suppresses more overlap but can also drop genuinely close players", + flags=GObject.ParamFlags.READWRITE, + ) + agnostic_nms = GObject.Property( + type=bool, + default=False, + nick="Class-Agnostic NMS", + blurb="Suppress overlapping boxes across classes too; off by default " + "(like football_analyzer) so two close players aren't merged", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.mgr.engine_name = "pyml_yolo_engine" @@ -222,6 +264,14 @@ def engine_name(self, value): "The 'engine_name' property cannot be set in this derived class." ) + def do_forward(self, frames): + # Push NMS/confidence knobs to the engine before it runs the model. + if self.engine: + self.engine.conf = self.confidence + self.engine.iou = self.nms_iou + self.engine.agnostic_nms = self.agnostic_nms + return super().do_forward(frames) + def do_decode(self, buf, result, stream_idx=0): self.logger.debug( f"Decoding YOLO result for buffer {hex(id(buf))}, stream {stream_idx}: {result}" From ec81ba949723fe55120d96b9301cd468e092087c Mon Sep 17 00:00:00 2001 From: Marcus Edel Date: Wed, 17 Jun 2026 23:08:50 +0000 Subject: [PATCH 10/10] Detect with the ONNX football model at 640 while drawing the overlay on the original-resolution frame, expose the detector's confidence/NMS thresholds, and harden the overlay's kit-colour referee detection, box de-duplication, missed-detection bridging and circle smoothing. Signed-off-by: Marcus Edel --- demo/football/onnx_loop.py | 156 +++++++++++++++++++ plugins/python/engine/ml_engine.py | 7 +- plugins/python/engine/onnx_engine.py | 67 +++++++- plugins/python/football_overlay.py | 220 +++++++++++++++++++++------ plugins/python/objectdetector.py | 28 ++++ 5 files changed, 427 insertions(+), 51 deletions(-) create mode 100644 demo/football/onnx_loop.py diff --git a/demo/football/onnx_loop.py b/demo/football/onnx_loop.py new file mode 100644 index 0000000..bbe9540 --- /dev/null +++ b/demo/football/onnx_loop.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# Run a video through the ONNX (fp16) football pipeline. +# +# detector (onnx) -> pyml_tracker -> pyml_football_overlay +# +# Usage: +# python demo/football/onnx_loop.py INPUT.mp4 # live display, looping +# python demo/football/onnx_loop.py INPUT.mp4 OUTPUT.mp4 # write annotated mp4 +# (self-contained: finds the repo venv + plugins and re-execs into them) +import os +import sys +import glob + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +VENV = os.path.join(REPO, ".venv") +MODEL = os.path.join(REPO, "models/football/football_fp16.onnx") +os.environ["GST_PLUGIN_PATH"] = ( + os.path.join(REPO, "plugins") + os.pathsep + os.environ.get("GST_PLUGIN_PATH", "") +) +if not os.environ.get("_ONNX_LOOP_REEXEC") and os.path.isdir(VENV): + os.environ["VIRTUAL_ENV"] = VENV + os.environ["PATH"] = ( + os.path.join(VENV, "bin") + os.pathsep + os.environ.get("PATH", "") + ) + libs = sorted( + set( + glob.glob( + os.path.join( + VENV, "lib", "python*", "site-packages", "nvidia", "*", "lib" + ) + ) + ) + ) + if libs: + os.environ["LD_LIBRARY_PATH"] = os.pathsep.join( + [*libs, os.environ.get("LD_LIBRARY_PATH", "")] + ) + os.environ["_ONNX_LOOP_REEXEC"] = "1" + pybin = os.path.join(VENV, "bin", "python") + exe = pybin if os.path.exists(pybin) else sys.executable + os.execv(exe, [exe, *sys.argv]) + +import gi # noqa: E402 + +gi.require_version("Gst", "1.0") +from gi.repository import Gst, GLib # noqa: E402 + +Gst.init(None) + + +def on_message(bus, message, loop, pipeline, do_loop): + t = message.type + + if t == Gst.MessageType.EOS: + if do_loop: + # Display mode: seek back to the start to loop the clip. + print("Looping...") + if not pipeline.seek_simple( + Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0 + ): + print("Failed to seek back to start", file=sys.stderr) + loop.quit() + else: + # mp4 mode: end of file, the muxer has finalized the file. + loop.quit() + + elif t == Gst.MessageType.ERROR: + err, debug = message.parse_error() + print(f"ERROR: {err}", file=sys.stderr) + if debug: + print(f"DEBUG: {debug}", file=sys.stderr) + loop.quit() + + +def main(): + if len(sys.argv) < 2: + print(f"usage: {sys.argv[0]} INPUT.mp4 [OUTPUT.mp4]", file=sys.stderr) + print( + " no OUTPUT -> live display (looping); OUTPUT -> write annotated mp4", + file=sys.stderr, + ) + sys.exit(1) + video = os.path.abspath(sys.argv[1]) + out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else None + + # Shared detection + overlay chain. Feed the ORIGINAL resolution: + # pyml_objectdetector letterboxes to the model's 640 internally for + # inference and maps boxes back, so the overlay stays full-res. + chain = ( + f"filesrc location={video} ! " + "decodebin ! videoconvert ! video/x-raw,format=RGB ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_objectdetector engine-name=onnx " + f" model-name={MODEL} device=cuda:0 " + " input-format=nchw post-process=anchor_free interval=1 " + " confidence=0.1 nms-iou=0.7 ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_tracker tracker-type=bytetrack new-track-confidence=0.25 ! " + "videoconvert ! video/x-raw,format=RGBA ! " + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "pyml_football_overlay class-names=ball,goalkeeper,player,referee " + " team-colors=true trails=false show-ids=false show-labels=false " + " draw-from-detections=true min-confidence=0 merge-iou=0.5 " + " position-smoothing=0.7 highlight-focal=false ! " + ) + if out: + pipeline_description = ( + chain + "queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 ! " + "videoconvert ! openh264enc ! h264parse ! mp4mux ! " + f"filesink location={out}" + ) + do_loop = False + else: + # Pre-roll buffer absorbs inference jitter for smooth real-time display. + pipeline_description = ( + chain + "queue max-size-buffers=600 max-size-time=0 max-size-bytes=0 " + " min-threshold-buffers=30 ! " + "videoconvert ! autovideosink sync=true" + ) + do_loop = True + + print(pipeline_description) + print(f"writing -> {out}" if out else "live display (looping)") + + try: + pipeline = Gst.parse_launch(pipeline_description) + except GLib.Error as e: + print(f"Failed to create pipeline: {e}", file=sys.stderr) + sys.exit(1) + + loop = GLib.MainLoop() + + bus = pipeline.get_bus() + bus.add_signal_watch() + bus.connect("message", on_message, loop, pipeline, do_loop) + + pipeline.set_state(Gst.State.PLAYING) + + try: + loop.run() + except KeyboardInterrupt: + if out: + # Finalize the mp4 on Ctrl-C: send EOS and wait for the muxer to + # flush its trailer, otherwise the file is left unplayable. + pipeline.send_event(Gst.Event.new_eos()) + bus.timed_pop_filtered( + 5 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR + ) + finally: + pipeline.set_state(Gst.State.NULL) + if out: + print(f"Done: {out}") + + +if __name__ == "__main__": + main() diff --git a/plugins/python/engine/ml_engine.py b/plugins/python/engine/ml_engine.py index 3d5a74c..58f2e9d 100644 --- a/plugins/python/engine/ml_engine.py +++ b/plugins/python/engine/ml_engine.py @@ -96,7 +96,12 @@ def _apply_post_process(self, raw, is_batch): if pp != "none" and not isinstance(raw, list): from utils.detection_decoder import decode - results = decode(raw, pp) + results = decode( + raw, + pp, + conf_threshold=getattr(self, "conf", 0.25), + iou_threshold=getattr(self, "iou", 0.45), + ) return results[0] if not is_batch else results return raw diff --git a/plugins/python/engine/onnx_engine.py b/plugins/python/engine/onnx_engine.py index 0b77d22..ad3dce7 100644 --- a/plugins/python/engine/onnx_engine.py +++ b/plugins/python/engine/onnx_engine.py @@ -49,6 +49,62 @@ def _input_is_nchw(self): shape = self.session.get_inputs()[0].shape return len(shape) == 4 and shape[1] in (1, 3, 4) + def _model_input_hw(self): + """(H, W) the model's input expects, or None if dynamic/unknown.""" + if self.session is None: + return None + shape = self.session.get_inputs()[0].shape + if len(shape) != 4: + return None + h, w = shape[2], shape[3] + if isinstance(h, int) and isinstance(w, int) and h > 0 and w > 0: + return (h, w) + return None + + def _letterbox(self, frames, is_batch): + """Resize frame(s) to the model input size, preserving aspect ratio with + grey padding (YOLO-style). Returns (processed, transform); transform = + (ratio, pad_x, pad_y, orig_w, orig_h) maps model coords back to the + original frame. Returns (frames, None) when no resize is needed (already + model-sized, or dynamic input) -- so pre-sized callers are unaffected.""" + import numpy as np + import cv2 + + mhw = self._model_input_hw() + if mhw is None: + return frames, None + mh, mw = mhw + imgs = frames if is_batch else frames[None] + h, w = int(imgs.shape[1]), int(imgs.shape[2]) + if (h, w) == (mh, mw): + return frames, None + r = min(mh / h, mw / w) + nh, nw = int(round(h * r)), int(round(w * r)) + pad_x, pad_y = (mw - nw) // 2, (mh - nh) // 2 + out = np.full((imgs.shape[0], mh, mw, imgs.shape[3]), 114, dtype=imgs.dtype) + for i in range(imgs.shape[0]): + out[i, pad_y : pad_y + nh, pad_x : pad_x + nw] = cv2.resize( + imgs[i], (nw, nh), interpolation=cv2.INTER_LINEAR + ) + proc = out if is_batch else out[0] + return proc, (r, float(pad_x), float(pad_y), w, h) + + def _unletterbox(self, results, transform): + """Map detection boxes from model coords back to original-frame coords.""" + import numpy as np + + r, pad_x, pad_y, ow, oh = transform + for res in results if isinstance(results, list) else [results]: + if not isinstance(res, dict): + continue + b = res.get("boxes") + if b is None or len(b) == 0: + continue + b = np.asarray(b, dtype=np.float32).copy() + b[:, [0, 2]] = ((b[:, [0, 2]] - pad_x) / r).clip(0, ow) + b[:, [1, 3]] = ((b[:, [1, 3]] - pad_y) / r).clip(0, oh) + res["boxes"] = b + def do_load_model(self, model_name, **kwargs): """Load a pre-trained model by name from TorchVision, Transformers (via Optimum ONNX), or a local ONNX path.""" processor_name = kwargs.get("processor_name") @@ -369,14 +425,21 @@ def do_forward(self, frames): fmt = self.input_format if fmt == "auto" and self._input_is_nchw(): self.input_format = "nchw" - img = self._apply_input_format(frames.astype(np.float32) / 255.0, is_batch) + # Letterbox to the model's fixed input size for inference, keeping + # the transform so boxes map back to the original frame -- lets the + # caller feed full-res frames and overlay on them. + proc, transform = self._letterbox(frames, is_batch) + img = self._apply_input_format(proc.astype(np.float32) / 255.0, is_batch) if "float16" in self.session.get_inputs()[0].type: img = img.astype(np.float16) outputs = self.session.run(self.output_names, {self.input_names[0]: img}) raw = outputs if len(outputs) > 1 else outputs[0] if isinstance(raw, np.ndarray) and raw.dtype != np.float32: raw = raw.astype(np.float32) - return self._apply_post_process(raw, is_batch) + results = self._apply_post_process(raw, is_batch) + if transform is not None: + self._unletterbox(results, transform) + return results else: raise ValueError("Unsupported model type.") diff --git a/plugins/python/football_overlay.py b/plugins/python/football_overlay.py index 79f7f4d..9dc4f42 100644 --- a/plugins/python/football_overlay.py +++ b/plugins/python/football_overlay.py @@ -483,9 +483,11 @@ def _update_ellipse_width(self, track_id, raw_w): self._widths.append(raw_w) clamped = raw_w prev = self._ell_w.get(track_id) - # EMA: fast enough to follow real perspective changes, slow enough to - # damp single-frame spikes. - self._ell_w[track_id] = clamped if prev is None else 0.4 * clamped + 0.6 * prev + # EMA: slow enough to keep the circle size steady frame-to-frame, fast + # enough to still follow real perspective changes as players move. + self._ell_w[track_id] = ( + clamped if prev is None else 0.25 * clamped + 0.75 * prev + ) def _px_per_meter(self): if not self._heights: @@ -506,19 +508,28 @@ def _focal_track(self): if not keys: return None + # Only consider *sustained* tracks. Otherwise a track that flickered for + # a few frames -- common when detection/tracking churns -- can win on a + # single ball contact and then show ~0 distance (it was barely tracked). + # The floor scales with elapsed frames, with a small absolute minimum. + floor = max(10, int(0.2 * self._frame)) + candidates = [t for t in keys if self._frames_seen.get(t, 0) >= floor] or list( + keys + ) + # Rank by ball contacts (the player most involved with the ball), with # frames-seen as a tiebreak / pre-contact fallback (before anyone has # touched the ball, the most-tracked player is shown). def score(t): return (self._contacts.get(t, 0), self._frames_seen.get(t, 0)) - best = max(keys, key=score) + best = max(candidates, key=score) # Stability: keep the current focal unless a challenger has *strictly # more* contacts, so the highlight/HUD don't flip on ties or noise. cur = self._focal if ( cur is not None - and cur in keys + and cur in candidates and self._contacts.get(best, 0) <= self._contacts.get(cur, 0) ): best = cur @@ -537,19 +548,47 @@ def _stable_label(self, track_id, fallback=""): def _c(self, rgba): return tuple(rgba[i] for i in self._order) + def _team_color(self, track_id): + # Confident kit colour from a track's accumulated jersey votes, else + # None. Red/blue -> team; "ref" (distinctive non-team kit) -> gold. + # Requires a minimum number of votes AND a clear majority, so a few + # noisy frames can't decide the colour. + if track_id is None: + return None + c = self._team_votes.get(track_id) + if not c: + return None + red, blue, ref = c.get("red", 0), c.get("blue", 0), c.get("ref", 0) + total = red + blue + ref + if total < 4: + return None + colors = {_RED_TEAM_RGBA: red, _BLUE_TEAM_RGBA: blue, _REFEREE_RGBA: ref} + color, n = max(colors.items(), key=lambda kv: kv[1]) + return color if n >= 0.6 * total else None + + def _is_referee_track(self, track_id, fallback_label): + # A track is a referee only if referee *clearly dominates* its class + # votes. Referees are rare, so a mostly-player track with a few stray + # 'referee' mislabels stays a player (won't get the gold circle). + votes = self._class_votes.get(track_id) if track_id is not None else None + if not votes: + return _is_referee(fallback_label) + total = sum(votes.values()) + ref = sum(c for lbl, c in votes.items() if _is_referee(lbl)) + return total > 0 and ref >= 3 and ref >= 0.6 * total + def _color_for(self, label, track_id): - if _is_referee(label): - return _REFEREE_RGBA + # Colour by the track's *accumulated* jersey team (robust to per-frame + # noise). Referee/player only decides the fallback when the team is + # undecided: a referee keeps gold (stays visible), a player isn't drawn. if _is_ball(label): return _BALL_RGBA - if self.team_colors and track_id is not None: - c = self._team_votes.get(track_id) - if c and (c.get("red", 0) or c.get("blue", 0)): - return _RED_TEAM_RGBA if c["red"] >= c["blue"] else _BLUE_TEAM_RGBA - # Team not decided yet (or unclassifiable kit, e.g. goalkeeper): - # draw nothing rather than flashing the default cyan. - return None - return _PLAYER_RGBA + if self.team_colors: + team = self._team_color(track_id) + if team is not None: + return team + return _REFEREE_RGBA if self._is_referee_track(track_id, label) else None + return _REFEREE_RGBA if _is_referee(label) else _PLAYER_RGBA @staticmethod def _overlap(a, b): @@ -570,11 +609,23 @@ def _overlap(a, b): contain = inter / smaller if smaller > 0.0 else 0.0 return max(iou, contain) + @staticmethod + def _feet_close(a, b): + # True when two boxes' foot points (bottom-centre, where the ellipse is + # drawn) are within ~0.4 of the smaller box width. The ellipse is ~2x the + # box width, so near-coincident feet = one player circled twice even when + # the boxes' IoU is low. Genuinely adjacent players are ~a full width + # apart at the feet, so they're not merged. + fax, fay = (a[0] + a[2]) / 2.0, a[3] + fbx, fby = (b[0] + b[2]) / 2.0, b[3] + ref = max(1.0, min(a[2] - a[0], b[2] - b[0])) + return ((fax - fbx) ** 2 + (fay - fby) ** 2) ** 0.5 < 0.4 * ref + def _merge_overlaps(self, entries): # Class-agnostic greedy suppression: keep the most confident box, drop - # any later box that overlaps it past merge_iou. Collapses a player - # circled twice (e.g. player+goalkeeper on one person) into one. The - # ball is never merged against players. + # any later box that overlaps it past merge_iou OR sits at the same feet. + # Collapses a player circled twice (e.g. player+goalkeeper on one person, + # or two offset boxes) into one. The ball is never merged against players. if self.merge_iou <= 0.0 or len(entries) < 2: return entries ordered = sorted(entries, key=lambda e: e["confidence"], reverse=True) @@ -585,13 +636,44 @@ def _merge_overlaps(self, entries): continue if any( not _is_ball(k["label"]) - and self._overlap(e["box"], k["box"]) >= self.merge_iou + and ( + self._overlap(e["box"], k["box"]) >= self.merge_iou + or self._feet_close(e["box"], k["box"]) + ) for k in kept ): continue kept.append(e) return kept + def _assign_track_ids(self, draw_entries, track_entries): + # Give each drawn box a stable track id: track-mode boxes already carry + # one; detection-mode boxes borrow the id of the best-overlapping track + # (greedy, each track used once) so detection circles can use the + # tracker's persistent id for the badge and the accumulated colour. + ids = [e["track_id"] for e in draw_entries] + if not track_entries: + return ids + pairs = [] + for di, e in enumerate(draw_entries): + if e["track_id"] is not None or _is_ball(e["label"]): + continue + for t in track_entries: + if _is_ball(t["label"]): + continue + ov = self._overlap(e["box"], t["box"]) + if ov >= 0.3: + pairs.append((ov, di, t["track_id"])) + pairs.sort(key=lambda p: p[0], reverse=True) + used_draw, used_track = set(), set() + for _ov, di, tid in pairs: + if di in used_draw or tid in used_track: + continue + ids[di] = tid + used_draw.add(di) + used_track.add(tid) + return ids + def _smooth_boxes(self, np, entries): # Temporal EMA on the boxes we're about to draw. Each box is matched to # the nearest slot from last frame (by centre, within a size-relative @@ -644,22 +726,27 @@ def _smooth_boxes(self, np, entries): return out def _detection_color(self, cv2, np, frame, label, box): - # Colour a raw detection box (no track id): referee gold, otherwise the - # jersey team classified from this frame. Returns None for an - # undecided kit (e.g. goalkeeper) so it isn't drawn, matching the - # track-mode behaviour. - if _is_referee(label): - return _REFEREE_RGBA + # Colour a raw detection box (no track id) by its jersey team, classified + # from this frame -- referees included. When the jersey isn't clearly a + # team colour, a referee falls back to gold (so real refs stay visible) + # and a player isn't drawn (matching the track-mode behaviour). + ref = _is_referee(label) if not self.team_colors: - return _PLAYER_RGBA + return _REFEREE_RGBA if ref else _PLAYER_RGBA vote = self._classify_jersey(cv2, np, frame, box) - if vote is None: - return None - return _RED_TEAM_RGBA if vote == "red" else _BLUE_TEAM_RGBA + if vote == "red": + return _RED_TEAM_RGBA + if vote == "blue": + return _BLUE_TEAM_RGBA + if vote == "ref": + return _REFEREE_RGBA + return _REFEREE_RGBA if ref else None def _classify_jersey(self, cv2, np, frame, box): - # Dominant jersey hue in the torso patch -> "red"/"blue"/None (HSV), - # ported from football_analyzer.classify_jersey. + # Dominant jersey colour in the torso patch -> "red"/"blue"/"ref"/None + # (HSV). "ref" is a distinctive non-team kit colour (yellow/orange or + # pink/magenta) -- chosen to avoid grass-green and the red/blue teams -- + # so the referee is identified by its kit colour, not the class label. x1, y1, x2, y2 = (int(v) for v in box) h_box, w_box = y2 - y1, x2 - x1 if h_box <= 0 or w_box <= 0: @@ -676,12 +763,17 @@ def _classify_jersey(self, cv2, np, frame, box): hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) s_v = (hsv[..., 1] > 80) & (hsv[..., 2] > 50) h = hsv[..., 0] - red = (((h <= 10) | (h >= 170)) & s_v).sum() - blue = ((h >= 100) & (h <= 130) & s_v).sum() + red = int((((h <= 10) | (h >= 170)) & s_v).sum()) + blue = int(((h >= 100) & (h <= 130) & s_v).sum()) + # Referee kit: yellow/orange (~18-34) or pink/magenta (~145-165). These + # bands skip grass-green (~40-90) and the red/blue team bands. + ref = int(((((h >= 18) & (h <= 34)) | ((h >= 145) & (h <= 165))) & s_v).sum()) min_pixels = max(20, int(0.02 * rgb.shape[0] * rgb.shape[1])) - if red < min_pixels and blue < min_pixels: + counts = {"red": red, "blue": blue, "ref": ref} + best = max(counts, key=counts.get) + if counts[best] < min_pixels: return None - return "red" if red >= blue else "blue" + return best def _load_headshot(self, cv2, np): if self._headshot_loaded: @@ -874,7 +966,27 @@ def do_transform_ip(self, buf): ) if self.draw_from_detections: - draw_entries = det_entries + draw_entries = list(det_entries) + # Bridge missed detections: the detector occasionally drops a + # player for a frame, which would flicker the circle. The tracker + # is still coasting that player (Kalman keep-alive), so draw any + # confirmed track that has no detection this frame -- detections + # still drive everything they cover; tracks only fill the gaps. + if track_entries: + covered = set() + for d in det_entries: + if _is_ball(d["label"]): + continue + for t in track_entries: + if t["track_id"] in covered or _is_ball(t["label"]): + continue + if self._overlap(d["box"], t["box"]) >= 0.3: + covered.add(t["track_id"]) + draw_entries += [ + t + for t in track_entries + if not _is_ball(t["label"]) and t["track_id"] not in covered + ] else: draw_entries = track_entries if track_entries else det_entries # min-confidence gates only what we *draw* (tracks carry conf 1.0, so @@ -903,16 +1015,20 @@ def do_transform_ip(self, buf): # Jersey team voting first, so trails/ellipses use this frame's # vote (track mode; detection mode classifies per box at draw). + # Referees are voted on too -- their colour comes from the jersey + # (gold only as the fallback), not the class label. if self.team_colors: for e in track_entries: tid = e["track_id"] lab = self._stable_label(tid, e["label"]) - if _is_ball(lab) or _is_referee(lab): + if _is_ball(lab): continue vote = self._classify_jersey(cv2, np, frame, e["box"]) if vote: - tv = self._team_votes.setdefault(tid, {"red": 0, "blue": 0}) - tv[vote] += 1 + tv = self._team_votes.setdefault( + tid, {"red": 0, "blue": 0, "ref": 0} + ) + tv[vote] = tv.get(vote, 0) + 1 if self.trails: for tid in active: @@ -942,27 +1058,35 @@ def do_transform_ip(self, buf): if ov > best: best, focal_idx = ov, i + # Stable track id per drawn box (detection boxes borrow the id of + # the track they overlap) -- used for the id badge and to look up + # the track's accumulated colour. + draw_ids = self._assign_track_ids(draw_entries, track_entries) + for i, e in enumerate(draw_entries): box = e["box"] - tid = e["track_id"] - if tid is not None: - # Track mode: style by the majority-voted class (stable), - # not this frame's possibly-flickering label. - label = self._stable_label(tid, e["label"]) + badge_id = draw_ids[i] + # Use the track's stable identity (class + accumulated team + # votes) for colour whenever the box maps to a track -- in + # detection mode that's the box's matched track id. This + # makes colour robust to per-frame label/jersey noise. Only + # an unmatched detection falls back to this frame's guess. + color_tid = e["track_id"] if e["track_id"] is not None else badge_id + if color_tid is not None: + label = self._stable_label(color_tid, e["label"]) else: - # Detection mode: the raw per-frame class. label = e["label"] if _is_ball(label): if self.show_ball: self._draw_triangle(cv2, np, frame, box, _BALL_RGBA) continue - if tid is not None: - rgba = self._color_for(label, tid) + if color_tid is not None: + rgba = self._color_for(label, color_tid) else: rgba = self._detection_color(cv2, np, frame, label, box) if rgba is None: continue - self._draw_ellipse(cv2, frame, box, rgba, tid) + self._draw_ellipse(cv2, frame, box, rgba, badge_id) if i == focal_idx: self._draw_focal_marker(cv2, np, frame, box) if self.show_labels: diff --git a/plugins/python/objectdetector.py b/plugins/python/objectdetector.py index a429eb5..9272295 100644 --- a/plugins/python/objectdetector.py +++ b/plugins/python/objectdetector.py @@ -46,12 +46,40 @@ class ObjectDetector(BaseObjectDetector): "Aaron Boxer ", ) + confidence = GObject.Property( + type=float, + default=0.25, + minimum=0.0, + maximum=1.0, + nick="Confidence Threshold", + blurb="Minimum detection confidence for the decoder post-process " + "(anchor_free); lower = more (and weaker) detections", + flags=GObject.ParamFlags.READWRITE, + ) + nms_iou = GObject.Property( + type=float, + default=0.45, + minimum=0.0, + maximum=1.0, + nick="NMS IoU", + blurb="NMS IoU threshold for the decoder post-process; higher keeps " + "more overlapping boxes", + flags=GObject.ParamFlags.READWRITE, + ) + def __init__(self): super().__init__() self.logger.info( "ObjectDetector created without a model. Please set the 'model-name' property." ) + def do_forward(self, frames): + # Push decoder thresholds to the engine before it post-processes. + if self.engine: + self.engine.conf = self.confidence + self.engine.iou = self.nms_iou + return super().do_forward(frames) + if CAN_REGISTER_ELEMENT: GObject.type_register(ObjectDetector)