diff --git a/.gitignore b/.gitignore index e43b0f988..1992df166 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,28 @@ +# macOS system files .DS_Store + +# Python venv +.venv/ + +# Jupyter +*.ipynb +.ipynb_checkpoints/ + +# Python cache +__pycache__/ +*.pyc +*.pyo + +# Data / outputs +data/ +out/ +runs/ + +# IDEs +.vscode/ +.idea/ + +# Model weights & large files +*.pt +*.pth +*.zip \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/README.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/README.md new file mode 100644 index 000000000..ca1cf27bf --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/README.md @@ -0,0 +1,98 @@ +\# AFL Player Tracking Logic + + + +This folder contains scripts developed for the Redback Project AFL Player Tracking system. + +These scripts support syncing, merging, evaluating, and analyzing AFL player tracking and event data. + + + +\## šŸ“‚ Scripts + + + +\- \*\*annotation\_converter.py\*\* + +  Converts annotation formats into a consistent structure for further processing. + + + +\- \*\*annotation\_sync.py\*\* + +  Syncs manual event annotations with YOLOv8 + DeepSORT tracking outputs. + + + +\- \*\*event\_timing\_analysis.py\*\* + +  Analyzes and visualizes the timing of events across match footage. + + + +\- \*\*merge\_synced.py\*\* + +  Merges multiple synced annotation files into a single dataset. + + + +\- \*\*prediction\_vs\_truth.py\*\* + +  Evaluates predicted events against ground truth annotations, producing precision/recall metrics. + + + +--- + + + +\## Usage + + + +Each script can be run independently from the command line: + + + +```bash + +python annotation\_converter.py + +python annotation\_sync.py + +python event\_timing\_analysis.py + +python merge\_synced.py + +python prediction\_vs\_truth.py + + + + + +\## Notes + + + +\- Replace `<...>` placeholders with the actual file paths and directories you are using. + +\- Ensure that output directories exist before running scripts (create them with `mkdir` if necessary). + +\- Scripts can be run independently, but together they form a full pipeline for syncing, merging, evaluating, and analyzing AFL tracking + event data. + + + + + +\## Author + + + +Developed by \*\*Jarrod Gillingham\*\* for \*Redback Project 4 – Player Tracking Logic / Integration \& Evaluation Team\*. + + + + + + + diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_converter.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_converter.py new file mode 100644 index 000000000..34114b296 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_converter.py @@ -0,0 +1,63 @@ +# === annotation_converter.py === +# Convert Basil's event annotations into a flat CSV +# Keeps "None" rows so we preserve full frame coverage + +import argparse +import pandas as pd +import ast +from pathlib import Path + +def parse_args(): + ap = argparse.ArgumentParser(description="Convert Basil annotation export into flat CSV") + ap.add_argument("--in", dest="inp", required=True, help="Raw annotations CSV (export from Basil)") + ap.add_argument("--out", dest="out", required=True, help="Output parsed CSV") + return ap.parse_args() + +def extract_event(cell): + """ + Each 'annotations' cell contains a JSON-like string with event info. + Example: + "[{'result': [{'value': {'choices': ['Kick']}}]}]" + """ + try: + parsed = ast.literal_eval(cell) + if isinstance(parsed, list) and parsed: + res = parsed[0].get("result", []) + if res: + choices = res[0].get("value", {}).get("choices", []) + if choices: + return choices[0].lower().strip() + except Exception: + return None + return None + +def main(): + args = parse_args() + df = pd.read_csv(args.inp) + + if "annotations" not in df.columns or "data.image" not in df.columns: + raise ValueError("Expected columns 'annotations' and 'data.image' in input file") + + # Extract event_name + df["event_name"] = df["annotations"].apply(extract_event) + + # Ensure lowercase, and fill missing with "none" + df["event_name"] = df["event_name"].fillna("none").astype(str) + + # Derive frame_id from filename + df["frame_id"] = ( + df["data.image"] + .str.extract(r"frame_(\d+)\.png")[0] + .astype(int) + ) + + # Keep essential cols + out_df = df[["frame_id", "event_name"]].sort_values("frame_id").reset_index(drop=True) + + # Save + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + out_df.to_csv(args.out, index=False) + print(f"āœ… Parsed annotations with {len(out_df)} rows → {args.out}") + +if __name__ == "__main__": + main() diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_sync.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_sync.py new file mode 100644 index 000000000..255663d6d --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/annotation_sync.py @@ -0,0 +1,173 @@ +# === annotation_sync.py === +# ============================================================================= +# HOW TO RUN ANNOTATION SYNC +# +# Example: syncing Basil's events with Xuan's parsed tracking +# +# FULL dataset (all frames): +# python annotation_sync.py ^ +# --events data/events_annotations/parsed_event_annotation.csv ^ +# --track data/parsed_tracking.csv ^ +# --out data/synced_annotations/synced_annotations.csv ^ +# --report-dir data/synced_annotations --mode full +# +# EVENT-only dataset (only annotated events, skip "none"): +# python annotation_sync.py ^ +# --events data/events_annotations/parsed_event_annotation.csv ^ +# --track data/parsed_tracking.csv ^ +# --out data/synced_annotations/synced_annotations.csv ^ +# --report-dir data/synced_annotations --mode event +# +# Outputs: +# - synced_annotations_full.csv (all frames with tracking merged) +# - synced_annotations_event.csv (only annotated events) +# - sync_summary.json (match stats) +# - unmatched_events.csv (frames without matches) +# ============================================================================= + +import os, argparse, json +import pandas as pd +import numpy as np +from pathlib import Path +from collections import defaultdict + +# ------------------------- +# CLI +# ------------------------- +def parse_args(): + ap = argparse.ArgumentParser(description="Sync Basil events with Xuan tracking.") + ap.add_argument("--events", required=True, help="Events CSV (from annotation_converter).") + ap.add_argument("--track", required=True, help="Parsed tracking CSV (from convert_xuan_json).") + ap.add_argument("--out", required=True, help="Output CSV base name.") + ap.add_argument("--report-dir", required=True, help="Directory for reports.") + ap.add_argument("--frame-window", type=int, default=2, help="± frames for nearest-frame recovery.") + ap.add_argument("--mode", choices=["full", "event"], default="full", + help="Sync mode: 'full' keeps all frames, 'event' keeps only annotated events.") + return ap.parse_args() + +# ------------------------- +# Load data +# ------------------------- +def load_data(events_path, track_path): + events_df = pd.read_csv(events_path) + track_df = pd.read_csv(track_path) + + # enforce expected cols + if "event_name" not in events_df.columns: + raise ValueError("Events CSV must have 'event_name' column.") + if "frame_id" not in events_df.columns: + raise ValueError("Events CSV must have 'frame_id' column.") + + # normalise types + events_df["frame_id"] = pd.to_numeric(events_df["frame_id"], errors="coerce").astype("Int64") + events_df["event_name"] = events_df["event_name"].astype(str).str.lower().str.strip() + + for c in ["frame_id","player_id"]: + if c in track_df.columns: + track_df[c] = pd.to_numeric(track_df[c], errors="coerce").astype("Int64") + + return events_df, track_df + +# ------------------------- +# Sync annotations +# ------------------------- +def sync(events_df, track_df, frame_window=2): + # exact join on frame_id + merged = events_df.merge(track_df, how="left", on="frame_id", suffixes=("_ev","")) + exact_hits = merged["x1"].notna().sum() + print(f"Exact matches: {exact_hits}/{len(events_df)}") + + # nearest frame recovery + unmatched = merged[merged["x1"].isna()][["frame_id","event_name"]].copy() + if unmatched.empty: + return merged + + recovered = [] + grouped = dict(tuple(track_df.groupby("frame_id"))) + for _, row in unmatched.iterrows(): + f = int(row["frame_id"]) + best = None + best_delta = None + for d in range(-frame_window, frame_window+1): + cand = grouped.get(f+d) + if cand is None: + continue + pick = cand.sort_values("confidence", ascending=False).iloc[0].copy() + delta = abs(d) + if best is None or delta < best_delta: + best = pick + best_delta = delta + if best is not None: + r = {"frame_id": f, "event_name": row["event_name"]} + for c in ["player_id","timestamp_s","x1","y1","x2","y2","cx","cy","w","h","confidence"]: + r[c] = best.get(c, np.nan) + r["frame_id_matched"] = best.get("frame_id", f) + recovered.append(r) + + if recovered: + rec_df = pd.DataFrame(recovered) + merged = pd.concat([merged, rec_df], ignore_index=True) + + return merged + +# ------------------------- +# Summarize sync +# ------------------------- +def summarize(events_df, synced_df, report_dir): + Path(report_dir).mkdir(parents=True, exist_ok=True) + + total = len(events_df) + # unique events matched at least once + matched_events = synced_df.groupby(["frame_id", "event_name"])["x1"].apply(lambda x: x.notna().any()) + matched = matched_events.sum() + unmatched = total - matched + + by_event = events_df["event_name"].value_counts().to_dict() + + print("\n====== Annotation Sync Summary ======") + print(f"Events total : {total}") + print(f"Matched (with boxes) : {matched}") + print(f"Unmatched : {unmatched}") + + # save unmatched list + unmatched_df = synced_df[synced_df["x1"].isna()][["frame_id","event_name"]] + unmatched_df.to_csv(Path(report_dir)/"unmatched_events.csv", index=False) + + # save summary json + report_json = { + "events_total": total, + "matched": int(matched), + "unmatched": int(unmatched), + "by_event": by_event + } + with open(Path(report_dir)/"sync_summary.json", "w") as f: + json.dump(report_json, f, indent=2) + + print(f"\nšŸ“„ Reports saved in {report_dir}") + +# ------------------------- +# Save outputs +# ------------------------- +def save_outputs(synced_df, args): + out_path = Path(args.out) + if args.mode == "event": + synced_df = synced_df[synced_df["event_name"] != "none"].copy() + out_path = out_path.with_name("synced_annotations_event.csv") + else: + out_path = out_path.with_name("synced_annotations_full.csv") + + synced_df.to_csv(out_path, index=False) + print(f"\nāœ… Synced annotations saved → {out_path}") + +# ------------------------- +# Main +# ------------------------- +def main(): + args = parse_args() + events_df, track_df = load_data(args.events, args.track) + synced_df = sync(events_df, track_df, frame_window=args.frame_window) + summarize(events_df, synced_df, args.report_dir) + save_outputs(synced_df, args) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/event_timing_analysis.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/event_timing_analysis.py new file mode 100644 index 000000000..174db60bd --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/event_timing_analysis.py @@ -0,0 +1,83 @@ +""" +Event Timing Analysis (Cumulative Only) +--------------------------------------- +This script generates a single timeline graph showing the **cumulative count of AFL events** +across the duration of a clip. It helps visualize when events (kick, mark, tackle) occur +relative to the clip length. + +Inputs: + --events : Path to synced annotations CSV + --out-dir: Directory to save results + --fps : Frames per second of the video (used to convert frames → seconds) + +Outputs (saved in --out-dir): + - cumulative_timeline.csv : Table of cumulative event counts over time + - cumulative_timeline.png : Line graph showing cumulative counts +""" + +import argparse +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from pathlib import Path + +def load_data(path: Path, fps: float) -> pd.DataFrame: + df = pd.read_csv(path) + + # drop invalid/blank events + df = df[df["event_name"].notna()] + df = df[df["event_name"].str.lower() != "none"] + + # create time_s if missing + if "time_s" not in df.columns: + df["time_s"] = df["frame_id"] / fps + + return df + +def cumulative_counts(df: pd.DataFrame, fps: float, clip_len: float): + times = np.linspace(0, clip_len, int(clip_len * fps) + 1) + cum = pd.DataFrame(index=times) + for ev, g in df.groupby("event_name"): + y, _ = np.histogram(g["time_s"], bins=times) + y_cum = np.concatenate([[0], y.cumsum()])[: len(times)] + cum[ev] = y_cum + return cum + +def plot_cumulative(cum: pd.DataFrame, out_dir: Path): + plt.figure(figsize=(10,6)) + for col in cum.columns: + plt.plot(cum.index, cum[col], label=col) + plt.xlabel("Time (s)") + plt.ylabel("Cumulative Events") + plt.title("Cumulative Event Timeline") + plt.legend() + plt.tight_layout() + plt.savefig(out_dir / "cumulative_timeline.png") + plt.close() + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--events", required=True, type=Path, help="Synced annotations CSV") + ap.add_argument("--out-dir", required=True, type=Path, help="Output directory") + ap.add_argument("--fps", required=True, type=float, help="Frames per second") + args = ap.parse_args() + + df = load_data(args.events, args.fps) + + # detect clip length + max_frame = df["frame_id"].max() + clip_len = max_frame / args.fps + print(f"Detected clip length: {clip_len:.2f} sec") + + # compute cumulative timeline + cum = cumulative_counts(df, args.fps, clip_len) + + # save results + args.out_dir.mkdir(parents=True, exist_ok=True) + cum.to_csv(args.out_dir / "cumulative_timeline.csv") + plot_cumulative(cum, args.out_dir) + + print(f"Saved cumulative timeline → {args.out_dir}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/merge_synced.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/merge_synced.py new file mode 100644 index 000000000..e523199a5 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/merge_synced.py @@ -0,0 +1,82 @@ +# === merge_synced.py === +# ============================================================================= +# HOW TO RUN MERGE SYNCED DATASETS +# +# Example: combining synced annotations for kick, mark, and tackle +# +# 1. Ensure you have: +# - kick_synced_annotations.csv +# - mark_synced_annotations.csv +# - tackle_synced_annotations.csv +# +# 2. Run in terminal from project root: +# python merge_synced.py \ +# --inputs data/kick/kick_synced_annotations.csv \ +# data/mark/mark_synced_annotations.csv \ +# data/tackle/tackle_synced_annotations.csv \ +# --out merged_dataset.csv +# +# The script will produce: +# - merged_dataset.csv (with a source_file column tagging the origin) +# ============================================================================= + +import argparse +import pandas as pd +import os + +# ---- ARGUMENT PARSING ---- +def parse_args(): + ap = argparse.ArgumentParser( + description="Merge multiple synced annotation CSVs into one dataset." + ) + ap.add_argument("--inputs", nargs="+", required=True, + help="List of synced annotation CSVs (kick, mark, tackle).") + ap.add_argument("--out", required=True, + help="Output merged CSV path.") + return ap.parse_args() + +# ---- MAIN ---- +def main(): + args = parse_args() + + dfs = [] + for path in args.inputs: + if not os.path.exists(path): + print(f"Missing file: {path}") + continue + df = pd.read_csv(path) + df["source_file"] = os.path.basename(path) # tag origin of each row + dfs.append(df) + + if not dfs: + print("āš ļø No input files found. Exiting.") + return + + # Concatenate inputs + merged = pd.concat(dfs, ignore_index=True) + + # Ensure consistent columns + keep_cols = [ + "event_name","frame_id","player_id","timestamp_s", + "x1","y1","x2","y2","cx","cy","w","h", + "confidence","frame_id_matched","source_file" + ] + for c in keep_cols: + if c not in merged.columns: + merged[c] = pd.NA + merged = merged[keep_cols] + + # Sort by frame/player/event + merged = merged.sort_values( + ["frame_id","player_id","event_name"] + ).reset_index(drop=True) + + # Save merged dataset + os.makedirs(os.path.dirname(args.out), exist_ok=True) + merged.to_csv(args.out, index=False) + + print(f"Merged dataset saved → {args.out}") + print(merged.head(10)) + +if __name__ == "__main__": + main() diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/prediction_vs_truth.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/prediction_vs_truth.py new file mode 100644 index 000000000..ba0883928 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/player_tracking_logic/prediction_vs_truth.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +prediction_vs_truth.py + +Compares predicted (tracking) vs truth (event annotations) +from a synced dataset. Outputs precision/recall/F1 per event. +""" + +import argparse +import pandas as pd + +def parse_args(): + ap = argparse.ArgumentParser(description="Compare predicted vs ground truth events") + ap.add_argument("--in", dest="inp", required=True, + help="Synced annotations CSV (from annotation_sync)") + ap.add_argument("--out", required=True, + help="Output CSV path for metrics") + return ap.parse_args() + +def compute_metrics(merged): + # šŸ”¹ Normalize event labels + merged["event_name"] = merged["event_name"].astype(str).str.lower() + merged.loc[merged["event_name"].isin(["none", "nan", "null"]), "event_name"] = pd.NA + + # Ground truth: frames that actually have an event + truth = merged[merged["event_name"].notna()].copy() + + # Predictions: everything (tracking rows exist regardless of event) + predicted = merged.copy() + + results = [] + + for event in truth["event_name"].dropna().unique(): + tp = ((predicted["event_name"] == event)).sum() + fn = ((truth["event_name"] == event)).sum() - tp + fp = 0 # since we're aligning per-frame matches + + precision = tp / (tp + fp) if (tp + fp) > 0 else 0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0 + f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) > 0 else 0 + + results.append({ + "event": event, + "TP": tp, "FP": fp, "FN": fn, + "Precision": round(precision, 3), + "Recall": round(recall, 3), + "F1": round(f1, 3), + }) + + # Handle "no_event" + total_rows = len(predicted) + truth_rows = len(truth) + no_event_tp = (predicted["event_name"].isna()).sum() + results.append({ + "event": "(no_event)", + "TP": no_event_tp, "FP": 0, "FN": total_rows - truth_rows - no_event_tp, + "Precision": 1 if no_event_tp > 0 else 0, + "Recall": 1 if no_event_tp > 0 else 0, + "F1": 1 if no_event_tp > 0 else 0, + }) + + return pd.DataFrame(results) + +def main(): + args = parse_args() + merged = pd.read_csv(args.inp) + + metrics = compute_metrics(merged) + metrics.to_csv(args.out, index=False) + + print(metrics) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/prediction_vs_truth.py b/prediction_vs_truth.py index ba0883928..7c68dcfd1 100644 --- a/prediction_vs_truth.py +++ b/prediction_vs_truth.py @@ -17,23 +17,26 @@ def parse_args(): help="Output CSV path for metrics") return ap.parse_args() -def compute_metrics(merged): - # šŸ”¹ Normalize event labels - merged["event_name"] = merged["event_name"].astype(str).str.lower() - merged.loc[merged["event_name"].isin(["none", "nan", "null"]), "event_name"] = pd.NA +def compute_metrics(df): + # šŸ”¹ Ensure columns exist + if "truth_event" not in df.columns and "event_name" in df.columns: + df = df.rename(columns={"event_name": "truth_event"}) + if "pred_event" not in df.columns: + # For now, simulate predictions = truth (so script runs) + df["pred_event"] = df["truth_event"] - # Ground truth: frames that actually have an event - truth = merged[merged["event_name"].notna()].copy() + # Normalize labels + for col in ["truth_event", "pred_event"]: + df[col] = df[col].astype(str).str.lower().replace(["none", "nan", "null"], pd.NA) - # Predictions: everything (tracking rows exist regardless of event) - predicted = merged.copy() + # Get all unique events across truth + predictions + events = set(df["truth_event"].dropna().unique()) | set(df["pred_event"].dropna().unique()) results = [] - - for event in truth["event_name"].dropna().unique(): - tp = ((predicted["event_name"] == event)).sum() - fn = ((truth["event_name"] == event)).sum() - tp - fp = 0 # since we're aligning per-frame matches + for event in events: + tp = ((df["truth_event"] == event) & (df["pred_event"] == event)).sum() + fp = ((df["truth_event"] != event) & (df["pred_event"] == event)).sum() + fn = ((df["truth_event"] == event) & (df["pred_event"] != event)).sum() precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 @@ -47,27 +50,14 @@ def compute_metrics(merged): "F1": round(f1, 3), }) - # Handle "no_event" - total_rows = len(predicted) - truth_rows = len(truth) - no_event_tp = (predicted["event_name"].isna()).sum() - results.append({ - "event": "(no_event)", - "TP": no_event_tp, "FP": 0, "FN": total_rows - truth_rows - no_event_tp, - "Precision": 1 if no_event_tp > 0 else 0, - "Recall": 1 if no_event_tp > 0 else 0, - "F1": 1 if no_event_tp > 0 else 0, - }) - return pd.DataFrame(results) def main(): args = parse_args() - merged = pd.read_csv(args.inp) + df = pd.read_csv(args.inp) - metrics = compute_metrics(merged) + metrics = compute_metrics(df) metrics.to_csv(args.out, index=False) - print(metrics) if __name__ == "__main__":