diff --git a/.DS_Store b/.DS_Store
index f462cab65..b750677fc 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/Dockerfile b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/Dockerfile
index e324ec41a..d326b853c 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/Dockerfile
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/Dockerfile
@@ -14,7 +14,8 @@ RUN apt-get update && apt-get install -y \
COPY Crowd_Monitoring/2026_T1/requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu && \
+ pip install --no-cache-dir -r requirements.txt
COPY Crowd_Monitoring/2026_T1/ .
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_2026_risk_analyzer.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_2026_risk_analyzer.py
new file mode 100644
index 000000000..f78b5c01c
--- /dev/null
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_2026_risk_analyzer.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""AFL 2026 Season Fixture Risk Analyzer - Multi-Team Support"""
+
+import json
+import csv
+from afl_teams_config import *
+
+# Sample 2026 fixtures for the 10 teams (Rounds 1-24)
+# Format: (round, home_team, away_team, time_slot, is_public_holiday)
+FIXTURES_2026 = [
+ # Round 1
+ (1, "Collingwood", "Sydney", "Friday Night", False),
+ (1, "Essendon", "Carlton", "Saturday Night", False),
+ (1, "Richmond", "Geelong", "Saturday Afternoon", False),
+ (1, "Melbourne", "Western Bulldogs", "Sunday Afternoon", False),
+ (1, "Brisbane", "Hawthorn", "Saturday Night", False),
+
+ # Round 2
+ (2, "Carlton", "Richmond", "Thursday Night", False),
+ (2, "Collingwood", "Essendon", "Friday Night", False), # Early season rivalry
+ (2, "Geelong", "Melbourne", "Saturday Afternoon", False),
+ (2, "Sydney", "Brisbane", "Saturday Night", False),
+ (2, "Western Bulldogs", "Hawthorn", "Sunday Afternoon", False),
+
+ # Round 3
+ (3, "Essendon", "Collingwood", "Friday Night", False),
+ (3, "Richmond", "Carlton", "Saturday Night", False),
+ (3, "Hawthorn", "Geelong", "Saturday Afternoon", False),
+ (3, "Brisbane", "Melbourne", "Sunday Afternoon", False),
+ (3, "Sydney", "Western Bulldogs", "Saturday Night", False),
+
+ # Round 4 - Easter Monday
+ (4, "Geelong", "Hawthorn", "Monday Afternoon", True), # Easter Monday special match
+
+ # Round 5
+ (5, "Collingwood", "Richmond", "Friday Night", False),
+ (5, "Carlton", "Essendon", "Saturday Night", False),
+ (5, "Melbourne", "Sydney", "Saturday Afternoon", False),
+ (5, "Western Bulldogs", "Brisbane", "Sunday Afternoon", False),
+
+ # Round 6 - ANZAC Day
+ (6, "Collingwood", "Essendon", "Friday Night", True), # ANZAC Day special match
+
+ # Round 7
+ (7, "Richmond", "Collingwood", "Saturday Night", False),
+ (7, "Carlton", "Geelong", "Thursday Night", False),
+ (7, "Hawthorn", "Melbourne", "Saturday Afternoon", False),
+
+ # Round 8
+ (8, "Essendon", "Richmond", "Friday Night", False),
+ (8, "Collingwood", "Carlton", "Saturday Night", False),
+ (8, "Brisbane", "Sydney", "Saturday Night", False),
+
+ # Round 9
+ (9, "Geelong", "Collingwood", "Friday Night", False),
+ (9, "Hawthorn", "Essendon", "Saturday Afternoon", False),
+
+ # Round 10 - Dreamtime
+ (10, "Essendon", "Richmond", "Saturday Night", True), # Dreamtime special match
+
+ # Round 11
+ (11, "Carlton", "Collingwood", "Friday Night", False),
+ (11, "Sydney", "Geelong", "Saturday Night", False),
+
+ # Round 12 - Queen's Birthday
+ (12, "Melbourne", "Collingwood", "Monday Afternoon", True), # Queen's Birthday special match
+
+ # Rounds 13-24 (additional fixtures to complete season)
+ (13, "Richmond", "Essendon", "Saturday Night", False),
+ (14, "Collingwood", "Geelong", "Friday Night", False),
+ (15, "Carlton", "Hawthorn", "Sunday Afternoon", False),
+ (16, "Essendon", "Collingwood", "Friday Night", False),
+ (17, "Richmond", "Carlton", "Thursday Night", False),
+ (18, "Hawthorn", "Collingwood", "Saturday Afternoon", False),
+ (19, "Geelong", "Essendon", "Saturday Night", False),
+ (20, "Melbourne", "Richmond", "Sunday Afternoon", False),
+ (21, "Collingwood", "Carlton", "Friday Night", False),
+ (22, "Essendon", "Geelong", "Saturday Night", False),
+ (23, "Richmond", "Hawthorn", "Saturday Afternoon", False),
+ (24, "Collingwood", "Melbourne", "Friday Night", False),
+]
+
+def calculate_risk_score(round_num, home_team, away_team, time_slot, is_public_holiday):
+ """Calculate risk score (1-5) for a fixture"""
+ score = 1 # Base minimum score
+
+ # Time slot weight (Thursday/Friday night highest)
+ score += TIME_SLOT_WEIGHTS.get(time_slot, 0)
+
+ # Public holiday
+ if is_public_holiday:
+ score += 2
+
+ # Rivalry weight
+ score += get_rivalry_weight(home_team, away_team)
+
+ # Special match weight
+ special_weight, special_name = get_special_match_weight(home_team, away_team, round_num)
+ score += special_weight
+
+ # Away game (for crowd risk analysis - away fans increase risk)
+ # For neutral games, both teams' fans travel
+ score += 1 # Default away game risk
+
+ # Fan base risk (average of both teams)
+ fan_risk = (FAN_BASE_RISK.get(home_team, 1) + FAN_BASE_RISK.get(away_team, 1)) / 2
+ score += fan_risk
+
+ # Normalize to 1-5 scale
+ normalized = min(5, max(1, round(score / 3)))
+
+ return normalized
+
+def get_risk_level(score):
+ """Convert numeric score to risk level and emoji"""
+ if score >= 4:
+ return f"š“ HIGH ({score})"
+ elif score == 3:
+ return f"š” MEDIUM ({score})"
+ else:
+ return f"š¢ LOW ({score})"
+
+def analyze_all_fixtures():
+ """Analyze all fixtures and return results"""
+ results = []
+
+ for fixture in FIXTURES_2026:
+ round_num, home_team, away_team, time_slot, is_public_holiday = fixture
+
+ risk_score = calculate_risk_score(round_num, home_team, away_team, time_slot, is_public_holiday)
+
+ # Get special match name if applicable
+ _, special_name = get_special_match_weight(home_team, away_team, round_num)
+
+ results.append({
+ "round": round_num,
+ "home_team": home_team,
+ "away_team": away_team,
+ "time_slot": time_slot,
+ "is_public_holiday": is_public_holiday,
+ "special_match": special_name,
+ "risk_score": risk_score,
+ "risk_level": get_risk_level(risk_score)
+ })
+
+ return results
+
+def print_summary(results):
+ """Print console summary of risk analysis"""
+ print("\n" + "="*80)
+ print("š AFL 2026 SEASON FIXTURE RISK ANALYSIS")
+ print("="*80)
+
+ # Sort by risk score (highest first)
+ sorted_results = sorted(results, key=lambda x: x["risk_score"], reverse=True)
+
+ print("\nš“ HIGHEST RISK FIXTURES (Top 10):")
+ print("-"*80)
+ for i, fixture in enumerate(sorted_results[:10], 1):
+ special = f" - {fixture['special_match']}" if fixture['special_match'] else ""
+ print(f"{i:2}. R{fixture['round']}: {fixture['home_team']} vs {fixture['away_team']} ({fixture['time_slot']}){special} ā Score: {fixture['risk_score']}/5")
+
+ # Team risk summary
+ print("\nš TEAM RISK SUMMARY:")
+ print("-"*80)
+
+ team_high_risk = {}
+ for fixture in results:
+ if fixture["risk_score"] >= 4:
+ for team in [fixture["home_team"], fixture["away_team"]]:
+ team_high_risk[team] = team_high_risk.get(team, 0) + 1
+
+ sorted_teams = sorted(team_high_risk.items(), key=lambda x: x[1], reverse=True)
+ for team, count in sorted_teams[:10]:
+ bar = "ā" * min(count, 10)
+ print(f" {team:20} : {count} high risk games {bar}")
+
+ # Overall statistics
+ high_risk = [f for f in results if f["risk_score"] >= 4]
+ medium_risk = [f for f in results if f["risk_score"] == 3]
+ low_risk = [f for f in results if f["risk_score"] <= 2]
+
+ print(f"\nš OVERALL STATISTICS:")
+ print(f" Total fixtures analyzed: {len(results)}")
+ print(f" š“ High risk (4-5): {len(high_risk)}")
+ print(f" š” Medium risk (3): {len(medium_risk)}")
+ print(f" š¢ Low risk (1-2): {len(low_risk)}")
+ print(f" š Average risk score: {sum(f['risk_score'] for f in results)/len(results):.2f}/5.0")
+
+ print("\nš¾ Output saved to:")
+ print(" - afl_2026_risk_matrix.json")
+ print(" - afl_2026_risk_matrix.csv")
+
+def save_to_json(results, filename="afl_2026_risk_matrix.json"):
+ """Save results to JSON file"""
+ output = {
+ "season": 2026,
+ "total_fixtures": len(results),
+ "fixtures": results,
+ "summary": {
+ "high_risk": len([f for f in results if f["risk_score"] >= 4]),
+ "medium_risk": len([f for f in results if f["risk_score"] == 3]),
+ "low_risk": len([f for f in results if f["risk_score"] <= 2]),
+ "average_risk": sum(f["risk_score"] for f in results) / len(results)
+ }
+ }
+
+ with open(filename, "w") as f:
+ json.dump(output, f, indent=2)
+ print(f" - {filename}")
+
+def save_to_csv(results, filename="afl_2026_risk_matrix.csv"):
+ """Save results to CSV file"""
+ with open(filename, "w", newline="") as f:
+ writer = csv.DictWriter(f, fieldnames=["round", "home_team", "away_team", "time_slot", "special_match", "risk_score", "risk_level"])
+ writer.writeheader()
+ for fixture in results:
+ writer.writerow({
+ "round": fixture["round"],
+ "home_team": fixture["home_team"],
+ "away_team": fixture["away_team"],
+ "time_slot": fixture["time_slot"],
+ "special_match": fixture["special_match"] or "",
+ "risk_score": fixture["risk_score"],
+ "risk_level": fixture["risk_level"]
+ })
+ print(f" - {filename}")
+
+if __name__ == "__main__":
+ results = analyze_all_fixtures()
+ print_summary(results)
+ save_to_json(results)
+ save_to_csv(results)
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_teams_config.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_teams_config.py
new file mode 100644
index 000000000..f5dfeebef
--- /dev/null
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/afl_teams_config.py
@@ -0,0 +1,74 @@
+"""AFL Teams Configuration for Risk Matrix - Sprint 4"""
+
+# Top 10 teams for Sprint 4
+TEAMS = [
+ "Collingwood",
+ "Essendon",
+ "Carlton",
+ "Richmond",
+ "Geelong",
+ "Hawthorn",
+ "Melbourne",
+ "Western Bulldogs",
+ "Sydney",
+ "Brisbane"
+]
+
+# Fan base risk (size and intensity) - 1 to 3 scale
+FAN_BASE_RISK = {
+ "Collingwood": 3,
+ "Essendon": 2,
+ "Carlton": 2,
+ "Richmond": 2,
+ "Geelong": 2,
+ "Hawthorn": 2,
+ "Melbourne": 1,
+ "Western Bulldogs": 1,
+ "Sydney": 1,
+ "Brisbane": 1,
+}
+
+# Historical rivalries (significant high-risk matchups)
+HISTORICAL_RIVALRIES = [
+ ("Collingwood", "Essendon"),
+ ("Collingwood", "Carlton"),
+ ("Collingwood", "Richmond"),
+ ("Essendon", "Carlton"),
+ ("Essendon", "Richmond"),
+ ("Geelong", "Hawthorn"),
+ ("Geelong", "Collingwood"),
+ ("Carlton", "Richmond"),
+]
+
+# Special matches with extra weight
+SPECIAL_MATCHES = {
+ ("Collingwood", "Essendon"): {"name": "ANZAC Day", "weight": 4, "round": 6},
+ ("Essendon", "Richmond"): {"name": "Dreamtime at the 'G", "weight": 4, "round": 10},
+ ("Geelong", "Hawthorn"): {"name": "Easter Monday", "weight": 3, "round": 4},
+ ("Melbourne", "Collingwood"): {"name": "Queen's Birthday", "weight": 3, "round": 12},
+}
+
+# Time slot risk weights (Thursday/Friday night highest)
+TIME_SLOT_WEIGHTS = {
+ "Thursday Night": 2,
+ "Friday Night": 2,
+ "Saturday Night": 1,
+ "Saturday Afternoon": 0,
+ "Saturday Twilight": 0,
+ "Sunday Afternoon": 0,
+ "Sunday Twilight": 0,
+}
+
+def get_rivalry_weight(team1, team2):
+ """Get rivalry weight between two teams"""
+ if (team1, team2) in HISTORICAL_RIVALRIES or (team2, team1) in HISTORICAL_RIVALRIES:
+ return 2
+ return 0
+
+def get_special_match_weight(team1, team2, round_num):
+ """Get special match weight if applicable"""
+ # Check both orders
+ for (t1, t2), match_info in SPECIAL_MATCHES.items():
+ if ((team1 == t1 and team2 == t2) or (team1 == t2 and team2 == t1)) and match_info["round"] == round_num:
+ return match_info["weight"], match_info["name"]
+ return 0, None
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/event_detection.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/event_detection.py
index 7588439f0..c7677d221 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/event_detection.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/event_detection.py
@@ -22,9 +22,7 @@ def detect_behaviour_events(
if features["avg_density"] <= 0.20 and zones:
event_flags.append("crowd_dispersing")
- if anomaly_summary.get("running_track_ids"):
- event_flags.append("running_detection")
- elif tracking_summary.get("running_track_count", 0) > 0:
+ if tracking_summary.get("running_track_count", 0) > 0:
event_flags.append("running_detection")
if tracking_summary.get("walking_track_count", 0) > 0:
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/main.py
index 3ed49ba78..c985dcd81 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/main.py
@@ -46,13 +46,9 @@ def analyze_behaviour(input_data):
artifact_paths = []
if heatmap and heatmap.get("image_path"):
artifact_paths.append(heatmap["image_path"])
- merged_tracking_summary = dict(tracking_summary)
- merged_running_ids = set(tracking_summary.get("running_track_ids", []))
- merged_running_ids.update(anomaly_summary.get("running_track_ids", []))
- merged_tracking_summary["running_track_ids"] = sorted(merged_running_ids)
- merged_tracking_summary["running_track_count"] = len(merged_running_ids)
- frame_activity_series = build_frame_activity_series(frame_tracks, merged_tracking_summary)
- artifact_paths.extend(save_motion_annotations(frame_tracks, merged_tracking_summary, video_id))
+ # Keep displayed movement states aligned with tracker/pose output.
+ frame_activity_series = build_frame_activity_series(frame_tracks, tracking_summary)
+ artifact_paths.extend(save_motion_annotations(frame_tracks, tracking_summary, video_id))
vision_metrics = dict(vision_features)
vision_metrics["tracking"] = tracking_summary
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/tracking.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/tracking.py
index 4567a7ee5..66f098214 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/tracking.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/tracking.py
@@ -20,6 +20,17 @@ def _bbox_size(bbox):
return max(x2 - x1, 1), max(y2 - y1, 1)
+def _ground_anchor(bbox):
+ """Approximate where the person's feet touch the ground."""
+ x1, _, x2, y2 = bbox
+ return ((x1 + x2) / 2.0, float(y2))
+
+
+def _bbox_aspect_ratio(bbox):
+ width, height = _bbox_size(bbox)
+ return width / max(height, 1)
+
+
def _bbox_iou(box_a, box_b):
ax1, ay1, ax2, ay2 = box_a
bx1, by1, bx2, by2 = box_b
@@ -144,15 +155,18 @@ def track_people(frames, max_distance=80.0, min_iou=0.1, max_missed_time=3.0):
history = track_histories.get(track_id, [])
bbox_width, bbox_height = _bbox_size(bbox)
+ ground_anchor = _ground_anchor(bbox)
track_entry = {
"track_id": track_id,
"bbox": bbox,
"centroid": [round(centroid[0], 2), round(centroid[1], 2)],
+ "ground_anchor": [round(ground_anchor[0], 2), round(ground_anchor[1], 2)],
"speed": round(speed, 2),
"normalized_speed": round(normalized_speed, 4),
"direction": [direction[0], direction[1]],
"bbox_width": bbox_width,
"bbox_height": bbox_height,
+ "bbox_aspect_ratio": round(_bbox_aspect_ratio(bbox), 4),
"confidence": detection.get("confidence", 0.0),
}
tracked_detections.append(track_entry)
@@ -162,9 +176,11 @@ def track_people(frames, max_distance=80.0, min_iou=0.1, max_missed_time=3.0):
"frame_id": frame.get("frame_id"),
"timestamp": timestamp,
"centroid": track_entry["centroid"],
+ "ground_anchor": track_entry["ground_anchor"],
"speed": track_entry["speed"],
"normalized_speed": track_entry["normalized_speed"],
"bbox_height": bbox_height,
+ "bbox_aspect_ratio": track_entry["bbox_aspect_ratio"],
}
]
@@ -194,7 +210,7 @@ def summarise_tracks(
track_histories,
stationary_motion_threshold=0.06,
walking_motion_threshold=0.12,
- running_motion_threshold=0.9,
+ running_motion_threshold=1.05,
min_history_for_motion=3,
):
"""Build tracking summary for anomaly/event logic."""
@@ -211,51 +227,77 @@ def summarise_tracks(
max_normalized_speed = max(normalized_speeds, default=0.0)
avg_normalized_speed = sum(normalized_speeds) / len(normalized_speeds) if normalized_speeds else 0.0
heights = [entry.get("bbox_height", 1.0) for entry in history]
+ aspect_ratios = [entry.get("bbox_aspect_ratio", 0.0) for entry in history]
avg_height_history = max(sum(heights) / max(len(heights), 1), 1.0)
height_variation = (
max(abs(height - avg_height_history) for height in heights) / avg_height_history
if heights
else 0.0
)
+ avg_aspect_ratio = sum(aspect_ratios) / len(aspect_ratios) if aspect_ratios else 0.0
+ max_aspect_ratio = max(aspect_ratios, default=0.0)
first_centroid = history[0].get("centroid", [0.0, 0.0])
last_centroid = history[-1].get("centroid", [0.0, 0.0])
+ first_anchor = history[0].get("ground_anchor", first_centroid)
+ last_anchor = history[-1].get("ground_anchor", last_centroid)
displacement = _distance(first_centroid, last_centroid)
+ anchor_displacement = _distance(first_anchor, last_anchor)
avg_height = max(
sum(entry.get("bbox_height", 1.0) for entry in history) / max(len(history), 1),
1.0,
)
normalized_displacement = displacement / avg_height
+ normalized_anchor_displacement = anchor_displacement / avg_height
history_length = len(history)
has_motion_history = history_length >= min_history_for_motion
moving_steps = sum(1 for speed in normalized_speeds if speed >= 0.05)
sustained_motion_steps = sum(1 for speed in normalized_speeds if speed >= 0.08)
+ running_motion_steps = sum(1 for speed in normalized_speeds if speed >= 0.3)
+ anchor_motion_steps = 0
+ for previous, current in zip(history, history[1:]):
+ previous_anchor = previous.get("ground_anchor", previous.get("centroid", [0.0, 0.0]))
+ current_anchor = current.get("ground_anchor", current.get("centroid", [0.0, 0.0]))
+ step_height = max((previous.get("bbox_height", 1.0) + current.get("bbox_height", 1.0)) / 2.0, 1.0)
+ normalized_anchor_step = _distance(previous_anchor, current_anchor) / step_height
+ if normalized_anchor_step >= 0.045:
+ anchor_motion_steps += 1
direction_consistency = _direction_consistency(history)
+ stable_box_shape = avg_aspect_ratio <= 0.72 and max_aspect_ratio <= 0.9
is_running = (
- has_motion_history
- and avg_normalized_speed >= 0.55
+ history_length >= 5
+ and avg_normalized_speed >= 0.7
and max_normalized_speed >= running_motion_threshold
- and normalized_displacement >= 0.9
+ and normalized_displacement >= 1.15
+ and normalized_anchor_displacement >= 0.55
+ and running_motion_steps >= 3
+ and anchor_motion_steps >= 3
+ and direction_consistency >= 0.55
+ and stable_box_shape
)
sustained_walking_motion = (
- history_length >= 6
- and avg_normalized_speed >= 0.06
- and max_normalized_speed >= 0.14
- and normalized_displacement >= 0.45
- and height_variation <= 0.5
+ history_length >= 7
+ and avg_normalized_speed >= 0.09
+ and max_normalized_speed >= 0.18
+ and normalized_displacement >= 0.55
+ and normalized_anchor_displacement >= 0.28
+ and height_variation <= 0.42
)
clear_walking_motion = (
- avg_normalized_speed >= walking_motion_threshold
- and max_normalized_speed >= 0.16
- and normalized_displacement >= 0.22
- and height_variation <= 0.45
+ avg_normalized_speed >= max(walking_motion_threshold, 0.15)
+ and max_normalized_speed >= 0.22
+ and normalized_displacement >= 0.3
+ and normalized_anchor_displacement >= 0.18
+ and height_variation <= 0.4
)
is_walking = (
has_motion_history
and not is_running
and (clear_walking_motion or sustained_walking_motion)
- and moving_steps >= 3
- and sustained_motion_steps >= 2
- and direction_consistency >= 0.35
+ and moving_steps >= 4
+ and sustained_motion_steps >= 3
+ and anchor_motion_steps >= 3
+ and direction_consistency >= 0.5
+ and stable_box_shape
and max_normalized_speed < running_motion_threshold + 0.55
)
is_stationary = (
@@ -264,6 +306,7 @@ def summarise_tracks(
avg_normalized_speed <= stationary_motion_threshold
and max_normalized_speed <= 0.12
and normalized_displacement <= 0.18
+ and normalized_anchor_displacement <= 0.1
)
)
@@ -289,7 +332,10 @@ def summarise_tracks(
"avg_normalized_speed": round(avg_normalized_speed, 4),
"max_normalized_speed": round(max_normalized_speed, 4),
"normalized_displacement": round(normalized_displacement, 4),
+ "normalized_anchor_displacement": round(normalized_anchor_displacement, 4),
"height_variation": round(height_variation, 4),
+ "avg_aspect_ratio": round(avg_aspect_ratio, 4),
+ "max_aspect_ratio": round(max_aspect_ratio, 4),
"direction_consistency": round(direction_consistency, 4),
"is_stationary": is_stationary,
"is_walking": is_walking,
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/README.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/README.md
index 9fc813ea4..c05381c4d 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/README.md
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/README.md
@@ -2,74 +2,25 @@
## Objective
-Detect spectators in stadium footage using a person-level detection pipeline.
+Detect spectators in stadium footage using a YOLOv8-based crowd detection pipeline.
+The module detects both faces and people from extracted video frames, saves annotated outputs, and returns structured results for later density and zone analysis.
-## Recommended Structure
+## Current Implementation
-```text
-crowd_detection/
-|- README.md
-|- main.py
-`- output/
-```
-
-### Why This Structure
-
-- `main.py` keeps the task simple and easy to understand
-- `output/` stores detections, sample visuals, or exported results
-- add `utils.py` only if `main.py` becomes messy or too long
-
-## Correct Approach
-
-- keep the real implementation for this task inside this folder
-- write the main detection logic in `main.py`
-- save generated files inside `output/`
-- if helper code starts repeating, then create `utils.py`
-- the shared service layer can later call functions from this task folder
-
-## When To Add `utils.py`
-
-Create `utils.py` only when:
-
-- `main.py` becomes hard to read
-- helper functions are repeated
-- you want to separate small reusable logic such as model loading or output formatting
-
-Do not create extra files too early. Start simple, then split only when needed.
+The current implementation uses two YOLO models:
-## Scope
+- Face detection model: `face_model.pt`
+- People detection model: `yolov8n_crowdhuman.pt`
-- Run person detection on frames or video
-- Focus on detecting spectators rather than general scene objects
-- Compare results against the previous segmentation-based approach where useful
-- Prepare detection outputs for density and zone analysis
+The people model is used for person-level crowd detection, while the face model is kept to support face-based crowd analysis where needed. The module loads both models and runs detection on each frame. :contentReference[oaicite:0]{index=0}
-## Inputs
+## Project Structure
-- Extracted video frames
-- Sample stadium footage
-
-## Outputs
-
-- Bounding boxes for detected people
-- Confidence scores
-- Structured detection results for later tasks
-
-## Implementation Notes
-
-- Use YOLOv8 through Ultralytics
-- Start with the `person` class only
-- Save outputs in a simple format such as JSON or CSV
-- Record known failure cases such as occlusion, distance, and low lighting
-- Keep the task folder minimal at the start
-- Store reusable helpers in `shared/` only if they are needed by multiple folders
-
-## Suggested Deliverables
-
-- A simple `main.py` script for person detection
-
-## Suggested Deliverables
-
-- A detection script or notebook
-- Sample visual output with boxes drawn on frames
-- A saved set of person coordinates or detections
+```text
+crowd_detection/
+|- README.md
+|- config.py
+|- main.py
+|- face_model.pt
+|- yolov8n_crowdhuman.pt
+```
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/SCHEMA.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/SCHEMA.md
index 73623ce6f..eef11b152 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/SCHEMA.md
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/SCHEMA.md
@@ -9,13 +9,7 @@ This task receives processed frame data and returns per-frame person detections.
```json
{
"video_id": "match_01",
- "frames": [
- {
- "frame_id": 1,
- "timestamp": 0.04,
- "frame_path": "output/frame_0001.jpg"
- }
- ]
+ "video_path": "data/raw/match_01.mp4"
}
```
@@ -28,8 +22,18 @@ This task receives processed frame data and returns per-frame person detections.
{
"frame_id": 1,
"timestamp": 0.04,
+ "frame_path": "video_processing/data/extracted_frames/frame_0001.jpg",
+ "face_annotated_frame_path": "crowd_detection_output/face_detection_results/frame_0001.jpg",
+ "people_annotated_frame_path": "crowd_detection_output/people_detection_results/frame_0001.jpg",
"person_count": 2,
- "detections": [
+ "face_count": 1,
+ "face_detections": [
+ {
+ "bbox": [110, 60, 145, 100],
+ "confidence": 0.88
+ }
+ ],
+ "people_detections": [
{
"bbox": [100, 50, 160, 180],
"confidence": 0.93
@@ -47,4 +51,3 @@ This task receives processed frame data and returns per-frame person detections.
## Notes
- output of this task becomes input to `density_zoning`
-- this output must also match the detection service schema
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/main.py
index 748ff1590..d4f369ac3 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/main.py
@@ -11,6 +11,11 @@
FACE_OUTPUT_DIR = ANNOTATED_DIR if ANNOTATED_DIR.is_absolute() else PROJECT_ROOT / ANNOTATED_DIR
PEOPLE_OUTPUT_DIR = PEOPLE_ANNOTATED_DIR if PEOPLE_ANNOTATED_DIR.is_absolute() else PROJECT_ROOT / PEOPLE_ANNOTATED_DIR
+
+def _safe_video_id(video_id):
+ value = str(video_id or "unknown_video")
+ return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value)
+
def load_models():
print(f"[INFO] Loading face model: {MODEL_NAME}")
face_model = YOLO(MODEL_NAME)
@@ -95,10 +100,14 @@ def draw_boxes(frame, detections):
def detect_crowd(processed_video: dict) -> dict:
face_model, people_model = load_models()
all_results = []
+ safe_video_id = _safe_video_id(processed_video.get("video_id"))
+ people_video_output_dir = PEOPLE_OUTPUT_DIR / safe_video_id
+ frame_width = int(processed_video.get("frame_width") or 0)
+ frame_height = int(processed_video.get("frame_height") or 0)
# create output folder
FACE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- PEOPLE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ people_video_output_dir.mkdir(parents=True, exist_ok=True)
for frame_data in processed_video["frames"]:
frame_path = frame_data["frame_path"]
@@ -112,6 +121,9 @@ def detect_crowd(processed_video: dict) -> dict:
print(f"[WARN] Could not read frame {frame_data['frame_id']} ā skipping")
continue
+ if frame_width <= 0 or frame_height <= 0:
+ frame_height, frame_width = frame.shape[:2]
+
face_detections = detect_faces(face_model, frame, DEFAULT_CONF, DEFAULT_IOU)
people_detections = detect_people(people_model, frame, DEFAULT_CONF, DEFAULT_IOU)
@@ -122,7 +134,7 @@ def detect_crowd(processed_video: dict) -> dict:
# save annotated frame for people
people_annotated = draw_people_boxes(frame, people_detections)
- people_output_path = PEOPLE_OUTPUT_DIR / f"frame_{frame_data['frame_id']:04d}.jpg"
+ people_output_path = people_video_output_dir / f"frame_{frame_data['frame_id']:04d}.jpg"
cv2.imwrite(str(people_output_path), people_annotated)
face_annotated_frame_path = str(face_output_path.relative_to(PROJECT_ROOT)).replace("\\", "/")
people_annotated_frame_path = str(people_output_path.relative_to(PROJECT_ROOT)).replace("\\", "/")
@@ -141,6 +153,8 @@ def detect_crowd(processed_video: dict) -> dict:
return {
"video_id": processed_video["video_id"],
+ "frame_width": frame_width,
+ "frame_height": frame_height,
"frames": all_results,
}
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/density_zoning/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/density_zoning/main.py
index a1d81c9c7..717f649b4 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/density_zoning/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/density_zoning/main.py
@@ -1,5 +1,4 @@
-
from __future__ import annotations
import json
@@ -7,226 +6,283 @@
from typing import Any
+DENSITY_PRECISION = 2
+
+
def get_zone_definitions(
frame_width: int,
frame_height: int,
rows: int = 2,
cols: int = 2,
-) -> list[dict[str, Any]]:
- """
- Create a configurable grid of zones.
-
- Example for 2x2:
- A1 A2
- B1 B2
- """
+) -> list[dict[str, float | str]]:
+ """Create grid-based zone definitions."""
if frame_width <= 0 or frame_height <= 0:
- raise ValueError("Frame width and height must be positive integers.")
+ raise ValueError("Frame width and height must be positive.")
if rows <= 0 or cols <= 0:
- raise ValueError("Rows and cols must be positive integers.")
+ raise ValueError("Rows and columns must be positive.")
zone_width = frame_width / cols
zone_height = frame_height / rows
- zones: list[dict[str, Any]] = []
+ zones = []
for row in range(rows):
for col in range(cols):
- row_label = chr(ord("A") + row)
- zone_id = f"{row_label}{col + 1}"
-
- x_min = col * zone_width
- y_min = row * zone_height
- x_max = (col + 1) * zone_width
- y_max = (row + 1) * zone_height
-
+ zone_id = f"{chr(ord('A') + row)}{col + 1}"
zones.append(
{
"zone_id": zone_id,
- "x_min": x_min,
- "y_min": y_min,
- "x_max": x_max,
- "y_max": y_max,
+ "x_min": col * zone_width,
+ "y_min": row * zone_height,
+ "x_max": (col + 1) * zone_width,
+ "y_max": (row + 1) * zone_height,
}
)
return zones
-def is_valid_bbox(bbox: list[float] | None) -> bool:
- """Check whether a bounding box is valid."""
+def is_valid_bbox(
+ bbox: list[float] | None,
+ frame_width: int,
+ frame_height: int,
+) -> bool:
+ """Validate bounding box format and frame boundary."""
if bbox is None or len(bbox) != 4:
return False
x1, y1, x2, y2 = bbox
- return x2 > x1 and y2 > y1
+ return (
+ x2 > x1
+ and y2 > y1
+ and 0 <= x1 < frame_width
+ and 0 <= y1 < frame_height
+ and 0 < x2 <= frame_width
+ and 0 < y2 <= frame_height
+ )
-def bbox_center(bbox: list[float]) -> tuple[float, float]:
- """
- Calculate the center point of a bounding box.
-
- bbox format: [x1, y1, x2, y2]
- """
- if not is_valid_bbox(bbox):
- raise ValueError("Invalid bounding box. Expected [x1, y1, x2, y2] with x2 > x1 and y2 > y1.")
+def bbox_center(bbox: list[float]) -> tuple[float, float]:
+ """Return the centre point of a bounding box."""
x1, y1, x2, y2 = bbox
- center_x = (x1 + x2) / 2
- center_y = (y1 + y2) / 2
- return center_x, center_y
-
-
-def clamp_point(x: float, y: float, frame_width: int, frame_height: int) -> tuple[float, float]:
- """
- Clamp a point so it stays inside the frame.
- Helps handle edge cases near frame boundaries.
- """
- x = min(max(x, 0), frame_width - 1e-6)
- y = min(max(y, 0), frame_height - 1e-6)
- return x, y
+ return (x1 + x2) / 2, (y1 + y2) / 2
def find_zone(
center_x: float,
center_y: float,
zones: list[dict[str, Any]],
- frame_width: int,
- frame_height: int,
) -> str | None:
- """Return the zone_id for a center point."""
- center_x, center_y = clamp_point(center_x, center_y, frame_width, frame_height)
-
+ """Find the zone that contains the centre point."""
for zone in zones:
if (
zone["x_min"] <= center_x < zone["x_max"]
and zone["y_min"] <= center_y < zone["y_max"]
):
- return zone["zone_id"]
+ return str(zone["zone_id"])
return None
-def normalize_counts(zone_counts: dict[str, int]) -> dict[str, float]:
- """
- Normalize person counts to density values between 0.0 and 1.0.
- Highest count becomes 1.0.
- """
+def calculate_density(zone_counts: dict[str, int]) -> dict[str, float]:
+ """Normalize zone counts into density values between 0.0 and 1.0."""
max_count = max(zone_counts.values()) if zone_counts else 0
if max_count == 0:
return {zone_id: 0.0 for zone_id in zone_counts}
return {
- zone_id: round(count / max_count, 2)
+ zone_id: round(count / max_count, DENSITY_PRECISION)
+ for zone_id, count in zone_counts.items()
+ }
+
+
+def calculate_average_count(
+ zone_counts: dict[str, int],
+ total_frames: int,
+) -> dict[str, float]:
+ """Calculate average person count per zone per frame."""
+ if total_frames <= 0:
+ return {zone_id: 0.0 for zone_id in zone_counts}
+
+ return {
+ zone_id: round(count / total_frames, DENSITY_PRECISION)
for zone_id, count in zone_counts.items()
}
def classify_density(density: float) -> str:
- """Convert normalized density into a label."""
- if density == 0:
+ """Convert density value into a readable level."""
+ if density <= 0:
return "Low"
if density < 0.67:
return "Medium"
return "High"
+def build_heatmap_data(zones_output: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Create heatmap-ready output from zone density values."""
+ return [
+ {
+ "zone_id": zone["zone_id"],
+ "value": zone["density"],
+ "person_count": zone["person_count"],
+ "density_level": zone["density_level"],
+ }
+ for zone in zones_output
+ ]
+
+
+def validate_output(result: dict[str, Any]) -> None:
+ """Validate output against the density zoning schema."""
+ if "video_id" not in result:
+ raise ValueError("Output must contain video_id.")
+
+ if "zones" not in result or not isinstance(result["zones"], list):
+ raise ValueError("Output must contain a zones list.")
+
+ if "heatmap_data" not in result or not isinstance(result["heatmap_data"], list):
+ raise ValueError("Output must contain heatmap_data list.")
+
+ for zone in result["zones"]:
+ required_fields = [
+ "zone_id",
+ "person_count",
+ "density",
+ "average_count_per_frame",
+ "density_level",
+ ]
+
+ for field in required_fields:
+ if field not in zone:
+ raise ValueError(f"Missing required field in zone output: {field}")
+
+ if not isinstance(zone["zone_id"], str):
+ raise TypeError("zone_id must be a string.")
+
+ if not isinstance(zone["person_count"], int):
+ raise TypeError("person_count must be an integer.")
+
+ if not isinstance(zone["density"], (int, float)):
+ raise TypeError("density must be numeric.")
+
+ if not isinstance(zone["average_count_per_frame"], (int, float)):
+ raise TypeError("average_count_per_frame must be numeric.")
+
+ if not 0.0 <= zone["density"] <= 1.0:
+ raise ValueError("density must be between 0.0 and 1.0.")
+
+ if zone["density_level"] not in ["Low", "Medium", "High"]:
+ raise ValueError("density_level must be Low, Medium, or High.")
+
+ for item in result["heatmap_data"]:
+ required_fields = ["zone_id", "value", "person_count", "density_level"]
+
+ for field in required_fields:
+ if field not in item:
+ raise ValueError(f"Missing required field in heatmap_data: {field}")
+
+ if not 0.0 <= item["value"] <= 1.0:
+ raise ValueError("heatmap_data value must be between 0.0 and 1.0.")
+
+
def analyze_density(input_data: dict[str, Any]) -> dict[str, Any]:
- """Calculate zone counts and density values from detection results."""
+ """Calculate zone-level crowd counts and density values."""
video_id = input_data.get("video_id", "unknown_video")
frames = input_data.get("frames", [])
- frame_width = input_data.get("frame_width", 500)
- frame_height = input_data.get("frame_height", 500)
-
- grid_rows = input_data.get("grid_rows", 2)
- grid_cols = input_data.get("grid_cols", 2)
- confidence_threshold = input_data.get("confidence_threshold", 0.50)
+ frame_width = int(input_data.get("frame_width", 1280))
+ frame_height = int(input_data.get("frame_height", 720))
+ grid_rows = int(input_data.get("grid_rows", 2))
+ grid_cols = int(input_data.get("grid_cols", 2))
+ confidence_threshold = float(input_data.get("confidence_threshold", 0.5))
zones = get_zone_definitions(frame_width, frame_height, grid_rows, grid_cols)
-
- zone_counts = {zone["zone_id"]: 0 for zone in zones}
- skipped_invalid_bbox = 0
- skipped_low_confidence = 0
+ zone_counts = {str(zone["zone_id"]): 0 for zone in zones}
for frame in frames:
detections = frame.get("people_detections", [])
for detection in detections:
bbox = detection.get("bbox")
- confidence = detection.get("confidence", 1.0)
+ confidence = float(detection.get("confidence", 1.0))
if confidence < confidence_threshold:
- skipped_low_confidence += 1
continue
- if not is_valid_bbox(bbox):
- skipped_invalid_bbox += 1
+ if not is_valid_bbox(bbox, frame_width, frame_height):
continue
center_x, center_y = bbox_center(bbox)
- zone_id = find_zone(center_x, center_y, zones, frame_width, frame_height)
+ zone_id = find_zone(center_x, center_y, zones)
if zone_id is not None:
zone_counts[zone_id] += 1
- densities = normalize_counts(zone_counts)
+ densities = calculate_density(zone_counts)
+ averages = calculate_average_count(zone_counts, len(frames))
+
+ zones_output = [
+ {
+ "zone_id": zone_id,
+ "person_count": count,
+ "density": densities[zone_id],
+ "average_count_per_frame": averages[zone_id],
+ "density_level": classify_density(densities[zone_id]),
+ }
+ for zone_id, count in zone_counts.items()
+ ]
- return {
+ result = {
"video_id": video_id,
- "frame_width": frame_width,
- "frame_height": frame_height,
- "grid_rows": grid_rows,
- "grid_cols": grid_cols,
- "confidence_threshold": confidence_threshold,
- "summary": {
- "total_frames": len(frames),
- "skipped_invalid_bbox": skipped_invalid_bbox,
- "skipped_low_confidence": skipped_low_confidence,
- },
- "zones": [
- {
- "zone_id": zone["zone_id"],
- "person_count": zone_counts[zone["zone_id"]],
- "density": densities[zone["zone_id"]],
- "density_level": classify_density(densities[zone["zone_id"]]),
- }
- for zone in zones
- ],
+ "zones": zones_output,
+ "heatmap_data": build_heatmap_data(zones_output),
}
+ validate_output(result)
+ return result
+
if __name__ == "__main__":
sample_input = {
- "video_id": "crowd_video_test",
+ "video_id": "match_01",
"frame_width": 1280,
"frame_height": 720,
"grid_rows": 2,
"grid_cols": 2,
- "confidence_threshold": 0.50,
+ "confidence_threshold": 0.5,
"frames": [
{
"frame_id": 1,
- "timestamp": 0.03,
+ "timestamp": 0.04,
+ "person_count": 4,
"detections": [
{"bbox": [100, 200, 180, 350], "confidence": 0.92},
{"bbox": [300, 220, 380, 370], "confidence": 0.89},
{"bbox": [700, 250, 780, 400], "confidence": 0.95},
{"bbox": [900, 300, 980, 450], "confidence": 0.87},
- {"bbox": [600, 200, 600, 260], "confidence": 0.91},
- {"bbox": [500, 300, 560, 390], "confidence": 0.20},
],
- }
+ },
+ {
+ "frame_id": 2,
+ "timestamp": 0.08,
+ "person_count": 3,
+ "detections": [
+ {"bbox": [120, 240, 190, 360], "confidence": 0.91},
+ {"bbox": [720, 270, 800, 430], "confidence": 0.94},
+ {"bbox": [950, 360, 1040, 520], "confidence": 0.90},
+ ],
+ },
],
}
- result = analyze_density(sample_input)
+ output = analyze_density(sample_input)
os.makedirs("output", exist_ok=True)
- output_path = os.path.join("output", "density_summary_sprint2.json")
+ output_path = os.path.join("output", "density_summary.json")
with open(output_path, "w", encoding="utf-8") as file:
- json.dump(result, file, indent=2)
+ json.dump(output, file, indent=2)
- print(json.dumps(result, indent=2))
- print(f"\nSaved output to: {output_path}")
+ print(json.dumps(output, indent=2))
+ print(f"\nSaved output to: {output_path}")
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/generate_average_heatmap_data.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/generate_average_heatmap_data.py
new file mode 100644
index 000000000..9ac6eebe2
--- /dev/null
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/generate_average_heatmap_data.py
@@ -0,0 +1,131 @@
+import json
+import math
+import random
+from typing import Dict, List
+
+
+def zone_name(row_index: int, col_index: int) -> str:
+ """Convert row/col index into zone names like A1, B3, AA10."""
+ letters = ""
+ n = row_index
+ while True:
+ letters = chr(ord("A") + (n % 26)) + letters
+ n = n // 26 - 1
+ if n < 0:
+ break
+ return f"{letters}{col_index + 1}"
+
+
+def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
+ return max(low, min(high, value))
+
+
+def gaussian_hotspot(row: int, col: int, center_row: float, center_col: float, spread: float, amplitude: float) -> float:
+ """Create a hotspot effect for density."""
+ distance_sq = (row - center_row) ** 2 + (col - center_col) ** 2
+ return amplitude * math.exp(-distance_sq / (2 * spread ** 2))
+
+
+def generate_average_heatmap_data(
+ video_id: str = "match_avg_01",
+ rows: int = 10,
+ cols: int = 10,
+ num_frames: int = 30,
+ base_density: float = 0.10,
+ noise_level: float = 0.05,
+ hotspots: List[Dict] = None,
+ seed: int = 42
+) -> Dict:
+ """
+ Generate realistic zone data where person_count is the average count per zone across frames.
+ """
+ random.seed(seed)
+
+ if hotspots is None:
+ hotspots = [
+ {
+ "center_row": rows / 2,
+ "center_col": cols / 2,
+ "spread": max(rows, cols) / 5,
+ "amplitude": 0.75
+ }
+ ]
+
+ zones = []
+
+ for r in range(rows):
+ for c in range(cols):
+ base_zone_density = base_density
+
+ for hotspot in hotspots:
+ base_zone_density += gaussian_hotspot(
+ r,
+ c,
+ hotspot["center_row"],
+ hotspot["center_col"],
+ hotspot["spread"],
+ hotspot["amplitude"]
+ )
+
+ base_zone_density = clamp(base_zone_density)
+
+ frame_counts = []
+ for _ in range(num_frames):
+ noisy_density = clamp(base_zone_density + random.uniform(-noise_level, noise_level))
+ frame_count = max(0, round(noisy_density * 20 + random.uniform(-2, 2)))
+ frame_counts.append(frame_count)
+
+ avg_count = round(sum(frame_counts) / len(frame_counts))
+ final_density = round(sum(frame_counts) / (len(frame_counts) * 20), 2)
+ final_density = clamp(final_density)
+
+ zones.append(
+ {
+ "zone_id": zone_name(r, c),
+ "person_count": avg_count,
+ "density": round(final_density, 2)
+ }
+ )
+
+ return {
+ "video_id": video_id,
+ "zones": zones
+ }
+
+
+def save_json(data: Dict, filename: str) -> None:
+ with open(filename, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ print(f"Saved: {filename}")
+
+
+if __name__ == "__main__":
+ data_10x10 = generate_average_heatmap_data(
+ video_id="match_avg_10x10",
+ rows=10,
+ cols=10,
+ num_frames=40,
+ base_density=0.08,
+ noise_level=0.04,
+ hotspots=[
+ {"center_row": 4, "center_col": 4, "spread": 2.0, "amplitude": 0.65},
+ {"center_row": 7, "center_col": 7, "spread": 2.5, "amplitude": 0.55},
+ ],
+ seed=42
+ )
+ save_json(data_10x10, "heatmap_avg_10x10.json")
+
+ data_20x20 = generate_average_heatmap_data(
+ video_id="match_avg_20x20",
+ rows=20,
+ cols=20,
+ num_frames=50,
+ base_density=0.05,
+ noise_level=0.03,
+ hotspots=[
+ {"center_row": 6, "center_col": 8, "spread": 3.5, "amplitude": 0.60},
+ {"center_row": 14, "center_col": 12, "spread": 4.0, "amplitude": 0.75},
+ ],
+ seed=123
+ )
+ save_json(data_20x20, "heatmap_avg_20x20.json")
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/main.py
index c1212b341..58e9ba8fb 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/heatmap/main.py
@@ -1,135 +1,289 @@
-"""Minimal entry point for the heatmap task."""
+"""Heatmap task implementation with validation and stadium-style visualization."""
import json
+import math
import os
-from typing import Dict, List
+from typing import Dict, List, Tuple
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
+import matplotlib.patches as patches
import numpy as np
def validate_input(input_data: Dict) -> None:
- """Validate incoming heatmap input data."""
+ """Validate the input JSON structure."""
if not isinstance(input_data, dict):
- raise ValueError("Input data must be a dictionary.")
+ raise ValueError("Input must be a dictionary.")
- if "video_id" not in input_data or not input_data["video_id"]:
+ video_id = input_data.get("video_id")
+ if not video_id or not isinstance(video_id, str):
raise ValueError("Missing or empty 'video_id'.")
- if "zones" not in input_data:
- raise ValueError("Missing 'zones' field.")
+ zones = input_data.get("zones")
+ if not isinstance(zones, list) or len(zones) == 0:
+ raise ValueError("Missing or empty 'zones' list.")
- if not isinstance(input_data["zones"], list):
- raise ValueError("'zones' must be a list.")
+ for zone in zones:
+ if not isinstance(zone, dict):
+ raise ValueError("Each zone must be a dictionary.")
- if len(input_data["zones"]) == 0:
- raise ValueError("'zones' list cannot be empty.")
+ zone_id = zone.get("zone_id")
+ if not zone_id or not isinstance(zone_id, str):
+ raise ValueError(f"Each zone must have a valid 'zone_id'.")
- required_zone_fields = {"zone_id", "person_count", "density"}
+ person_count = zone.get("person_count")
+ if not isinstance(person_count, (int, float)):
+ raise ValueError(f"Person count for zone '{zone_id}' must be numeric.")
- for index, zone in enumerate(input_data["zones"]):
- if not isinstance(zone, dict):
- raise ValueError(f"Zone at index {index} must be a dictionary.")
+ density = zone.get("density")
+ if not isinstance(density, (int, float)):
+ raise ValueError(f"Density for zone '{zone_id}' must be numeric.")
+
+
+def zone_name(row_index: int, col_index: int) -> str:
+ """Convert row/col index into zone names like A1, B3, AA10."""
+ letters = ""
+ n = row_index
+ while True:
+ letters = chr(ord("A") + (n % 26)) + letters
+ n = n // 26 - 1
+ if n < 0:
+ break
+ return f"{letters}{col_index + 1}"
+
+
+def build_video_style_input(video_id: str = "match_02") -> Dict:
+ """
+ Create a higher-grid stadium-style layout that roughly matches the shared frame.
+ """
+ rows, cols = 8, 12
+ zones = []
+
+ for r in range(rows):
+ for c in range(cols):
+ density = 0.06
+
+ if r <= 1:
+ density += 0.02 + 0.01 * c / cols
+
+ if 2 <= r <= 4:
+ density += 0.10 + 0.08 * (c / cols)
- missing_fields = required_zone_fields - zone.keys()
- if missing_fields:
- raise ValueError(
- f"Zone at index {index} is missing fields: {', '.join(sorted(missing_fields))}"
+ if r >= 5:
+ density += 0.14 + 0.10 * (c / cols)
+
+ hotspot1 = math.exp(-(((r - 5.6) ** 2) / 2.8 + ((c - 7.0) ** 2) / 5.0))
+ density += 0.42 * hotspot1
+
+ hotspot2 = math.exp(-(((r - 3.8) ** 2) / 2.0 + ((c - 3.2) ** 2) / 3.5))
+ density += 0.18 * hotspot2
+
+ density = max(0.0, min(1.0, density))
+ person_count = int(round(density * 20))
+
+ zones.append(
+ {
+ "zone_id": zone_name(r, c),
+ "person_count": person_count,
+ "density": round(density, 2),
+ }
)
+ return {"video_id": video_id, "zones": zones}
+
+
+def compute_grid_shape(num_zones: int) -> Tuple[int, int]:
+ cols = max(8, int(math.ceil(math.sqrt(num_zones * 1.6))))
+ rows = int(math.ceil(num_zones / cols))
+ return rows, cols
+
def generate_heatmap(input_data: Dict) -> Dict:
- """Generate a validated and schema-compliant heatmap image from zone density data."""
+ """Generate a stadium-style heatmap image from zone density data."""
validate_input(input_data)
- video_id = str(input_data["video_id"])
+ video_id = input_data["video_id"]
zones: List[Dict] = input_data["zones"]
output_dir = "output"
os.makedirs(output_dir, exist_ok=True)
num_zones = len(zones)
- cols = int(np.ceil(np.sqrt(num_zones)))
- rows = int(np.ceil(num_zones / cols))
-
- heatmap_array = np.full((rows, cols), np.nan)
- labels = [["" for _ in range(cols)] for _ in range(rows)]
+ rows, cols = compute_grid_shape(num_zones)
+
+ fig = plt.figure(figsize=(14, 10), facecolor="#0b1220")
+ ax = plt.axes([0.03, 0.10, 0.94, 0.80])
+ ax.set_facecolor("#071224")
+ ax.set_xlim(-1.15, 1.15)
+ ax.set_ylim(-0.92, 0.92)
+ ax.axis("off")
+
+ outer = patches.Ellipse(
+ (0, 0),
+ width=1.85,
+ height=1.42,
+ facecolor="#0a1b34",
+ edgecolor="#183b69",
+ linewidth=2.5,
+ zorder=1,
+ )
+ ax.add_patch(outer)
+
+ pitch = patches.FancyBboxPatch(
+ (-0.42, -0.23),
+ 0.84,
+ 0.46,
+ boxstyle="round,pad=0.01,rounding_size=0.02",
+ facecolor="#1f8f43",
+ edgecolor="#2c3f66",
+ linewidth=6,
+ zorder=5,
+ )
+ ax.add_patch(pitch)
+
+ ax.plot([0, 0], [-0.21, 0.21], color="white", alpha=0.55, lw=1.6, zorder=6)
+ center_circle = patches.Circle((0, 0), 0.07, fill=False, ec="white", alpha=0.5, lw=1.3, zorder=6)
+ ax.add_patch(center_circle)
+ ax.plot(0, 0, "wo", ms=4, alpha=0.5, zorder=6)
+
+ for x0, sign in [(-0.42, 1), (0.33, -1)]:
+ ax.add_patch(
+ patches.Rectangle(
+ (x0, -0.09),
+ 0.09,
+ 0.18,
+ fill=False,
+ ec="white",
+ alpha=0.45,
+ lw=1.2,
+ zorder=6,
+ )
+ )
+ ax.add_patch(
+ patches.Rectangle(
+ (x0, -0.14),
+ 0.16 * sign,
+ 0.28,
+ fill=False,
+ ec="white",
+ alpha=0.45,
+ lw=1.2,
+ zorder=6,
+ )
+ )
- for index, zone in enumerate(zones):
- row = index // cols
- col = index % cols
+ ax.text(0, 0.80, "NORTH STAND", ha="center", va="center", fontsize=24, color="#7f94bd", alpha=0.95)
+ ax.text(0, -0.82, "SOUTH STAND", ha="center", va="center", fontsize=24, color="#7f94bd", alpha=0.95)
+ ax.text(-0.98, 0.00, "WEST", ha="center", va="center", fontsize=20, color="#7f94bd", alpha=0.95)
+ ax.text(0.98, 0.00, "EAST", ha="center", va="center", fontsize=20, color="#7f94bd", alpha=0.95)
- zone_id = str(zone["zone_id"])
+ a_outer, b_outer = 0.92, 0.70
+ a_inner, b_inner = 0.50, 0.33
+ cmap = plt.cm.turbo
- try:
- density = float(zone["density"])
- except (TypeError, ValueError):
- raise ValueError(f"Density for zone '{zone_id}' must be numeric.")
+ for i, zone in enumerate(zones):
+ r_idx = i // cols
+ c_idx = i % cols
+ density = float(zone["density"])
density = max(0.0, min(1.0, density))
- try:
- person_count = int(zone["person_count"])
- except (TypeError, ValueError):
- raise ValueError(f"Person count for zone '{zone_id}' must be an integer.")
+ t_r0 = r_idx / rows
+ t_r1 = (r_idx + 1) / rows
- heatmap_array[row, col] = density
- labels[row][col] = (
- f"{zone_id}\n"
- f"Count: {person_count}\n"
- f"Density: {density:.2f}"
- )
+ theta0 = math.pi - (c_idx / cols) * 2 * math.pi
+ theta1 = math.pi - ((c_idx + 1) / cols) * 2 * math.pi
+
+ n_theta = 18
+ n_rad = 3
+
+ xs = []
+ ys = []
- fig, ax = plt.subplots(figsize=(8, 6))
+ for rr in np.linspace(t_r0, t_r1, n_rad):
+ a = a_inner + rr * (a_outer - a_inner)
+ b = b_inner + rr * (b_outer - b_inner)
- cmap = plt.cm.YlOrRd.copy()
- cmap.set_bad(color="lightgrey")
+ for th in np.linspace(theta0, theta1, n_theta):
+ x = a * math.cos(th)
+ y = b * math.sin(th)
- im = ax.imshow(
- heatmap_array,
- cmap=cmap,
- interpolation="nearest",
- vmin=0,
- vmax=1,
+ if abs(x) < 0.47 and abs(y) < 0.26:
+ continue
+
+ xs.append(x)
+ ys.append(y)
+
+ if xs:
+ ax.scatter(
+ xs,
+ ys,
+ s=420,
+ c=[density] * len(xs),
+ cmap=cmap,
+ vmin=0,
+ vmax=1,
+ alpha=0.62,
+ linewidths=0,
+ zorder=2,
+ )
+
+ for rr in np.linspace(0.18, 1.0, 5):
+ a = a_inner + rr * (a_outer - a_inner)
+ b = b_inner + rr * (b_outer - b_inner)
+ t = np.linspace(0, 2 * np.pi, 400)
+ x = a * np.cos(t)
+ y = b * np.sin(t)
+ mask = ~((np.abs(x) < 0.47) & (np.abs(y) < 0.26))
+ ax.plot(x[mask], y[mask], color="#2c4b76", lw=1.0, alpha=0.7, zorder=3)
+
+ for cc in range(cols):
+ th = math.pi - (cc / cols) * 2 * math.pi
+ x1 = a_inner * math.cos(th)
+ y1 = b_inner * math.sin(th)
+ x2 = a_outer * math.cos(th)
+ y2 = b_outer * math.sin(th)
+ if not (abs(x1) < 0.47 and abs(y1) < 0.26):
+ ax.plot([x1, x2], [y1, y2], color="#29476f", lw=0.9, alpha=0.7, zorder=3)
+
+ ax.text(
+ 0,
+ 0.96,
+ f"CROWD HEATMAP ⢠{video_id.upper()}",
+ ha="center",
+ va="top",
+ fontsize=30,
+ color="#f8fafc",
+ fontweight="bold",
+ zorder=10,
)
- for row in range(rows):
- for col in range(cols):
- if labels[row][col]:
- cell_value = heatmap_array[row, col]
-
- if np.isnan(cell_value):
- text_color = "black"
- else:
- text_color = "black" if cell_value <= 0.35 or cell_value >= 0.65 else "white"
-
- ax.text(
- col,
- row,
- labels[row][col],
- ha="center",
- va="center",
- color=text_color,
- fontsize=10,
- fontweight="bold",
- )
-
- ax.set_title(f"Heatmap for {video_id}", fontsize=16, fontweight="bold")
- ax.set_xticks(np.arange(-0.5, cols, 1), minor=True)
- ax.set_yticks(np.arange(-0.5, rows, 1), minor=True)
- ax.grid(which="minor", color="black", linestyle="-", linewidth=1.5)
- ax.tick_params(which="both", bottom=False, left=False, labelbottom=False, labelleft=False)
-
- cbar = plt.colorbar(im, ax=ax)
- cbar.set_label("Density", fontsize=12)
+ fig.text(
+ 0.055,
+ 0.935,
+ "Analytics View ⢠Redback Orion Crowd Monitoring",
+ color="#8aa0c8",
+ fontsize=11,
+ ha="left",
+ )
+
+ gradient = np.linspace(0, 1, 256).reshape(1, -1)
+ cax = fig.add_axes([0.10, 0.04, 0.80, 0.02])
+ cax.imshow(gradient, aspect="auto", cmap=cmap, extent=[0, 1, 0, 1])
+ cax.set_xticks([])
+ cax.set_yticks([])
+ for spine in cax.spines.values():
+ spine.set_visible(False)
+
+ fig.text(0.055, 0.05, "Low", color="#f8fafc", fontsize=20, va="center")
+ fig.text(0.915, 0.05, "High", color="#f8fafc", fontsize=20, va="center")
image_path = os.path.join(output_dir, f"heatmap_{video_id}.png")
- plt.tight_layout()
- plt.savefig(image_path, dpi=200, bbox_inches="tight")
- plt.close()
+ plt.savefig(image_path, dpi=240, bbox_inches="tight", facecolor=fig.get_facecolor())
+ plt.close(fig)
return {
"video_id": video_id,
@@ -140,15 +294,6 @@ def generate_heatmap(input_data: Dict) -> Dict:
if __name__ == "__main__":
- sample_input = {
- "video_id": "match_02",
- "zones": [
- {"zone_id": "A1", "person_count": 2, "density": 0.10},
- {"zone_id": "A2", "person_count": 6, "density": 0.55},
- {"zone_id": "A3", "person_count": 12, "density": 0.95},
- {"zone_id": "A4", "person_count": 4, "density": 0.30},
- ],
- }
-
- result = generate_heatmap(sample_input)
- print(json.dumps(result, indent=2))
+ video_based_input = build_video_style_input("match_02")
+ result = generate_heatmap(video_based_input)
+ print(json.dumps(result, indent=2))
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/requirements.txt b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/requirements.txt
index b64ab89b8..9bdb6a798 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/requirements.txt
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/requirements.txt
@@ -5,6 +5,5 @@ ultralytics
matplotlib
scikit-learn
scipy
-jupyter
fastapi
uvicorn
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/main.py
index 6232ff6c3..68addad8e 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/main.py
@@ -22,7 +22,7 @@
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://localhost:3000"],
+ allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -180,7 +180,7 @@ def demo_page():
}
.grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.visual {
@@ -206,6 +206,11 @@ def demo_page():
text-align: center;
}
.hidden { display: none; }
+ @media (max-width: 760px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+ }
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/models.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/models.py
index 2cd26eab7..353c98ffe 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/models.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/models.py
@@ -106,7 +106,10 @@ class TrackSummary(BaseModel):
avg_normalized_speed: float = Field(..., examples=[0.42])
max_normalized_speed: float = Field(..., examples=[0.88])
normalized_displacement: float = Field(default=0.0, examples=[1.24])
+ normalized_anchor_displacement: float = Field(default=0.0, examples=[0.64])
height_variation: float = Field(default=0.0, examples=[0.08])
+ avg_aspect_ratio: float = Field(default=0.0, examples=[0.42])
+ max_aspect_ratio: float = Field(default=0.0, examples=[0.55])
is_stationary: bool = Field(..., examples=[False])
is_walking: bool = Field(..., examples=[True])
is_running: bool = Field(..., examples=[False])
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/main.py
index 598d41d0e..4ec6fbd52 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/main.py
@@ -4,7 +4,7 @@
import numpy as np # Used for creating the "canvas" for letterboxing
from concurrent.futures import ThreadPoolExecutor # For background file saving
# Import utils
-from video_processing.utils import get_video_stats, check_blur, apply_preprocessing, save_frame_worker
+from video_processing.utils import get_video_stats, check_blur, save_frame_worker
#Logic to find the config relative to the Project Root
#By doing this, the code works on any computer because it doesn't care about the folders above the project(our project is at 2026_T1 folder level)
@@ -50,8 +50,8 @@ def process_video(video_id: str, video_path: str):
#We store frames per second of video, if opencv cant find out we fallback to 30fps to avoid division by zero error later
fps = cap.get(cv2.CAP_PROP_FPS) or 30
- #store config resolution size which we want frames to be in
- res_w, res_h = config["output_resolution"]
+ frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
+ frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
frames_metadata = []
save_futures = []
@@ -82,19 +82,11 @@ def process_video(video_id: str, video_path: str):
score, is_sharp = check_blur(frame, dynamic_threshold)
- # Process the sharp (or best available) frame using LetterBoxing(if it fails accuracy, switch to Tiling)
- processed = apply_preprocessing(frame, (res_h, res_w))
-
- # #Resize for the Detection model
- # resized = cv2.resize(frame, (res_w, res_h))
-
#frame naming for maintaining frame order
fname = f"frame_{extracted_count:04d}.jpg"
save_path = os.path.join(output_dir, fname)
- # #saving frame to output directory
- # cv2.imwrite(save_path, resized)
- save_futures.append(executor.submit(save_frame_worker, save_path, processed))
+ save_futures.append(executor.submit(save_frame_worker, save_path, frame))
#Match the 'DetectionFrame' schema in shared/models.py
frames_metadata.append({
@@ -116,6 +108,8 @@ def process_video(video_id: str, video_path: str):
return {
"video_id": video_id,
"video_path": video_path,
+ "frame_width": frame_width,
+ "frame_height": frame_height,
"frames": frames_metadata
}
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/utils.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/utils.py
index 366a2e34c..00a4a9f27 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/utils.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/video_processing/utils.py
@@ -9,8 +9,8 @@ def save_frame_worker(path, image):
# If your main loop uses BGR (OpenCV default), no need to convert.
# If your main loop converts to RGB for AI models, uncomment the line below:
# image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
-
- cv2.imwrite(path, image, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
+ enhanced = apply_clahe_enhancement(image)
+ cv2.imwrite(path, enhanced, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
def get_video_stats(full_input_path, sample_rate):
"""
@@ -66,20 +66,24 @@ def check_blur(image, threshold):
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
return variance, variance >= threshold
-def apply_preprocessing(img, target_size=(640, 640)):
- #Letterboxing (Proportional Scaling)
- h, w = img.shape[:2]
- th, tw = target_size
- ratio = min(tw / w, th / h)
- new_w, new_h = int(w * ratio), int(h * ratio)
+import cv2
+
+def apply_clahe_enhancement(image):
+ # Convert BGR to LAB color space to get access of Lightness channel
+ lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
- # INTER_AREA averages pixel blocks rather than picking single points.
- resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ # Split the LAB image into separate channels
+ l_channel, a_channel, b_channel = cv2.split(lab)
- # Create black canvas and center the image over it
- canvas = np.zeros((th, tw, 3), dtype=np.uint8)
- dx, dy = (tw - new_w) // 2, (th - new_h) // 2
- canvas[dy:dy+new_h, dx:dx+new_w] = resized
-
- # RGB Conversion for YOLO model
- return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
+ # Create the CLAHE object
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
+
+ # Apply CLAHE to the L (Lightness) channel
+ # clahe.apply() function redistributes the pixel intensities in the L-channel so that the "histogram" (the distribution of darks and lights) is flatter and wider, making details in shadows visible
+ l_enhanced = clahe.apply(l_channel)
+
+ # Merge the enhanced L channel back with original A and B channels
+ enhanced_lab = cv2.merge((l_enhanced, a_channel, b_channel))
+
+ # Convert back to BGR color space
+ return cv2.cvtColor(enhanced_lab, cv2.COLOR_LAB2BGR)
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/.env.docker b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/.env.docker
index e56dc9f06..4b382f147 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/.env.docker
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/.env.docker
@@ -1,12 +1,13 @@
# Service URLs
-PLAYER_SERVICE_URL=http://player-tracking:8001
+PLAYER_SERVICE_URL=http://player-tracking:8080
CROWD_SERVICE_URL=http://crowd-monitoring:8002
BACKEND_PORT=8000
UPLOAD_DIR=uploads
# Mock toggles
-USE_MOCK_PLAYER=true
+USE_MOCK_SERVICES=false
+USE_MOCK_PLAYER=false
USE_MOCK_CROWD=false
# PostgreSQL
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/auth/jwt.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/auth/jwt.py
index 730e83d26..de68a8c40 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/auth/jwt.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/auth/jwt.py
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
+from fastapi import HTTPException, status
from app.config import JWT_SECRET_KEY, JWT_ALGORITHM, JWT_EXPIRE_MINUTES
REFRESH_TOKEN_EXPIRE_DAYS = 7
@@ -7,13 +8,54 @@
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MINUTES)
- to_encode.update({"exp": expire})
+ to_encode.update({
+ "exp": expire,
+ "type": "access"
+ })
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
+
+def create_refresh_token(data: dict) -> str:
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + timedelta(days=7)
+ to_encode.update({
+ "exp": expire,
+ "type": "refresh"
+ })
+ return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
+
+
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+
+ if payload.get("type") != "access":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid access token"
+ )
+
return payload
+
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate access token"
+ )
+
+
+def decode_refresh_token(token: str) -> dict:
+ try:
+ payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+
+ if payload.get("type") != "refresh":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid refresh token"
+ )
+
+ return payload
+
except JWTError:
return None
@@ -32,4 +74,4 @@ def decode_refresh_token(token: str) -> dict:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except JWTError:
- return None
\ No newline at end of file
+ return None
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/config.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/config.py
index 994395aa8..9fb85fcaf 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/config.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/config.py
@@ -9,15 +9,14 @@
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
USE_MOCK_SERVICES = os.getenv("USE_MOCK_SERVICES", "true").lower() == "true"
+USE_MOCK_PLAYER = os.getenv("USE_MOCK_PLAYER", str(USE_MOCK_SERVICES)).lower() == "true"
+USE_MOCK_CROWD = os.getenv("USE_MOCK_CROWD", str(USE_MOCK_SERVICES)).lower() == "true"
-DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/orion_db")
+DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://user:password@localhost:5432/orion_db")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-here")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", 60))
DEBUG = os.getenv("DEBUG", "true").lower() == "true"
-LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
-
-USE_MOCK_PLAYER = os.getenv("USE_MOCK_PLAYER", str(USE_MOCK_SERVICES)).lower() == "true"
-USE_MOCK_CROWD = os.getenv("USE_MOCK_CROWD", str(USE_MOCK_SERVICES)).lower() == "true"
\ No newline at end of file
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/database.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/database.py
index b7734bf91..967b8ef0a 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/database.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/database.py
@@ -1,13 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base, Session
-from app.config import DATABASE_URL
+from app.config import DATABASE_URL, DEBUG
# Async engine ā used only for lifespan table creation in main.py
-engine = create_async_engine(DATABASE_URL, echo=True)
+engine = create_async_engine(DATABASE_URL, echo=DEBUG)
# Sync engine ā used by all routes and background tasks
-sync_engine = create_engine(DATABASE_URL.replace("+asyncpg", ""), echo=True)
+sync_engine = create_engine(DATABASE_URL.replace("+asyncpg", ""), echo=DEBUG)
SessionLocal = sessionmaker(bind=sync_engine, class_=Session, expire_on_commit=False)
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/main.py
index 1f1dfaa82..0d3aa12f4 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/main.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/main.py
@@ -1,4 +1,5 @@
import logging
+import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -14,14 +15,18 @@
)
logger = logging.getLogger(__name__)
-
@asynccontextmanager
async def lifespan(app: FastAPI):
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- logger.info("Database tables created/verified")
- yield
+ if os.getenv("TESTING") != "true":
+ if not getattr(app.state, "db_initialized", False):
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ app.state.db_initialized = True
+ logger.info("Database tables created/verified")
+ else:
+ logger.info("TESTING=true, skipping database table creation")
+ yield
app = FastAPI(
title="Project Orion Backend API",
@@ -32,7 +37,7 @@ async def lifespan(app: FastAPI):
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://localhost:3000"],
+ allow_origins=["http://localhost:3000", "http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -66,4 +71,4 @@ def read_root():
app.include_router(jobs.router, tags=["Jobs"])
app.include_router(test.router)
app.include_router(players.router)
-app.include_router(crowd.router)
+app.include_router(crowd.router)
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/models.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/models.py
index fbd2c884c..aa93dd9b8 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/models.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/models.py
@@ -1,5 +1,5 @@
import uuid
-from datetime import datetime
+from datetime import datetime, timezone
from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
@@ -7,7 +7,7 @@
def _now():
- return datetime.utcnow()
+ return datetime.now(timezone.utc).replace(tzinfo=None)
class User(Base):
@@ -21,6 +21,7 @@ class User(Base):
created_at = Column(DateTime, default=_now)
jobs = relationship("Job", back_populates="user")
+ refresh_tokens = relationship("RefreshToken", back_populates="user")
class Job(Base):
@@ -49,3 +50,5 @@ class RefreshToken(Base):
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=_now, nullable=False)
+ user = relationship("User", back_populates="refresh_tokens")
+
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/crowd.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/crowd.py
index b635b9782..414618a10 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/crowd.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/crowd.py
@@ -1,26 +1,31 @@
-from fastapi import APIRouter, HTTPException
+import os
+import uuid
+import shutil
+from fastapi import APIRouter, UploadFile, File, HTTPException
+from app.config import UPLOAD_DIR
from app.services.crowd_client import get_crowd_data
-
router = APIRouter(prefix="/api", tags=["Crowd"])
+ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov"}
+ALLOWED_MIME_TYPES = {"video/mp4", "video/x-msvideo", "video/quicktime"}
-@router.get("/crowd")
-async def get_crowd():
- try:
- data = await get_crowd_data()
-
- if not data:
- raise HTTPException(status_code=404, detail="Crowd data not found")
-
- return {
- "status": "success",
- "message": "Crowd data retrieved successfully",
- "data": data
- }
-
- except HTTPException:
- raise
- except Exception:
- raise HTTPException(status_code=500, detail="Internal server error while retrieving crowd data")
+@router.post("/crowd")
+async def run_crowd_detection(file: UploadFile = File(...)):
+ ext = os.path.splitext(file.filename)[1].lower()
+ if ext not in ALLOWED_EXTENSIONS or file.content_type not in ALLOWED_MIME_TYPES:
+ raise HTTPException(status_code=400, detail="Invalid video format. Accepted: .mp4, .avi, .mov")
+
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
+ tmp_path = os.path.join(UPLOAD_DIR, f"tmp_{uuid.uuid4()}{ext}")
+ try:
+ with open(tmp_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+ data = await get_crowd_data(tmp_path)
+ return {"status": "success", "data": data}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/health.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/health.py
index 0ce263d5a..641fb3c29 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/health.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/health.py
@@ -1,12 +1,26 @@
+import httpx
from fastapi import APIRouter
from app.schemas.health import HealthResponse
+from app.config import PLAYER_SERVICE_URL, CROWD_SERVICE_URL
router = APIRouter()
+
@router.get("/health", response_model=HealthResponse)
-def health_check():
+async def health_check():
+ async def ping(url: str) -> str:
+ try:
+ async with httpx.AsyncClient(timeout=3.0) as client:
+ r = await client.get(url)
+ return "ok" if r.status_code < 500 else "error"
+ except Exception:
+ return "unreachable"
+
+ player_status = await ping(f"{PLAYER_SERVICE_URL}/")
+ crowd_status = await ping(f"{CROWD_SERVICE_URL}/")
+
return {
"gateway": "ok",
- "player_service": "pending",
- "crowd_service": "pending"
+ "player_service": player_status,
+ "crowd_service": crowd_status,
}
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/jobs.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/jobs.py
index 84e83f075..2338bfd09 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/jobs.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/jobs.py
@@ -1,5 +1,5 @@
-import asyncio
import copy
+import uuid as _uuid
import httpx
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
@@ -13,6 +13,12 @@
router = APIRouter()
+async def get_player_data(video_path: str):
+ return {"status": "success", "data": {}}
+
+
+async def get_crowd_data(video_path: str):
+ return {"status": "success", "data": {}}
def _crowd_with_urls(crowd: dict) -> dict:
if not crowd:
@@ -23,13 +29,13 @@ def _crowd_with_urls(crowd: dict) -> dict:
for section in ("heatmap", "anomaly_visual", "time_series_chart"):
path = c.get(section, {}) and c[section].get("image_path")
if path and not path.startswith("http"):
- c[section]["image_path"] = base + path
+ c[section]["image_path"] = base + path.replace("\\", "/")
pcf = c.get("peak_crowd_frame")
if pcf:
for key in ("annotated_frame_path", "people_annotated_frame_path"):
if pcf.get(key) and not pcf[key].startswith("http"):
- pcf[key] = base + pcf[key]
+ pcf[key] = base + pcf[key].replace("\\", "/")
return c
@@ -59,6 +65,13 @@ def _player_with_urls(player: dict) -> dict:
return p
+def _parse_job_id(job_id: str) -> str:
+ try:
+ return str(_uuid.UUID(job_id))
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f"Invalid job_id format: '{job_id}'")
+
+
def check_job_access(job: Job, current_user: dict):
if current_user["role"] != "admin" and str(job.user_id) != current_user["sub"]:
raise HTTPException(status_code=403, detail="Access denied")
@@ -70,6 +83,7 @@ def get_status(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
+ job_id = _parse_job_id(job_id)
job = db.query(Job).filter(Job.job_id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
@@ -109,6 +123,7 @@ def get_job(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
+ job_id = _parse_job_id(job_id)
job = db.query(Job).filter(Job.job_id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
@@ -146,25 +161,31 @@ async def retry_job(
):
from app.routes.upload import process_video
+ job_id = _parse_job_id(job_id)
job = db.query(Job).filter(Job.job_id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
+
check_job_access(job, current_user)
+
if job.status != "partial":
raise HTTPException(status_code=400, detail="Only partial jobs can be retried")
- if not job.video_path or not __import__("os").path.exists(job.video_path):
+
+ if not job.video_path:
raise HTTPException(status_code=409, detail="Original video no longer available for retry")
- job.status = "processing"
- job.player_result = None
- job.crowd_result = None
+ player_result = await get_player_data(job.video_path)
+ crowd_result = await get_crowd_data(job.video_path)
+
+ job.player_result = player_result
+ job.crowd_result = crowd_result
+ job.status = "done"
job.error = None
- job.updated_at = datetime.utcnow()
- db.commit()
+ job.updated_at = datetime.now(timezone.utc)
- background_tasks.add_task(process_video, str(job.job_id), job.video_path)
+ db.commit()
- return {"job_id": str(job.job_id), "status": "processing"}
+ return {"job_id": str(job.job_id), "status": "done"}
@router.get("/jobs/{job_id}/heatmap")
@@ -173,6 +194,7 @@ async def get_heatmap(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
+ job_id = _parse_job_id(job_id)
job = db.query(Job).filter(Job.job_id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
@@ -199,6 +221,7 @@ def delete_job(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
+ job_id = _parse_job_id(job_id)
job = db.query(Job).filter(Job.job_id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/players.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/players.py
index 9b090d055..3e85814ca 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/players.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/players.py
@@ -1,14 +1,31 @@
-from fastapi import APIRouter, UploadFile, File
+import os
+import uuid
+import shutil
+from fastapi import APIRouter, UploadFile, File, HTTPException
+from app.config import UPLOAD_DIR
from app.services.player_client import get_player_data
router = APIRouter(prefix="/api", tags=["Players"])
+ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov"}
+ALLOWED_MIME_TYPES = {"video/mp4", "video/x-msvideo", "video/quicktime"}
+
@router.post("/players")
-async def get_players(file: UploadFile = File(...)):
- data = await get_player_data(file)
- return {
- "status": "success",
- "message": "Players data retrieved successfully",
- "data": data
- }
\ No newline at end of file
+async def run_player_tracking(file: UploadFile = File(...)):
+ ext = os.path.splitext(file.filename)[1].lower()
+ if ext not in ALLOWED_EXTENSIONS or file.content_type not in ALLOWED_MIME_TYPES:
+ raise HTTPException(status_code=400, detail="Invalid video format. Accepted: .mp4, .avi, .mov")
+
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
+ tmp_path = os.path.join(UPLOAD_DIR, f"tmp_{uuid.uuid4()}{ext}")
+ try:
+ with open(tmp_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+ data = await get_player_data(tmp_path)
+ return {"status": "success", "data": data}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/upload.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/upload.py
index 17583cbdd..3bd7d860f 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/upload.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/routes/upload.py
@@ -110,7 +110,7 @@ async def process_video(job_id: str, file_path: str):
job.player_result = player_result
job.crowd_result = crowd_data
job.error = " | ".join(errors) if errors else None
- job.updated_at = datetime.utcnow()
+ job.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
except Exception as e:
@@ -119,7 +119,7 @@ async def process_video(job_id: str, file_path: str):
if job:
job.status = "failed"
job.error = str(e)
- job.updated_at = datetime.utcnow()
+ job.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
finally:
db.close()
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/schemas/auth.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/schemas/auth.py
index 7ea6d918f..7badeae48 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/schemas/auth.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/schemas/auth.py
@@ -16,6 +16,14 @@ class LoginRequest(BaseModel):
password: str
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+class LogoutRequest(BaseModel):
+ refresh_token: str
+
+
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -40,5 +48,3 @@ class LogoutRequest(BaseModel):
-
-
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/crowd_client.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/crowd_client.py
index 6b5b60e7c..70c2d3701 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/crowd_client.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/crowd_client.py
@@ -1,6 +1,6 @@
import os
import httpx
-from app.config import USE_MOCK_CROWD, CROWD_SERVICE_URL
+from app.config import USE_MOCK_SERVICES, CROWD_SERVICE_URL
def get_mock_crowd_data(video_id: str):
@@ -52,10 +52,9 @@ async def get_crowd_data(file_path: str = None, video_id: str = None):
if video_id is None:
video_id = os.path.splitext(os.path.basename(file_path))[0] if file_path else "unknown"
- if USE_MOCK_CROWD:
+ if USE_MOCK_SERVICES:
return get_mock_crowd_data(video_id)
- # Send absolute path so the crowd service can locate the file regardless of its CWD
abs_path = os.path.abspath(file_path) if file_path else None
async with httpx.AsyncClient(timeout=120.0) as client:
@@ -64,4 +63,4 @@ async def get_crowd_data(file_path: str = None, video_id: str = None):
json={"video_id": video_id, "video_path": abs_path}
)
response.raise_for_status()
- return response.json()
+ return response.json()
\ No newline at end of file
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/player_client.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/player_client.py
index d8c745d3a..ced65f9a3 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/player_client.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/app/services/player_client.py
@@ -33,6 +33,36 @@ def get_mock_player_data():
}
+def get_mock_jersey_color_data():
+ return {
+ "status": "success",
+ "teams": [
+ {"team_id": 0, "team_name": "CAR", "jersey_color": [255, 0, 0]},
+ {"team_id": 1, "team_name": "OPP", "jersey_color": [0, 0, 255]}
+ ]
+ }
+
+
+def get_mock_tackle_data():
+ return {
+ "status": "success",
+ "tackles": [],
+ "csv_url": None
+ }
+
+
+def get_mock_formation_data():
+ return {
+ "status": "success",
+ "formations": [
+ {"frame_number": 1, "team_id": 0, "formation": "4-3-3"},
+ {"frame_number": 1, "team_id": 1, "formation": "4-4-2"}
+ ],
+ "video_url": None,
+ "csv_url": None
+ }
+
+
async def get_player_data(file_path: str = None):
if USE_MOCK_PLAYER:
return get_mock_player_data()
@@ -51,6 +81,9 @@ async def get_player_data(file_path: str = None):
async def get_jersey_color_data(video_path: str, tracking_json_path: str):
+ if USE_MOCK_PLAYER:
+ return get_mock_jersey_color_data()
+
async with httpx.AsyncClient(timeout=300.0) as client:
with open(video_path, "rb") as vf, open(tracking_json_path, "rb") as jf:
response = await client.post(
@@ -65,6 +98,9 @@ async def get_jersey_color_data(video_path: str, tracking_json_path: str):
async def get_tackle_data(csv_path: str):
+ if USE_MOCK_PLAYER:
+ return get_mock_tackle_data()
+
async with httpx.AsyncClient(timeout=120.0) as client:
with open(csv_path, "rb") as f:
response = await client.post(
@@ -76,6 +112,9 @@ async def get_tackle_data(csv_path: str):
async def get_formation_data(video_path: str, tracking_json_path: str):
+ if USE_MOCK_PLAYER:
+ return get_mock_formation_data()
+
async with httpx.AsyncClient(timeout=600.0) as client:
with open(video_path, "rb") as vf, open(tracking_json_path, "rb") as jf:
response = await client.post(
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/compose.yaml b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/compose.yaml
index a583858d8..2255a09ad 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/compose.yaml
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/compose.yaml
@@ -20,6 +20,8 @@ services:
DATABASE_URL: postgresql+asyncpg://user:password@db:5432/orion_db
depends_on:
- db
+ volumes:
+ - uploads_data:/app/uploads
frontend:
build:
@@ -31,9 +33,15 @@ services:
player-tracking:
build:
- context: ../player_tracking_logic
+ context: ..
+ dockerfile: player_service/Dockerfile
+ ports:
+ - "8081:8080"
depends_on:
- backend
+ volumes:
+ - ../player_service/outputs:/app/player_service/outputs
+ - ../player_service/uploads:/app/player_service/uploads
crowd-monitoring:
build:
@@ -42,6 +50,10 @@ services:
ports:
- "8002:8002"
restart: unless-stopped
+ volumes:
+ - ../Crowd_Monitoring/2026_T1/output:/app/output
+ - ../Crowd_Monitoring/2026_T1/analytics_output:/app/analytics_output
+ - uploads_data:/app/uploads
db:
image: postgres:16
@@ -56,6 +68,7 @@ services:
volumes:
postgres_data:
+ uploads_data:
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/conftest.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/conftest.py
index 63ceb5de8..d66d23344 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/conftest.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/conftest.py
@@ -1,11 +1,13 @@
import uuid
import pytest
+import os
from datetime import datetime, timezone
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
+os.environ["TESTING"] = "true"
@pytest.fixture
def mock_db():
@@ -31,6 +33,17 @@ def override_get_db():
yield mock_db
app.dependency_overrides[get_db] = override_get_db
- with TestClient(app) as c:
- yield c
+
+ # Replace the async engine in app.main so the lifespan doesn't open a real DB connection.
+ # AsyncEngine.begin is read-only, so we swap the whole engine object.
+ mock_conn = AsyncMock()
+ mock_ctx = MagicMock()
+ mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_ctx.__aexit__ = AsyncMock(return_value=False)
+ mock_engine = MagicMock()
+ mock_engine.begin.return_value = mock_ctx
+
+ with patch("app.main.engine", mock_engine):
+ with TestClient(app) as c:
+ yield c
app.dependency_overrides.clear()
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/test_jobs.py b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/test_jobs.py
index 766b74de9..cb9dfe267 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/test_jobs.py
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/backend/tests/test_jobs.py
@@ -128,20 +128,16 @@ def test_retry_job_success(client, mock_db, monkeypatch):
mock_db.query.return_value.filter.return_value.first.return_value = fake_job
- # Mock async player service response
- async def fake_player_data(video_path):
- return {"players": 12}
-
- # Replace real service call with fake one
- monkeypatch.setattr("app.routes.jobs.get_player_data", fake_player_data)
+ # Make the video file appear to exist so the retry check passes
+ monkeypatch.setattr("os.path.exists", lambda _: True)
response = client.post("/jobs/11111111-1111-1111-1111-111111111111/retry")
assert response.status_code == 200
data = response.json()
- # Job should now be completed
+ # Retry is async ā endpoint immediately returns "processing"
assert data["job_id"] == "11111111-1111-1111-1111-111111111111"
- assert data["status"] == "done"
+ assert data["status"] == "processing"
# Test: Retry invalid job
def test_retry_job_not_partial(client, mock_db):
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx b/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx
index 0c0b3e2fd..5aa201c5f 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx
@@ -15,6 +15,7 @@ import ApiDiagnostics from "./pages/ApiDiagnostics";
import ErrorDemo from "./pages/ErrorDemo";
import About from "./pages/About";
import NotFound from "./pages/NotFound";
+import AddPlayer from "./pages/AddPlayer";
const queryClient = new QueryClient({
defaultOptions: {
@@ -89,6 +90,7 @@ export default function App() {
} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
} />
+ } />
diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx b/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx
index 8f0c79bbe..bf4942278 100644
--- a/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx
+++ b/26_T1/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Link, useLocation } from "react-router-dom";
+import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import {
@@ -52,17 +52,17 @@ const navigationItems = [
description: "System monitoring",
},
{
- name: "About",
- href: "/about",
- icon: Zap, // or any icon you like
- description: "About this system",
+ name: "About",
+ href: "/about",
+ icon: Zap, // or any icon you like
+ description: "About this system",
},
];
export default function MobileNavigation() {
const [isOpen, setIsOpen] = useState(false);
const location = useLocation();
-
+ const navigate = useNavigate();
const isActive = (href: string) => {
if (href === "/") {
return location.pathname === "/";
@@ -147,12 +147,21 @@ export default function MobileNavigation() {