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() {