diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/README.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/README.md index 19ef3672..15817922 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/README.md +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/README.md @@ -76,3 +76,29 @@ Do not create extra files too early. Start simple, then split only when needed. - Allocation recommendation report (JSON/CSV) - Risk threshold configuration file - Examples showing critical scenarios and responses + +## Validation & Testing + +### Test Scenarios Validated + +The module has been tested with the following scenarios: + +1. **Normal operation** - Matches SCHEMA.md example exactly +2. **Critical density zones** - Density ā„ 0.85 triggers immediate crowd control alerts +3. **Multiple high-risk zones** - All zones with density ā„ 0.70 are flagged and monitored +4. **Edge cases** - Empty zones, missing crowd_state handled gracefully +5. **Integration handoff** - Successfully receives data from crowd_behaviour_analytics + +### Risk Thresholds + +| Risk Level | Density Range | Flagged | Action | +|------------|--------------|---------|--------| +| Critical | ā„ 0.85 | True | Immediate crowd control required | +| High | 0.70 - 0.84 | True | Close monitoring | +| Medium | 0.40 - 0.69 | False | Standard monitoring | +| Low | 0.30 - 0.39 | False | Routine observation | +| Very Low | < 0.30 | False | No action needed | + +### Test Results + +All validation tests passed (5/5). Module is ready for integration. diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/main.py index 4d089fc4..bf6fabd6 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/main.py +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/main.py @@ -69,11 +69,17 @@ def get_risk_level(density): recommendations = [] crowd_state = input_data.get("crowd_state", "stable") - # Recommendations based on SCHEMA.md example + # Critical zone recommendations (highest priority) + for zone in assessed_zones: + if zone["risk_level"] == "critical": + recommendations.append(f"šØ CRITICAL: Zone {zone['zone_id']} at critical density - immediate crowd control required") + + # High risk zone recommendations for zone in assessed_zones: if zone["risk_level"] == "high" and zone["flagged"]: recommendations.append(f"Monitor zone {zone['zone_id']} closely") + # Crowd state recommendations if crowd_state == "increasing_density": recommendations.append("Prepare crowd redirection if density increases further") diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/test_integration.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/test_integration.py new file mode 100644 index 00000000..4b296171 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/test_integration.py @@ -0,0 +1,51 @@ +"""Integration test to verify handoff from crowd_behaviour_analytics""" + +import sys +sys.path.insert(0, '/Users/xiwan2020/redback-orion/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1') + +# Import both tasks +from crowd_allocation_risk_zone.main import assess_risk + +# Mock the behavior analytics output (since it's not fully implemented yet) +def mock_analyze_behaviour(input_data): + """Simulates what crowd_behaviour_analytics would return""" + return { + "video_id": input_data.get("video_id", "test"), + "crowd_state": input_data.get("crowd_state", "stable"), + "zones": input_data.get("zones", []) + } + +# Test data that would come from the shared service +test_pipeline_data = { + "video_id": "integration_test_01", + "crowd_state": "increasing_density", + "zones": [ + {"zone_id": "Z1", "person_count": 12, "density": 0.88}, + {"zone_id": "Z2", "person_count": 7, "density": 0.65}, + {"zone_id": "Z3", "person_count": 2, "density": 0.20} + ] +} + +print("="*60) +print("INTEGRATION TEST: Handoff from crowd_behaviour_analytics") +print("="*60) + +# Simulate the pipeline +print("\n1. Behaviour Analytics processes input...") +behaviour_result = mock_analyze_behaviour(test_pipeline_data) +print(f" ā Returns: video_id={behaviour_result['video_id']}, crowd_state={behaviour_result['crowd_state']}, zones={len(behaviour_result['zones'])} zones") + +print("\n2. Risk Zone task receives behaviour_result...") +risk_result = assess_risk(behaviour_result) +print(f" ā Returns: video_id={risk_result['video_id']}, zones assessed={len(risk_result['zones'])}") + +print("\n3. Final output from pipeline:") +print(f" Video ID: {risk_result['video_id']}") +print(f" Zones assessed:") +for zone in risk_result['zones']: + print(f" - {zone['zone_id']}: {zone['risk_level']} (flagged: {zone['flagged']})") +print(f" Recommendations:") +for rec in risk_result['recommendations']: + print(f" ⢠{rec}") + +print("\nā Integration handoff verified successfully!") \ 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/test_scenarios.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/test_scenarios.py new file mode 100644 index 00000000..cd53f248 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_allocation_risk_zone/test_scenarios.py @@ -0,0 +1,241 @@ +"""Test scenarios for crowd allocation risk zone validation""" + +import json +from main import assess_risk + +def run_test(test_name, input_data, expected_output): + """Run a single test and report results""" + print(f"\n{'='*60}") + print(f"Test: {test_name}") + print(f"{'='*60}") + + try: + result = assess_risk(input_data) + + # Compare results + matches = True + issues = [] + + # Check video_id + if result.get("video_id") != expected_output.get("video_id"): + matches = False + issues.append(f"video_id mismatch: got {result.get('video_id')}, expected {expected_output.get('video_id')}") + + # Check zones length + if len(result.get("zones", [])) != len(expected_output.get("zones", [])): + matches = False + issues.append(f"Zone count mismatch: got {len(result.get('zones', []))}, expected {len(expected_output.get('zones', []))}") + + # Check each zone + for i, (result_zone, expected_zone) in enumerate(zip(result.get("zones", []), expected_output.get("zones", []))): + if result_zone.get("risk_level") != expected_zone.get("risk_level"): + matches = False + issues.append(f"Zone {i} risk_level: got {result_zone.get('risk_level')}, expected {expected_zone.get('risk_level')}") + if result_zone.get("flagged") != expected_zone.get("flagged"): + matches = False + issues.append(f"Zone {i} flagged: got {result_zone.get('flagged')}, expected {expected_zone.get('flagged')}") + + # Check recommendations + if result.get("recommendations") != expected_output.get("recommendations"): + matches = False + issues.append(f"Recommendations mismatch") + + if matches: + print("ā PASSED") + print(f"Output: {json.dumps(result, indent=2)}") + else: + print("ā FAILED") + for issue in issues: + print(f" ⢠{issue}") + print(f"\nGot: {json.dumps(result, indent=2)}") + print(f"\nExpected: {json.dumps(expected_output, indent=2)}") + + return matches + + except Exception as e: + print(f"ā ERROR: {str(e)}") + return False + +# Test Scenario 1: Normal operation (from SCHEMA.md) +def scenario_1(): + input_data = { + "video_id": "match_01", + "crowd_state": "increasing_density", + "zones": [ + {"zone_id": "A1", "person_count": 8, "density": 0.72}, + {"zone_id": "A2", "person_count": 5, "density": 0.45} + ] + } + expected = { + "video_id": "match_01", + "zones": [ + {"zone_id": "A1", "risk_level": "high", "flagged": True}, + {"zone_id": "A2", "risk_level": "medium", "flagged": False} + ], + "recommendations": [ + "Monitor zone A1 closely", + "Prepare crowd redirection if density increases further" + ] + } + return run_test("Normal operation (SCHEMA.md example)", input_data, expected) + +# Test Scenario 2: Critical density zone +def scenario_2(): + input_data = { + "video_id": "match_02", + "crowd_state": "stable", + "zones": [ + {"zone_id": "B1", "person_count": 15, "density": 0.92}, + {"zone_id": "B2", "person_count": 3, "density": 0.25} + ] + } + result = assess_risk(input_data) + print(f"\n{'='*60}") + print(f"Test: Critical density zone") + print(f"{'='*60}") + + # Manual checks + issues = [] + zones = result.get("zones", []) + + if zones[0].get("risk_level") != "critical": + issues.append(f"Zone B1 risk_level: got {zones[0].get('risk_level')}, expected critical") + if not zones[0].get("flagged"): + issues.append(f"Zone B1 flagged: expected True") + if zones[1].get("risk_level") != "very_low": + issues.append(f"Zone B2 risk_level: got {zones[1].get('risk_level')}, expected very_low") + + # Check for critical zone recommendation + has_critical_rec = any("critical" in rec.lower() for rec in result.get("recommendations", [])) + if not has_critical_rec: + issues.append("Missing recommendation for critical zone") + + # Also check that critical recommendation includes the zone ID + critical_rec_for_b1 = any("B1" in rec and "critical" in rec.lower() for rec in result.get("recommendations", [])) + if not critical_rec_for_b1: + issues.append("Critical recommendation should mention zone B1") + + if not issues: + print("ā PASSED") + print(f"Output: {json.dumps(result, indent=2)}") + return True + else: + print("ā FAILED") + for issue in issues: + print(f" ⢠{issue}") + return False + +# Test Scenario 3: Multiple high-risk zones +def scenario_3(): + input_data = { + "video_id": "match_03", + "crowd_state": "increasing_density", + "zones": [ + {"zone_id": "C1", "person_count": 10, "density": 0.75}, + {"zone_id": "C2", "person_count": 9, "density": 0.72}, + {"zone_id": "C3", "person_count": 4, "density": 0.38} + ] + } + result = assess_risk(input_data) + print(f"\n{'='*60}") + print(f"Test: Multiple high-risk zones") + print(f"{'='*60}") + + issues = [] + zones = result.get("zones", []) + + # Check risk levels + if zones[0].get("risk_level") != "high": + issues.append(f"Zone C1: expected high, got {zones[0].get('risk_level')}") + if zones[1].get("risk_level") != "high": + issues.append(f"Zone C2: expected high, got {zones[1].get('risk_level')}") + if zones[2].get("risk_level") != "low": + issues.append(f"Zone C3: expected low, got {zones[2].get('risk_level')}") + + # Check flagged status + if not zones[0].get("flagged"): + issues.append("Zone C1 should be flagged") + if not zones[1].get("flagged"): + issues.append("Zone C2 should be flagged") + + # Check recommendations include high-risk zones + recommendations = result.get("recommendations", []) + if not any("C1" in rec for rec in recommendations): + issues.append("Recommendation missing for C1") + if not any("C2" in rec for rec in recommendations): + issues.append("Recommendation missing for C2") + + if not issues: + print("ā PASSED") + print(f"Output: {json.dumps(result, indent=2)}") + return True + else: + print("ā FAILED") + for issue in issues: + print(f" ⢠{issue}") + return False + +# Test Scenario 4: Empty zones list +def scenario_4(): + input_data = { + "video_id": "match_04", + "crowd_state": "stable", + "zones": [] + } + expected = { + "video_id": "match_04", + "zones": [], + "recommendations": ["All zones within safe thresholds - continue monitoring"] + } + return run_test("Empty zones list", input_data, expected) + +# Test Scenario 5: Missing crowd_state (should default to stable) +def scenario_5(): + input_data = { + "video_id": "match_05", + "zones": [ + {"zone_id": "D1", "person_count": 3, "density": 0.25} + ] + } + result = assess_risk(input_data) + print(f"\n{'='*60}") + print(f"Test: Missing crowd_state (should default to stable)") + print(f"{'='*60}") + + # Should not throw error and should work + if result.get("video_id") == "match_05" and result.get("zones"): + print("ā PASSED - Handles missing crowd_state gracefully") + print(f"Output: {json.dumps(result, indent=2)}") + return True + else: + print("ā FAILED") + return False + +# Run all tests +def run_all_tests(): + print("\n" + "="*60) + print("RUNNING CROWD ALLOCATION RISK ZONE VALIDATION TESTS") + print("="*60) + + results = [] + results.append(scenario_1()) + results.append(scenario_2()) + results.append(scenario_3()) + results.append(scenario_4()) + results.append(scenario_5()) + + print("\n" + "="*60) + print("TEST SUMMARY") + print("="*60) + passed = sum(results) + total = len(results) + print(f"Passed: {passed}/{total}") + print(f"Failed: {total - passed}/{total}") + + if passed == total: + print("\nš ALL TESTS PASSED! Module is ready for integration.") + else: + print(f"\nā ļø {total - passed} test(s) failed. Please review and fix.") + +if __name__ == "__main__": + run_all_tests() \ No newline at end of file diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/SCHEMA.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/SCHEMA.md index 8babd8a5..25f5624d 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/SCHEMA.md +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/SCHEMA.md @@ -27,55 +27,24 @@ This task receives analytics output and determines overall crowd behaviour trend } ``` -``` -{ - "video_id": "match_02", - "zones": [ - { - "zone_id": "A1", - "person_count": 16, - "density": 0.88 - }, - { - "zone_id": "A2", - "person_count": 14, - "density": 0.79 - }, - { - "zone_id": "B1", - "person_count": 11, - "density": 0.68 - } - ], - "heatmap": { - "image_path": "output/heatmap_match_02.png" - } -} -``` +Optional additional input for AI-vision processing: -``` +```json { - "video_id": "match_03", - "zones": [ + "frames": [ { - "zone_id": "A1", - "person_count": 2, - "density": 0.12 - }, - { - "zone_id": "A2", - "person_count": 3, - "density": 0.18 - }, - { - "zone_id": "B1", - "person_count": 1, - "density": 0.10 + "frame_id": 1, + "timestamp": 0.04, + "annotated_frame_path": "crowd_detection_output/people_detection_results/frame_0001.jpg", + "people_detections": [ + { + "bbox": [100, 50, 160, 180], + "confidence": 0.93 + } + ], + "face_detections": [] } - ], - "heatmap": { - "image_path": "output/heatmap_match_03.png" - } + ] } ``` @@ -91,7 +60,76 @@ This task receives analytics output and determines overall crowd behaviour trend "person_count": 8, "density": 0.72 } - ] + ], + "event_flags": [ + "running_detection", + "crowd_surge", + "motion_anomaly" + ], + "artifact_paths": [ + "output/heatmap_match_01.png", + "crowd_behaviour_analytics/output/running_frames/motion_frame_0008.jpg" + ], + "vision_metrics": { + "vision_enabled": true, + "avg_motion_magnitude": 0.84, + "peak_motion_magnitude": 1.27, + "reverse_flow_ratio": 0.18, + "motion_intensity": 1.05, + "tracking": { + "track_count": 3, + "walking_track_count": 1, + "walking_track_ids": [2], + "running_track_count": 1, + "running_track_ids": [1], + "tracks": [ + { + "track_id": 1, + "history_length": 4, + "avg_speed": 8.4, + "max_speed": 12.6, + "avg_normalized_speed": 0.42, + "max_normalized_speed": 0.88, + "normalized_displacement": 1.24, + "height_variation": 0.08, + "is_walking": false, + "is_running": true, + "movement_state": "running" + }, + { + "track_id": 2, + "history_length": 4, + "avg_speed": 5.2, + "max_speed": 6.4, + "avg_normalized_speed": 0.22, + "max_normalized_speed": 0.36, + "normalized_displacement": 0.72, + "height_variation": 0.05, + "is_walking": true, + "is_running": false, + "movement_state": "walking" + } + ] + }, + "anomaly_model": { + "model_enabled": true, + "anomaly_track_ids": [1], + "running_track_ids": [1], + "anomaly_count": 1, + "track_scores": [ + { + "track_id": 1, + "history_length": 4, + "avg_speed": 8.4, + "avg_normalized_speed": 0.42, + "max_normalized_speed": 0.88, + "normalized_displacement": 1.24, + "anomaly_score": 0.2174, + "is_anomaly": true + } + ] + } + } } ``` @@ -99,5 +137,10 @@ This task receives analytics output and determines overall crowd behaviour trend - output of this task is used by `crowd_allocation_risk_zone` - keep `crowd_state` aligned with the intelligence service schema -- behaviour analysis can use zone density patterns and heatmap availability as input features -- this task can evolve from rule-based scoring to a trained ML model later without changing the output schema +- behaviour analysis can use zone density patterns, heatmap availability, and sequential annotated frames as input features +- `event_flags` and `artifact_paths` are optional extended outputs for demo and frontend visibility +- optional `frames` should use people bbox-annotated frame paths from `crowd_detection` for downstream visual analysis and motion analysis +- `people_detections` is the input used for person tracking +- `tracking` contains per-person movement-state outputs derived from lightweight tracking +- `anomaly_model` contains IsolationForest-based motion anomaly outputs +- current movement states are `stationary`, `walking`, and `running` diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/anomaly_model.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/anomaly_model.py new file mode 100644 index 00000000..ebff332b --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/anomaly_model.py @@ -0,0 +1,129 @@ +"""IsolationForest-based anomaly scoring for tracked crowd behaviour.""" + +from math import sqrt + +import numpy as np +from sklearn.ensemble import IsolationForest + + +def _track_feature_vector(track_id, history): + speeds = [entry.get("speed", 0.0) for entry in history] + normalized_speeds = [entry.get("normalized_speed", 0.0) for entry in history] + first_centroid = history[0].get("centroid", [0.0, 0.0]) + last_centroid = history[-1].get("centroid", [0.0, 0.0]) + dx = float(last_centroid[0]) - float(first_centroid[0]) + dy = float(last_centroid[1]) - float(first_centroid[1]) + displacement = sqrt(dx * dx + dy * dy) + 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 + + return { + "track_id": track_id, + "vector": [ + float(len(history)), + float(sum(normalized_speeds) / len(normalized_speeds)) if normalized_speeds else 0.0, + float(max(normalized_speeds, default=0.0)), + float(normalized_displacement), + float(sum(speeds) / len(speeds)) if speeds else 0.0, + ], + } + + +def detect_track_anomalies(track_histories): + """Score tracked people using IsolationForest and return anomalous tracks.""" + if not track_histories: + return { + "model_enabled": False, + "anomaly_track_ids": [], + "running_track_ids": [], + "anomaly_count": 0, + "track_scores": [], + } + + track_vectors = [ + _track_feature_vector(track_id, history) + for track_id, history in track_histories.items() + if history + ] + + if not track_vectors: + return { + "model_enabled": False, + "anomaly_track_ids": [], + "running_track_ids": [], + "anomaly_count": 0, + "track_scores": [], + } + + # Synthetic reference samples represent relatively normal crowd motion. + reference_vectors = np.array( + [ + [2.0, 0.05, 0.10, 0.12, 1.5], + [3.0, 0.08, 0.14, 0.20, 2.0], + [4.0, 0.12, 0.22, 0.30, 2.7], + [5.0, 0.18, 0.30, 0.45, 3.5], + [4.0, 0.10, 0.18, 0.25, 2.3], + [5.0, 0.16, 0.28, 0.38, 3.2], + [6.0, 0.22, 0.36, 0.55, 4.0], + [7.0, 0.28, 0.42, 0.72, 5.0], + ], + dtype=float, + ) + observed_vectors = np.array([entry["vector"] for entry in track_vectors], dtype=float) + training_vectors = np.vstack([reference_vectors, observed_vectors]) + + model = IsolationForest( + n_estimators=100, + contamination=0.15, + random_state=42, + ) + model.fit(training_vectors) + + predictions = model.predict(observed_vectors) + scores = model.decision_function(observed_vectors) + + anomaly_track_ids = [] + running_track_ids = [] + track_scores = [] + + for track_entry, prediction, score in zip(track_vectors, predictions, scores): + track_id = track_entry["track_id"] + history_length, avg_normalized_speed, max_normalized_speed, normalized_displacement, avg_speed = track_entry["vector"] + is_anomaly = int(prediction) == -1 + anomaly_score = round(float(-score), 4) + + if is_anomaly: + anomaly_track_ids.append(track_id) + + if ( + is_anomaly + and history_length >= 3 + and avg_normalized_speed >= 0.55 + and max_normalized_speed >= 0.9 + and normalized_displacement >= 1.0 + ): + running_track_ids.append(track_id) + + track_scores.append( + { + "track_id": track_id, + "history_length": int(history_length), + "avg_speed": round(avg_speed, 2), + "avg_normalized_speed": round(avg_normalized_speed, 4), + "max_normalized_speed": round(max_normalized_speed, 4), + "normalized_displacement": round(normalized_displacement, 4), + "anomaly_score": anomaly_score, + "is_anomaly": is_anomaly, + } + ) + + return { + "model_enabled": True, + "anomaly_track_ids": anomaly_track_ids, + "running_track_ids": running_track_ids, + "anomaly_count": len(anomaly_track_ids), + "track_scores": track_scores, + } 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 new file mode 100644 index 00000000..7588439f --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/event_detection.py @@ -0,0 +1,49 @@ +"""Event-detection helpers for crowd behaviour analytics.""" + + +def detect_behaviour_events( + features, + vision_features, + zones, + tracking_summary=None, + anomaly_summary=None, +): + """Generate event flags using density patterns and motion cues.""" + event_flags = [] + tracking_summary = tracking_summary or {} + anomaly_summary = anomaly_summary or {} + + if features["max_density"] >= 0.80 or features["hotspot_count"] >= 2: + event_flags.append("overcrowding_spike") + + if features["density_variation"] >= 0.35: + event_flags.append("sudden_gathering") + + 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: + event_flags.append("running_detection") + + if tracking_summary.get("walking_track_count", 0) > 0: + event_flags.append("walking_detection") + + if tracking_summary.get("stationary_track_count", 0) > 0: + event_flags.append("stationary_detection") + + if vision_features["vision_enabled"] and vision_features["reverse_flow_ratio"] >= 0.30: + event_flags.append("reverse_flow") + + if ( + features["avg_density"] >= 0.60 + and vision_features["vision_enabled"] + and vision_features["avg_motion_magnitude"] >= 0.80 + ): + event_flags.append("crowd_surge") + + if anomaly_summary.get("anomaly_count", 0) > 0: + event_flags.append("motion_anomaly") + + return event_flags diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/feature_extraction.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/feature_extraction.py new file mode 100644 index 00000000..d9e87afe --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/feature_extraction.py @@ -0,0 +1,50 @@ +"""Feature extraction helpers for crowd behaviour analytics.""" + + +def extract_density_features(zones, heatmap): + """Build behaviour features from zone density and heatmap availability.""" + if not zones: + return { + "avg_density": 0.0, + "max_density": 0.0, + "density_variation": 0.0, + "total_people": 0, + "hotspot_count": 0, + "heatmap_available": False, + } + + densities = [zone.get("density", 0.0) for zone in zones] + avg_density = sum(densities) / len(densities) + max_density = max(densities) + min_density = min(densities) + total_people = sum(zone.get("person_count", 0) for zone in zones) + hotspot_count = sum(1 for density in densities if density >= 0.6) + + return { + "avg_density": avg_density, + "max_density": max_density, + "density_variation": max_density - min_density, + "total_people": total_people, + "hotspot_count": hotspot_count, + "heatmap_available": bool(heatmap and heatmap.get("image_path")), + } + + +def classify_crowd_state(features): + """ML-style scoring scaffold for overall crowd-state classification.""" + score = 0.0 + + score += features["avg_density"] * 0.35 + score += features["max_density"] * 0.35 + score += features["density_variation"] * 0.15 + score += min(features["hotspot_count"] / 3, 1.0) * 0.10 + score += min(features["total_people"] / 30, 1.0) * 0.05 + + if not features["heatmap_available"]: + score -= 0.05 + + if score >= 0.60: + return "increasing_density" + if score <= 0.20: + return "dispersing" + return "stable" 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 0ec164fd..3ed49ba7 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 @@ -1,53 +1,23 @@ -"""Minimal entry point for the crowd behaviour analytics task.""" +"""Crowd behaviour analytics task orchestration.""" - -def _extract_features(zones, heatmap): - """Build simple behaviour features from zone density and heatmap availability.""" - if not zones: - return { - "avg_density": 0.0, - "max_density": 0.0, - "density_variation": 0.0, - "total_people": 0, - "hotspot_count": 0, - "heatmap_available": False, - } - - densities = [zone.get("density", 0.0) for zone in zones] - avg_density = sum(densities) / len(densities) - max_density = max(densities) - min_density = min(densities) - total_people = sum(zone.get("person_count", 0) for zone in zones) - hotspot_count = sum(1 for density in densities if density >= 0.6) - - return { - "avg_density": avg_density, - "max_density": max_density, - "density_variation": max_density - min_density, - "total_people": total_people, - "hotspot_count": hotspot_count, - "heatmap_available": bool(heatmap and heatmap.get("image_path")), - } - - -def _classify_crowd_state(features): - """AI-style scoring scaffold that can later be replaced with a trained model.""" - score = 0.0 - - score += features["avg_density"] * 0.35 - score += features["max_density"] * 0.35 - score += features["density_variation"] * 0.15 - score += min(features["hotspot_count"] / 3, 1.0) * 0.10 - score += min(features["total_people"] / 30, 1.0) * 0.05 - - if not features["heatmap_available"]: - score -= 0.05 - - if score >= 0.60: - return "increasing_density" - if score <= 0.20: - return "dispersing" - return "stable" +from crowd_behaviour_analytics.anomaly_model import detect_track_anomalies +from crowd_behaviour_analytics.event_detection import detect_behaviour_events +from crowd_behaviour_analytics.feature_extraction import ( + classify_crowd_state, + extract_density_features, +) +from crowd_behaviour_analytics.pose_analysis import refine_tracking_summary_with_pose +from crowd_behaviour_analytics.tracking import ( + build_frame_activity_series, + save_motion_annotations, + summarise_tracks, + track_people, +) +from crowd_behaviour_analytics.vision_analysis import ( + extract_motion_features, + load_grayscale_frames, + resolve_frame_paths, +) def analyze_behaviour(input_data): @@ -55,17 +25,50 @@ def analyze_behaviour(input_data): zones = input_data.get("zones", []) heatmap = input_data.get("heatmap", {}) video_id = input_data.get("video_id") + frames = input_data.get("frames", []) + frame_paths = resolve_frame_paths(input_data) + + features = extract_density_features(zones, heatmap) + vision_features = extract_motion_features(load_grayscale_frames(frame_paths)) + frame_tracks, track_histories = track_people(frames) + tracking_summary = summarise_tracks(track_histories) + tracking_summary = refine_tracking_summary_with_pose(frames, frame_tracks, tracking_summary) + anomaly_summary = detect_track_anomalies(track_histories) + crowd_state = classify_crowd_state(features) + event_flags = detect_behaviour_events( + features, + vision_features, + zones, + tracking_summary, + anomaly_summary, + ) + + 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)) - features = _extract_features(zones, heatmap) - crowd_state = _classify_crowd_state(features) + vision_metrics = dict(vision_features) + vision_metrics["tracking"] = tracking_summary + vision_metrics["anomaly_model"] = anomaly_summary return { "video_id": video_id, "crowd_state": crowd_state, "zones": zones, + "event_flags": event_flags, + "artifact_paths": artifact_paths, + "frame_movement_summary": frame_activity_series, + "frame_activity_series": frame_activity_series, + "vision_metrics": vision_metrics, } if __name__ == "__main__": - # Add a simple local test call here when implementation starts. pass diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/pose_analysis.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/pose_analysis.py new file mode 100644 index 00000000..61e5d41a --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/pose_analysis.py @@ -0,0 +1,202 @@ +"""Optional pose-based validation for crowd movement states.""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +from ultralytics import YOLO + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +POSE_MODEL_CANDIDATES = [ + PROJECT_ROOT / "crowd_behaviour_analytics" / "yolov8n-pose.pt", + PROJECT_ROOT / "crowd_behaviour_analytics" / "yolov8s-pose.pt", + PROJECT_ROOT / "yolov8n-pose.pt", + PROJECT_ROOT / "yolov8s-pose.pt", +] +LEG_KEYPOINT_IDS = (13, 14, 15, 16) + + +def _resolve_frame_path(frame_path: str | None) -> Path | None: + if not frame_path: + return None + candidate = Path(frame_path) + return candidate if candidate.is_absolute() else PROJECT_ROOT / candidate + + +def _load_pose_model(): + for model_path in POSE_MODEL_CANDIDATES: + if model_path.exists(): + return YOLO(str(model_path)) + try: + return YOLO("yolov8n-pose.pt") + except Exception: + return None + + +def _extract_leg_keypoints(model, image, bbox, min_pose_confidence): + x1, y1, x2, y2 = bbox + crop = image[max(y1, 0):max(y2, 0), max(x1, 0):max(x2, 0)] + if crop.size == 0: + return None + + result = model(crop, verbose=False)[0] + if result.keypoints is None or len(result.keypoints.data) == 0: + return None + + keypoint_tensor = result.keypoints.data[0] + if keypoint_tensor is None: + return None + + keypoints = keypoint_tensor.tolist() + visible_keypoints = {} + for idx in LEG_KEYPOINT_IDS: + if idx >= len(keypoints): + continue + point = keypoints[idx] + if len(point) < 3 or float(point[2]) < min_pose_confidence: + continue + visible_keypoints[idx] = (float(point[0]), float(point[1])) + + if len(visible_keypoints) < 2: + return None + + return visible_keypoints + + +def _build_track_pose_sequences(frames, frame_tracks, tracking_summary, pose_model, min_bbox_height, min_pose_confidence): + frame_entries = { + frame.get("frame_id"): frame + for frame in frames or [] + } + walking_track_ids = set(tracking_summary.get("walking_track_ids", [])) + pose_sequences = {track_id: [] for track_id in walking_track_ids} + + for frame_track in frame_tracks: + frame_id = frame_track.get("frame_id") + frame_entry = frame_entries.get(frame_id, {}) + resolved_frame_path = _resolve_frame_path(frame_entry.get("frame_path") or frame_track.get("frame_path")) + if resolved_frame_path is None: + resolved_frame_path = _resolve_frame_path(frame_track.get("annotated_frame_path")) + if resolved_frame_path is None or not resolved_frame_path.exists(): + continue + + image = cv2.imread(str(resolved_frame_path)) + if image is None: + continue + + for tracked in frame_track.get("tracked_detections", []): + track_id = tracked.get("track_id") + if track_id not in pose_sequences: + continue + + bbox = tracked.get("bbox", []) + if len(bbox) != 4: + continue + + bbox_height = float(tracked.get("bbox_height", 0.0)) + if bbox_height < min_bbox_height: + continue + + leg_keypoints = _extract_leg_keypoints( + pose_model, + image, + bbox, + min_pose_confidence, + ) + if leg_keypoints is None: + continue + + pose_sequences[track_id].append( + { + "frame_id": frame_id, + "bbox_height": max(bbox_height, 1.0), + "leg_keypoints": leg_keypoints, + } + ) + + return pose_sequences + + +def _pose_leg_motion_score(sequence): + if len(sequence) < 3: + return 0.0 + + normalized_steps = [] + for previous, current in zip(sequence, sequence[1:]): + shared_ids = set(previous["leg_keypoints"]).intersection(current["leg_keypoints"]) + if len(shared_ids) < 2: + continue + + step_magnitudes = [] + for keypoint_id in shared_ids: + prev_x, prev_y = previous["leg_keypoints"][keypoint_id] + curr_x, curr_y = current["leg_keypoints"][keypoint_id] + dx = curr_x - prev_x + dy = curr_y - prev_y + step_magnitudes.append((dx * dx + dy * dy) ** 0.5) + + if not step_magnitudes: + continue + + avg_height = max((previous["bbox_height"] + current["bbox_height"]) / 2.0, 1.0) + normalized_steps.append((sum(step_magnitudes) / len(step_magnitudes)) / avg_height) + + if not normalized_steps: + return 0.0 + + return sum(normalized_steps) / len(normalized_steps) + + +def refine_tracking_summary_with_pose( + frames, + frame_tracks, + tracking_summary, + min_bbox_height=60.0, + min_pose_confidence=0.25, + min_pose_motion_score=0.03, +): + """Validate walking tracks with leg-keypoint motion when a local pose model is available.""" + pose_model = _load_pose_model() + if pose_model is None: + return tracking_summary + + refined_summary = dict(tracking_summary) + tracks = [dict(track) for track in tracking_summary.get("tracks", [])] + pose_sequences = _build_track_pose_sequences( + frames, + frame_tracks, + tracking_summary, + pose_model, + min_bbox_height, + min_pose_confidence, + ) + + updated_walking_track_ids = [] + updated_stationary_track_ids = set(tracking_summary.get("stationary_track_ids", [])) + + for track in tracks: + track_id = track.get("track_id") + if not track.get("is_walking"): + continue + + pose_motion_score = _pose_leg_motion_score(pose_sequences.get(track_id, [])) + track["pose_used"] = bool(pose_sequences.get(track_id)) + track["pose_motion_score"] = round(pose_motion_score, 4) + + if track["pose_used"] and pose_motion_score < min_pose_motion_score: + track["is_walking"] = False + track["is_stationary"] = True + track["movement_state"] = "stationary" + updated_stationary_track_ids.add(track_id) + continue + + updated_walking_track_ids.append(track_id) + + refined_summary["tracks"] = tracks + refined_summary["walking_track_ids"] = sorted(updated_walking_track_ids) + refined_summary["walking_track_count"] = len(updated_walking_track_ids) + refined_summary["stationary_track_ids"] = sorted(updated_stationary_track_ids) + refined_summary["stationary_track_count"] = len(updated_stationary_track_ids) + return refined_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 new file mode 100644 index 00000000..8f9bcb9c --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/tracking.py @@ -0,0 +1,425 @@ +"""Lightweight person tracking for crowd behaviour analytics.""" + +from math import sqrt +from pathlib import Path +import shutil + +import cv2 + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_ROOT = PROJECT_ROOT / "crowd_behaviour_analytics" / "output" + + +def _centroid(bbox): + x1, y1, x2, y2 = bbox + return ((x1 + x2) / 2.0, (y1 + y2) / 2.0) + + +def _bbox_size(bbox): + x1, y1, x2, y2 = bbox + return max(x2 - x1, 1), max(y2 - y1, 1) + + +def _bbox_iou(box_a, box_b): + ax1, ay1, ax2, ay2 = box_a + bx1, by1, bx2, by2 = box_b + + inter_x1 = max(ax1, bx1) + inter_y1 = max(ay1, by1) + inter_x2 = min(ax2, bx2) + inter_y2 = min(ay2, by2) + + inter_w = max(0, inter_x2 - inter_x1) + inter_h = max(0, inter_y2 - inter_y1) + inter_area = inter_w * inter_h + + area_a = max(ax2 - ax1, 0) * max(ay2 - ay1, 0) + area_b = max(bx2 - bx1, 0) * max(by2 - by1, 0) + union_area = max(area_a + area_b - inter_area, 1) + + return inter_area / union_area + + +def _distance(point_a, point_b): + return sqrt((point_a[0] - point_b[0]) ** 2 + (point_a[1] - point_b[1]) ** 2) + + +def _direction_consistency(history): + """Measure how consistently a track moves in one direction across updates.""" + if len(history) < 3: + return 0.0 + + step_vectors = [] + for previous, current in zip(history, history[1:]): + prev_centroid = previous.get("centroid", [0.0, 0.0]) + curr_centroid = current.get("centroid", [0.0, 0.0]) + dx = float(curr_centroid[0]) - float(prev_centroid[0]) + dy = float(curr_centroid[1]) - float(prev_centroid[1]) + magnitude = sqrt(dx * dx + dy * dy) + if magnitude < 1e-6: + continue + step_vectors.append((dx / magnitude, dy / magnitude)) + + if len(step_vectors) < 2: + return 0.0 + + alignment_scores = [] + for previous, current in zip(step_vectors, step_vectors[1:]): + alignment_scores.append((previous[0] * current[0]) + (previous[1] * current[1])) + + positive_alignment = [score for score in alignment_scores if score > 0] + if not positive_alignment: + return 0.0 + + return sum(positive_alignment) / len(alignment_scores) + + +def track_people(frames, max_distance=80.0, min_iou=0.1, max_missed_time=3.0): + """Associate detections across frames using IoU and centroid distance.""" + active_tracks = {} + track_histories = {} + frame_tracks = [] + next_track_id = 1 + + sorted_frames = sorted(frames or [], key=lambda frame: frame.get("frame_id", 0)) + + for frame in sorted_frames: + timestamp = float(frame.get("timestamp", 0.0)) + detections = frame.get("people_detections", []) + used_track_ids = set() + tracked_detections = [] + + for detection in detections: + bbox = detection.get("bbox", []) + if len(bbox) != 4: + continue + + centroid = _centroid(bbox) + best_match = None + best_score = None + + for track_id, track in active_tracks.items(): + if track_id in used_track_ids: + continue + + time_gap = max(timestamp - track["timestamp"], 0.0001) + if time_gap > max_missed_time: + continue + + centroid_distance = _distance(centroid, track["centroid"]) + iou = _bbox_iou(bbox, track["bbox"]) + + # Prefer stronger IoU matches; fall back to centroid distance when IoU is weak. + if iou >= min_iou: + score = (2.0 * iou) - (centroid_distance / max(max_distance, 1.0)) + elif centroid_distance <= max_distance: + score = 0.05 - (centroid_distance / max(max_distance, 1.0)) + else: + continue + + if best_score is None or score > best_score: + best_score = score + best_match = track_id + + if best_match is None: + track_id = next_track_id + next_track_id += 1 + speed = 0.0 + normalized_speed = 0.0 + direction = (0.0, 0.0) + history = [] + else: + previous = active_tracks[best_match] + delta_t = max(timestamp - previous["timestamp"], 0.0001) + dx = centroid[0] - previous["centroid"][0] + dy = centroid[1] - previous["centroid"][1] + pixel_distance = _distance(centroid, previous["centroid"]) + speed = pixel_distance / delta_t + _, bbox_height = _bbox_size(bbox) + previous_height = previous["bbox_height"] + avg_height = max((bbox_height + previous_height) / 2.0, 1.0) + normalized_speed = pixel_distance / avg_height + direction = (round(dx, 2), round(dy, 2)) + track_id = best_match + history = track_histories.get(track_id, []) + + bbox_width, bbox_height = _bbox_size(bbox) + track_entry = { + "track_id": track_id, + "bbox": bbox, + "centroid": [round(centroid[0], 2), round(centroid[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, + "confidence": detection.get("confidence", 0.0), + } + tracked_detections.append(track_entry) + + history = history + [ + { + "frame_id": frame.get("frame_id"), + "timestamp": timestamp, + "centroid": track_entry["centroid"], + "speed": track_entry["speed"], + "normalized_speed": track_entry["normalized_speed"], + "bbox_height": bbox_height, + } + ] + + track_histories[track_id] = history + active_tracks[track_id] = { + "centroid": centroid, + "timestamp": timestamp, + "bbox_height": bbox_height, + "bbox": bbox, + } + used_track_ids.add(track_id) + + frame_tracks.append( + { + "frame_id": frame.get("frame_id"), + "timestamp": timestamp, + "frame_path": frame.get("frame_path"), + "annotated_frame_path": frame.get("annotated_frame_path"), + "tracked_detections": tracked_detections, + } + ) + + return frame_tracks, track_histories + + +def summarise_tracks( + track_histories, + stationary_motion_threshold=0.06, + walking_motion_threshold=0.12, + running_motion_threshold=0.9, + min_history_for_motion=3, +): + """Build tracking summary for anomaly/event logic.""" + track_summaries = [] + stationary_track_ids = [] + walking_track_ids = [] + running_track_ids = [] + + for track_id, history in track_histories.items(): + speeds = [entry["speed"] for entry in history] + normalized_speeds = [entry.get("normalized_speed", 0.0) for entry in history] + max_speed = max(speeds, default=0.0) + avg_speed = sum(speeds) / len(speeds) if speeds else 0.0 + 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] + 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 + ) + first_centroid = history[0].get("centroid", [0.0, 0.0]) + last_centroid = history[-1].get("centroid", [0.0, 0.0]) + displacement = _distance(first_centroid, last_centroid) + 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 + 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) + direction_consistency = _direction_consistency(history) + is_running = ( + has_motion_history + and avg_normalized_speed >= 0.55 + and max_normalized_speed >= running_motion_threshold + and normalized_displacement >= 0.9 + ) + 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 + ) + 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 + ) + 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 max_normalized_speed < running_motion_threshold + 0.55 + ) + is_stationary = ( + (not has_motion_history) + or ( + avg_normalized_speed <= stationary_motion_threshold + and max_normalized_speed <= 0.12 + and normalized_displacement <= 0.18 + ) + ) + + if is_running: + running_track_ids.append(track_id) + movement_state = "running" + elif is_walking: + walking_track_ids.append(track_id) + movement_state = "walking" + elif is_stationary: + stationary_track_ids.append(track_id) + movement_state = "stationary" + else: + movement_state = "stationary" + stationary_track_ids.append(track_id) + + track_summaries.append( + { + "track_id": track_id, + "history_length": history_length, + "avg_speed": round(avg_speed, 2), + "max_speed": round(max_speed, 2), + "avg_normalized_speed": round(avg_normalized_speed, 4), + "max_normalized_speed": round(max_normalized_speed, 4), + "normalized_displacement": round(normalized_displacement, 4), + "height_variation": round(height_variation, 4), + "direction_consistency": round(direction_consistency, 4), + "is_stationary": is_stationary, + "is_walking": is_walking, + "is_running": is_running, + "movement_state": movement_state, + } + ) + + return { + "track_count": len(track_summaries), + "stationary_track_count": len(stationary_track_ids), + "stationary_track_ids": stationary_track_ids, + "walking_track_count": len(walking_track_ids), + "walking_track_ids": walking_track_ids, + "running_track_count": len(running_track_ids), + "running_track_ids": running_track_ids, + "tracks": track_summaries, + } + + +def build_frame_activity_series(frame_tracks, tracking_summary): + """Return per-frame movement counts for stationary, walking, and running tracks.""" + walking_track_ids = set(tracking_summary.get("walking_track_ids", [])) + running_track_ids = set(tracking_summary.get("running_track_ids", [])) + stationary_track_ids = set(tracking_summary.get("stationary_track_ids", [])) + + activity_series = [] + for frame in frame_tracks: + walking_count = 0 + running_count = 0 + stationary_count = 0 + + for tracked in frame.get("tracked_detections", []): + track_id = tracked.get("track_id") + if track_id in running_track_ids: + running_count += 1 + elif track_id in walking_track_ids: + walking_count += 1 + elif track_id in stationary_track_ids: + stationary_count += 1 + + activity_series.append( + { + "frame_id": frame.get("frame_id"), + "timestamp": frame.get("timestamp", 0.0), + "walking_count": walking_count, + "running_count": running_count, + "stationary_count": stationary_count, + "active_count": walking_count + running_count, + "annotated_frame_path": frame.get("annotated_frame_path"), + } + ) + + return activity_series + + +def save_motion_annotations(frame_tracks, tracking_summary, video_id=None): + """Save annotated frames highlighting useful movement states for frontend visuals.""" + if not frame_tracks or not tracking_summary: + return [] + + safe_video_id = video_id or "unknown_video" + output_dir = OUTPUT_ROOT / safe_video_id + try: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + output_dir = OUTPUT_ROOT / f"{safe_video_id}_artifacts" + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + artifact_paths = [] + stationary_track_ids = set(tracking_summary.get("stationary_track_ids", [])) + walking_track_ids = set(tracking_summary.get("walking_track_ids", [])) + running_track_ids = set(tracking_summary.get("running_track_ids", [])) + if not stationary_track_ids and not walking_track_ids and not running_track_ids: + return [] + + highlight_dynamic_only = bool(walking_track_ids or running_track_ids) + + for frame in frame_tracks: + source_path = frame.get("annotated_frame_path") + if not source_path: + continue + + resolved_path = Path(source_path) + if not resolved_path.is_absolute(): + resolved_path = PROJECT_ROOT / resolved_path + + image = cv2.imread(str(resolved_path)) + if image is None: + continue + + wrote_annotation = False + + for tracked in frame.get("tracked_detections", []): + if tracked["track_id"] in running_track_ids: + label = f"RUNNING T{tracked['track_id']}" + color = (0, 0, 255) + elif tracked["track_id"] in walking_track_ids: + label = f"WALKING T{tracked['track_id']}" + color = (0, 165, 255) + elif tracked["track_id"] in stationary_track_ids: + if highlight_dynamic_only: + continue + label = f"STATIONARY T{tracked['track_id']}" + color = (0, 255, 0) + else: + continue + + x1, y1, x2, y2 = tracked["bbox"] + cv2.rectangle(image, (x1, y1), (x2, y2), color, 2) + label_y = min(y2 + 22, image.shape[0] - 10) + cv2.putText( + image, + label, + (x1, label_y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + color, + 2, + ) + wrote_annotation = True + + if not wrote_annotation: + continue + + output_path = output_dir / f"motion_frame_{int(frame.get('frame_id', 0)):04d}.jpg" + if cv2.imwrite(str(output_path), image): + artifact_paths.append(str(output_path.relative_to(PROJECT_ROOT)).replace("\\", "/")) + + return artifact_paths diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/vision_analysis.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/vision_analysis.py new file mode 100644 index 00000000..80678e56 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_behaviour_analytics/vision_analysis.py @@ -0,0 +1,96 @@ +"""Vision-analysis helpers for crowd behaviour analytics.""" + +from pathlib import Path + +import cv2 +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def resolve_frame_paths(input_data): + """Collect annotated frame paths for behaviour analysis.""" + legacy_frame_paths = input_data.get("frame_paths", []) + if legacy_frame_paths: + return legacy_frame_paths + + frame_entries = input_data.get("frames", []) + resolved_paths = [] + for frame in frame_entries: + annotated_path = frame.get("annotated_frame_path") + if annotated_path: + resolved_paths.append(annotated_path) + return resolved_paths + + +def load_grayscale_frames(frame_paths): + """Load a limited sequence of grayscale frames for motion analysis.""" + if not frame_paths: + return [] + + loaded_frames = [] + + for path in frame_paths[:8]: + resolved_path = Path(path) + if not resolved_path.is_absolute(): + resolved_path = PROJECT_ROOT / resolved_path + + frame = cv2.imread(str(resolved_path), cv2.IMREAD_GRAYSCALE) + if frame is not None: + loaded_frames.append(frame) + + return loaded_frames + + +def extract_motion_features(frames): + """Estimate motion-related features from consecutive frames using optical flow.""" + if len(frames) < 2: + return { + "vision_enabled": False, + "avg_motion_magnitude": 0.0, + "peak_motion_magnitude": 0.0, + "reverse_flow_ratio": 0.0, + "motion_intensity": 0.0, + } + + magnitudes = [] + reverse_flow_ratios = [] + + for idx in range(len(frames) - 1): + prev_frame = frames[idx] + next_frame = frames[idx + 1] + + flow = cv2.calcOpticalFlowFarneback( + prev_frame, + next_frame, + None, + 0.5, + 3, + 15, + 3, + 5, + 1.2, + 0, + ) + + mag, ang = cv2.cartToPolar(flow[..., 0], flow[..., 1], angleInDegrees=True) + magnitudes.append(float(np.mean(mag))) + + angle_bins = ((ang % 360) // 90).astype(int) + bin_counts = np.bincount(angle_bins.ravel(), minlength=4) + dominant_bin = int(np.argmax(bin_counts)) + opposite_bin = (dominant_bin + 2) % 4 + reverse_ratio = float(bin_counts[opposite_bin] / max(bin_counts.sum(), 1)) + reverse_flow_ratios.append(reverse_ratio) + + avg_motion = float(np.mean(magnitudes)) + peak_motion = float(np.max(magnitudes)) + reverse_ratio = float(np.mean(reverse_flow_ratios)) + + return { + "vision_enabled": True, + "avg_motion_magnitude": round(avg_motion, 4), + "peak_motion_magnitude": round(peak_motion, 4), + "reverse_flow_ratio": round(reverse_ratio, 4), + "motion_intensity": round((avg_motion + peak_motion) / 2, 4), + } diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/config.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/config.py index f8af0047..efb99d66 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/config.py +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/config.py @@ -2,15 +2,17 @@ from pathlib import Path CURRENT_DIR=os.path.dirname(os.path.abspath(__file__)) -MODEL_NAME = os.path.join(CURRENT_DIR, "model.pt") # Model downloaded from https://huggingface.co/arnabdhar/YOLOv8-Face-Detection +MODEL_NAME = os.path.join(CURRENT_DIR, "face_model.pt") # Model downloaded from https://huggingface.co/arnabdhar/YOLOv8-Face-Detection +PEOPLE_MODEL_NAME = os.path.join(CURRENT_DIR, "yolov8s.pt") +ANNOTATED_DIR = Path("crowd_detection_output") / "face_detection_results" PERSON_CLASS = None +PEOPLE_ANNOTATED_DIR = Path("crowd_detection_output") / "people_detection_results" - -DEFAULT_CONF = 0.45 -DEFAULT_IOU = 0.40 +DEFAULT_CONF = 0.35 +DEFAULT_IOU = 0.30 ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} -OUTPUT_DIR = Path("detection_output") \ No newline at end of file +OUTPUT_DIR = Path("detection_output") diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/model.pt b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/face_model.pt similarity index 100% rename from 26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/model.pt rename to 26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/face_model.pt 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 399dff9f..0e35965b 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 @@ -3,15 +3,23 @@ import cv2 from ultralytics import YOLO +from pathlib import Path -from .config import DEFAULT_CONF, DEFAULT_IOU, MODEL_NAME +from .config import DEFAULT_CONF, DEFAULT_IOU, MODEL_NAME, PEOPLE_ANNOTATED_DIR, PEOPLE_MODEL_NAME, ANNOTATED_DIR +PROJECT_ROOT = Path(__file__).resolve().parent.parent +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 load_model(): - print(f"[INFO] Loading model: {MODEL_NAME}") - model = YOLO(MODEL_NAME) - print("[INFO] Model ready ā\n") - return model +def load_models(): + print(f"[INFO] Loading face model: {MODEL_NAME}") + face_model = YOLO(MODEL_NAME) + + print(f"[INFO] Loading people model: {PEOPLE_MODEL_NAME}") + people_model = YOLO(PEOPLE_MODEL_NAME) + + print("[INFO] Models ready ā\n") + return face_model, people_model @@ -27,34 +35,113 @@ def detect_faces(model, frame, conf, iou): }) return detections +def detect_people(model, frame, conf, iou): + results = model(frame, conf=conf, iou=iou, verbose=False)[0] + detections = [] + + for box in results.boxes: + cls = int(box.cls[0]) + + # COCO class 0 = person + if cls != 0: + continue + + x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) + + detections.append({ + "bbox": [x1, y1, x2, y2], + "confidence": round(float(box.conf[0]), 4), + }) + + return detections + +def draw_people_boxes(frame, detections): + output = frame.copy() + + for d in detections: + x1, y1, x2, y2 = d["bbox"] + + # Blue boxes for people + cv2.rectangle(output, (x1, y1), (x2, y2), (255, 100, 0), 2) + + label = f"{d['confidence']:.2f}" + cv2.putText(output, label, (x1, y1 - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, + (255, 100, 0), 1) + + return output + +def draw_boxes(frame, detections): + output = frame.copy() + + for d in detections: + x1, y1, x2, y2 = d["bbox"] + + # Draw bounding box around face + cv2.rectangle(output, (x1, y1), (x2, y2), (0, 200, 80), 2) + + # Draw confidence score above the box + label = f"{d['confidence']:.2f}" + cv2.putText(output, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 80), 1) + + # Draw total face count in top left corner + cv2.putText(output, f"Faces: {len(detections)}", (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 220, 100), 2) + + return output def detect_crowd(processed_video: dict) -> dict: - - model = load_model() + face_model, people_model = load_models() all_results = [] + + # create output folder + FACE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + PEOPLE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) for frame_data in processed_video["frames"]: - - frame = cv2.imread(frame_data["frame_path"]) + frame_path = frame_data["frame_path"] + resolved_frame_path = Path(frame_path) + if not resolved_frame_path.is_absolute(): + resolved_frame_path = PROJECT_ROOT / resolved_frame_path + + frame = cv2.imread(str(resolved_frame_path)) if frame is None: print(f"[WARN] Could not read frame {frame_data['frame_id']} ā skipping") continue - detections = detect_faces(model, frame, DEFAULT_CONF, DEFAULT_IOU) + face_detections = detect_faces(face_model, frame, DEFAULT_CONF, DEFAULT_IOU) + people_detections = detect_people(people_model, frame, DEFAULT_CONF, DEFAULT_IOU) + + # save annotated frame + annotated = draw_boxes(frame, face_detections) + face_output_path = FACE_OUTPUT_DIR / f"frame_{frame_data['frame_id']:04d}.jpg" + cv2.imwrite(str(face_output_path), annotated) + + # 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" + 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("\\", "/") all_results.append({ - "frame_id": frame_data["frame_id"], - "timestamp": frame_data["timestamp"], - "person_count": len(detections), - "detections": detections + "frame_id": frame_data["frame_id"], + "timestamp": frame_data["timestamp"], + "frame_path": frame_path, + "annotated_frame_path": people_annotated_frame_path, + "face_annotated_frame_path": face_annotated_frame_path, + "people_annotated_frame_path": people_annotated_frame_path, + "person_count": len(people_detections), + "face_count": len(face_detections), + "face_detections": face_detections, + "people_detections": people_detections, }) return { "video_id": processed_video["video_id"], - "frames": all_results + "frames": all_results, } - \ No newline at end of file + diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/people_model.pt b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/people_model.pt new file mode 100644 index 00000000..85373076 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_detection/people_model.pt @@ -0,0 +1 @@ +Not Found \ No newline at end of file diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/README.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/README.md new file mode 100644 index 00000000..21270c27 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/README.md @@ -0,0 +1,24 @@ +# Crowd Region Preprocessing + +## Objective + +Prepare extracted stadium frames so downstream crowd detection focuses on visible spectator regions instead of the playing field. + +## Approach + +- accept extracted frames from `video_processing` +- generate a field exclusion mask +- preserve only the crowd-visible region in a new frame copy +- return the same frame metadata structure expected by `crowd_detection` + +## Current Mask Strategy + +- use manual polygons when configured +- otherwise detect the green playing field in HSV space +- black out the field region before YOLO person detection runs + +## Output + +- focused frames saved in `output/focused_frames/` +- updated `frame_path` values for downstream services +- per-frame metadata for field and crowd visibility diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/main.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/main.py new file mode 100644 index 00000000..3595c118 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/crowd_region_preprocessing/main.py @@ -0,0 +1,130 @@ +"""Prepare crowd-focused frames before crowd detection runs.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path + +import cv2 +import numpy as np + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CONFIG_PATH = PROJECT_ROOT / "shared" / "config" / "crowd_region_preprocessing_config.json" + + +def load_config() -> dict: + with CONFIG_PATH.open("r", encoding="utf-8") as config_file: + return json.load(config_file) + + +def _resolve_frame_path(frame_path: str) -> Path: + candidate = Path(frame_path) + return candidate if candidate.is_absolute() else PROJECT_ROOT / candidate + + +def _build_polygon_mask(frame_shape: tuple[int, int, int], points: list[list[float]]) -> np.ndarray: + height, width = frame_shape[:2] + polygon = np.array( + [[int(point[0] * width), int(point[1] * height)] for point in points], + dtype=np.int32, + ) + mask = np.zeros((height, width), dtype=np.uint8) + cv2.fillPoly(mask, [polygon], 255) + return mask + + +def _detect_field_mask(frame: np.ndarray, config: dict) -> np.ndarray: + hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) + lower = np.array(config["green_hsv_lower"], dtype=np.uint8) + upper = np.array(config["green_hsv_upper"], dtype=np.uint8) + + mask = cv2.inRange(hsv, lower, upper) + kernel_size = max(1, int(config["morph_kernel_size"])) + kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return np.zeros_like(mask) + + min_ratio = float(config["min_field_area_ratio"]) + min_area = frame.shape[0] * frame.shape[1] * min_ratio + + filtered = np.zeros_like(mask) + for contour in contours: + area = cv2.contourArea(contour) + if area >= min_area: + cv2.drawContours(filtered, [contour], -1, 255, thickness=cv2.FILLED) + + if not np.any(filtered): + return np.zeros_like(mask) + + dilation_size = max(1, int(config["field_mask_dilation_kernel"])) + dilation_kernel = np.ones((dilation_size, dilation_size), dtype=np.uint8) + return cv2.dilate(filtered, dilation_kernel, iterations=1) + + +def _prepare_frame(frame: np.ndarray, config: dict) -> tuple[np.ndarray, dict]: + field_polygon = config.get("field_polygon_normalized", []) + crowd_polygon = config.get("crowd_polygon_normalized", []) + + if field_polygon: + field_mask = _build_polygon_mask(frame.shape, field_polygon) + mask_source = "manual_field_polygon" + else: + field_mask = _detect_field_mask(frame, config) + mask_source = "auto_green_field_mask" + + if crowd_polygon: + crowd_mask = _build_polygon_mask(frame.shape, crowd_polygon) + mask_source = f"{mask_source}+manual_crowd_polygon" + elif np.any(field_mask): + crowd_mask = cv2.bitwise_not(field_mask) + else: + crowd_mask = np.full(frame.shape[:2], 255, dtype=np.uint8) + mask_source = "no_field_mask_detected" + + focused = cv2.bitwise_and(frame, frame, mask=crowd_mask) + field_ratio = round(float(np.count_nonzero(field_mask)) / float(field_mask.size), 4) if np.any(field_mask) else 0.0 + crowd_ratio = round(float(np.count_nonzero(crowd_mask)) / float(crowd_mask.size), 4) + + metadata = { + "mask_source": mask_source, + "field_visible_ratio": field_ratio, + "crowd_visible_ratio": crowd_ratio, + } + return focused, metadata + + +def prepare_crowd_frames(processed_video: dict) -> dict: + config = load_config() + output_dir = PROJECT_ROOT / config["focused_frames_dir"] + output_dir.mkdir(parents=True, exist_ok=True) + + focused_video = deepcopy(processed_video) + focused_frames = [] + + for frame_data in processed_video.get("frames", []): + source_path = _resolve_frame_path(frame_data["frame_path"]) + frame = cv2.imread(str(source_path)) + + if frame is None: + focused_frames.append(frame_data) + continue + + focused_frame, metadata = _prepare_frame(frame, config) + output_name = f"frame_{frame_data['frame_id']:04d}.jpg" + output_path = output_dir / output_name + cv2.imwrite(str(output_path), focused_frame) + + updated_frame = dict(frame_data) + updated_frame["source_frame_path"] = frame_data["frame_path"] + updated_frame["frame_path"] = str(output_path.relative_to(PROJECT_ROOT)).replace("\\", "/") + updated_frame["crowd_focus_metadata"] = metadata + focused_frames.append(updated_frame) + + focused_video["frames"] = focused_frames + return focused_video 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 bcc7e20c..a1d81c9c 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,4 +1,4 @@ -"""Minimal entry point for the density and zoning task.""" + from __future__ import annotations @@ -7,25 +7,58 @@ from typing import Any -def get_zone_definitions(frame_width: int, frame_height: int) -> list[dict[str, Any]]: +def get_zone_definitions( + frame_width: int, + frame_height: int, + rows: int = 2, + cols: int = 2, +) -> list[dict[str, Any]]: """ - Create a simple 2x2 grid of zones. + Create a configurable grid of zones. - Zone layout: - A1 = top-left - A2 = top-right - B1 = bottom-left - B2 = bottom-right + Example for 2x2: + A1 A2 + B1 B2 """ - half_width = frame_width / 2 - half_height = frame_height / 2 + if frame_width <= 0 or frame_height <= 0: + raise ValueError("Frame width and height must be positive integers.") + if rows <= 0 or cols <= 0: + raise ValueError("Rows and cols must be positive integers.") + + zone_width = frame_width / cols + zone_height = frame_height / rows + zones: list[dict[str, Any]] = [] + + 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 + + zones.append( + { + "zone_id": zone_id, + "x_min": x_min, + "y_min": y_min, + "x_max": x_max, + "y_max": y_max, + } + ) + + return zones + + +def is_valid_bbox(bbox: list[float] | None) -> bool: + """Check whether a bounding box is valid.""" + if bbox is None or len(bbox) != 4: + return False - return [ - {"zone_id": "A1", "x_min": 0, "y_min": 0, "x_max": half_width, "y_max": half_height}, - {"zone_id": "A2", "x_min": half_width, "y_min": 0, "x_max": frame_width, "y_max": half_height}, - {"zone_id": "B1", "x_min": 0, "y_min": half_height, "x_max": half_width, "y_max": frame_height}, - {"zone_id": "B2", "x_min": half_width, "y_min": half_height, "x_max": frame_width, "y_max": frame_height}, - ] + x1, y1, x2, y2 = bbox + return x2 > x1 and y2 > y1 def bbox_center(bbox: list[float]) -> tuple[float, float]: @@ -34,8 +67,8 @@ def bbox_center(bbox: list[float]) -> tuple[float, float]: bbox format: [x1, y1, x2, y2] """ - if len(bbox) != 4: - raise ValueError("Bounding box must contain exactly 4 values: [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.") x1, y1, x2, y2 = bbox center_x = (x1 + x2) / 2 @@ -43,14 +76,33 @@ def bbox_center(bbox: list[float]) -> tuple[float, float]: return center_x, center_y -def find_zone(center_x: float, center_y: float, zones: list[dict[str, Any]]) -> str | None: +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 + + +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) + 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 None @@ -70,35 +122,50 @@ def normalize_counts(zone_counts: dict[str, int]) -> dict[str, float]: } +def classify_density(density: float) -> str: + """Convert normalized density into a label.""" + if density == 0: + return "Low" + if density < 0.67: + return "Medium" + return "High" + + def analyze_density(input_data: dict[str, Any]) -> dict[str, Any]: """Calculate zone counts and density values from detection results.""" video_id = input_data.get("video_id", "unknown_video") frames = input_data.get("frames", []) - # Configurable frame size for simple first version frame_width = input_data.get("frame_width", 500) frame_height = input_data.get("frame_height", 500) - zones = get_zone_definitions(frame_width, frame_height) + 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) + + zones = get_zone_definitions(frame_width, frame_height, grid_rows, grid_cols) - # Initialize counts for all zones zone_counts = {zone["zone_id"]: 0 for zone in zones} + skipped_invalid_bbox = 0 + skipped_low_confidence = 0 - # Process detections frame by frame for frame in frames: - detections = frame.get("detections", []) + detections = frame.get("people_detections", []) for detection in detections: bbox = detection.get("bbox") - if not bbox: + confidence = detection.get("confidence", 1.0) + + if confidence < confidence_threshold: + skipped_low_confidence += 1 continue - try: - center_x, center_y = bbox_center(bbox) - except ValueError: + if not is_valid_bbox(bbox): + skipped_invalid_bbox += 1 continue - zone_id = find_zone(center_x, center_y, zones) + center_x, center_y = bbox_center(bbox) + zone_id = find_zone(center_x, center_y, zones, frame_width, frame_height) if zone_id is not None: zone_counts[zone_id] += 1 @@ -107,11 +174,22 @@ def analyze_density(input_data: dict[str, Any]) -> dict[str, Any]: return { "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 ], @@ -119,21 +197,24 @@ def analyze_density(input_data: dict[str, Any]) -> dict[str, Any]: if __name__ == "__main__": - sample_input = { - "video_id": "match_01", - "frame_width": 500, - "frame_height": 500, + "video_id": "crowd_video_test", + "frame_width": 1280, + "frame_height": 720, + "grid_rows": 2, + "grid_cols": 2, + "confidence_threshold": 0.50, "frames": [ { "frame_id": 1, - "timestamp": 0.04, - "person_count": 4, + "timestamp": 0.03, "detections": [ - {"bbox": [80, 80, 120, 120], "confidence": 0.95}, # A1 - {"bbox": [140, 100, 180, 140], "confidence": 0.92}, # A1 - {"bbox": [280, 150, 320, 190], "confidence": 0.90}, # A2 - {"bbox": [400, 350, 440, 390], "confidence": 0.88}, # B2 + {"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}, ], } ], @@ -142,10 +223,10 @@ def analyze_density(input_data: dict[str, Any]) -> dict[str, Any]: result = analyze_density(sample_input) os.makedirs("output", exist_ok=True) - output_path = os.path.join("output", "density_summary.json") + output_path = os.path.join("output", "density_summary_sprint2.json") with open(output_path, "w", encoding="utf-8") as file: json.dump(result, file, indent=2) print(json.dumps(result, indent=2)) - print(f"\nSaved output to: {output_path}") \ No newline at end of file + print(f"\nSaved output to: {output_path}") 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 d933869f..c1212b34 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 @@ -4,64 +4,131 @@ import os from typing import Dict, List +import matplotlib + +matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np +def validate_input(input_data: Dict) -> None: + """Validate incoming heatmap input data.""" + if not isinstance(input_data, dict): + raise ValueError("Input data must be a dictionary.") + + if "video_id" not in input_data or not input_data["video_id"]: + raise ValueError("Missing or empty 'video_id'.") + + if "zones" not in input_data: + raise ValueError("Missing 'zones' field.") + + if not isinstance(input_data["zones"], list): + raise ValueError("'zones' must be a list.") + + if len(input_data["zones"]) == 0: + raise ValueError("'zones' list cannot be empty.") + + required_zone_fields = {"zone_id", "person_count", "density"} + + for index, zone in enumerate(input_data["zones"]): + if not isinstance(zone, dict): + raise ValueError(f"Zone at index {index} must be a dictionary.") + + missing_fields = required_zone_fields - zone.keys() + if missing_fields: + raise ValueError( + f"Zone at index {index} is missing fields: {', '.join(sorted(missing_fields))}" + ) + + def generate_heatmap(input_data: Dict) -> Dict: - """Generate a basic heatmap image from zone density data.""" + """Generate a validated and schema-compliant heatmap image from zone density data.""" + validate_input(input_data) - video_id = input_data["video_id"] + video_id = str(input_data["video_id"]) zones: List[Dict] = input_data["zones"] - if not zones: - raise ValueError("Zones list is empty.") - output_dir = "output" os.makedirs(output_dir, exist_ok=True) - densities = [zone["density"] for zone in zones] - zone_ids = [zone["zone_id"] for zone in zones] - num_zones = len(zones) cols = int(np.ceil(np.sqrt(num_zones))) rows = int(np.ceil(num_zones / cols)) - heatmap_array = np.zeros((rows, cols)) + heatmap_array = np.full((rows, cols), np.nan) labels = [["" for _ in range(cols)] for _ in range(rows)] for index, zone in enumerate(zones): row = index // cols col = index % cols - heatmap_array[row, col] = zone["density"] - labels[row][col] = f"{zone['zone_id']}\nCount: {zone['person_count']}\nDensity: {zone['density']:.2f}" + + zone_id = str(zone["zone_id"]) + + try: + density = float(zone["density"]) + except (TypeError, ValueError): + raise ValueError(f"Density for zone '{zone_id}' must be numeric.") + + 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.") + + heatmap_array[row, col] = density + labels[row][col] = ( + f"{zone_id}\n" + f"Count: {person_count}\n" + f"Density: {density:.2f}" + ) fig, ax = plt.subplots(figsize=(8, 6)) - im = ax.imshow(heatmap_array, cmap="hot", interpolation="nearest") + + cmap = plt.cm.YlOrRd.copy() + cmap.set_bad(color="lightgrey") + + im = ax.imshow( + heatmap_array, + cmap=cmap, + interpolation="nearest", + vmin=0, + vmax=1, + ) 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="white", - fontsize=9, + color=text_color, + fontsize=10, + fontweight="bold", ) - ax.set_title(f"Heatmap for {video_id}") - ax.set_xticks([]) - ax.set_yticks([]) + 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") + cbar.set_label("Density", fontsize=12) image_path = os.path.join(output_dir, f"heatmap_{video_id}.png") plt.tight_layout() - plt.savefig(image_path, dpi=200) + plt.savefig(image_path, dpi=200, bbox_inches="tight") plt.close() return { @@ -74,14 +141,14 @@ def generate_heatmap(input_data: Dict) -> Dict: if __name__ == "__main__": sample_input = { - "video_id": "match_01", + "video_id": "match_02", "zones": [ - {"zone_id": "A1", "person_count": 8, "density": 0.72}, - {"zone_id": "A2", "person_count": 5, "density": 0.45}, - {"zone_id": "A3", "person_count": 10, "density": 0.88}, - {"zone_id": "A4", "person_count": 3, "density": 0.20}, + {"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)) \ No newline at end of file + print(json.dumps(result, indent=2)) diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/crowd_region_preprocessing_config.json b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/crowd_region_preprocessing_config.json new file mode 100644 index 00000000..cbcabc8c --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/crowd_region_preprocessing_config.json @@ -0,0 +1,10 @@ +{ + "focused_frames_dir": "crowd_region_preprocessing/output/focused_frames", + "green_hsv_lower": [25, 30, 30], + "green_hsv_upper": [95, 255, 255], + "morph_kernel_size": 9, + "field_mask_dilation_kernel": 21, + "min_field_area_ratio": 0.01, + "field_polygon_normalized": [], + "crowd_polygon_normalized": [] +} diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/video_processing_config.json b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/video_processing_config.json index 24052606..c769302b 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/video_processing_config.json +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/config/video_processing_config.json @@ -1,5 +1,5 @@ { - "sample_rate": 30, + "sample_rate": 5, "output_resolution": [640, 640], "extracted_frames_dir": "video_processing/data/extracted_frames" -} \ No newline at end of file +} diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/crowd_pipeline_schema.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/crowd_pipeline_schema.md new file mode 100644 index 00000000..477cda29 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/crowd_pipeline_schema.md @@ -0,0 +1,218 @@ +# Crowd Pipeline Service Schema + +## Endpoint + +`POST /process-crowd-detection` + +## Purpose + +This frontend-facing service runs the full crowd monitoring flow in one request. + +It combines: + +- video processing +- crowd detection +- density zoning +- heatmap generation +- crowd behaviour analytics +- crowd allocation risk zone + +The frontend should use this endpoint instead of calling the individual module endpoints. + +## Input JSON + +```json +{ + "video_id": "match_01", + "video_path": "data/raw/match_01.mp4" +} +``` + +## Input Fields + +- `video_id` - string - unique identifier for the video +- `video_path` - string - path to the source video file + +## Output JSON + +```json +{ + "video_id": "match_01", + "crowd_detection": { + "video_id": "match_01", + "frames": [ + { + "frame_id": 1, + "timestamp": 0.04, + "frame_path": "video_processing/data/extracted_frames/frame_0001.jpg", + "annotated_frame_path": "crowd_detection_output/people_detection_results/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, + "face_count": 1, + "face_detections": [ + { + "bbox": [120, 60, 155, 100], + "confidence": 0.91 + } + ], + "people_detections": [ + { + "bbox": [100, 50, 160, 180], + "confidence": 0.93 + } + ] + } + ] + }, + "density_zoning": [ + { + "zone_id": "A1", + "person_count": 8, + "density": 0.72 + } + ], + "heatmap": { + "image_path": "output/heatmap_match_01.png" + }, + "crowd_behaviour_analytics": { + "video_id": "match_01", + "crowd_state": "dispersing", + "zones": [ + { + "zone_id": "A1", + "person_count": 8, + "density": 0.72 + } + ], + "event_flags": [ + "walking_detection", + "stationary_detection" + ], + "artifact_paths": [ + "output/heatmap_match_01.png", + "crowd_behaviour_analytics/output/match_01/motion_frame_0001.jpg" + ], + "vision_metrics": { + "vision_enabled": true, + "avg_motion_magnitude": 0.4599, + "peak_motion_magnitude": 0.5651, + "reverse_flow_ratio": 0.0883, + "motion_intensity": 0.5125, + "tracking": { + "track_count": 3, + "stationary_track_count": 1, + "stationary_track_ids": [1], + "walking_track_count": 1, + "walking_track_ids": [2], + "running_track_count": 1, + "running_track_ids": [3], + "tracks": [ + { + "track_id": 2, + "history_length": 23, + "avg_speed": 12.81, + "max_speed": 32.17, + "avg_normalized_speed": 0.064, + "max_normalized_speed": 0.1429, + "normalized_displacement": 0.8137, + "height_variation": 0.3806, + "is_stationary": false, + "is_walking": true, + "is_running": false, + "movement_state": "walking" + } + ] + }, + "anomaly_model": { + "model_enabled": true, + "anomaly_track_ids": [3], + "running_track_ids": [3], + "anomaly_count": 1, + "track_scores": [ + { + "track_id": 3, + "history_length": 18, + "avg_speed": 19.1, + "avg_normalized_speed": 0.2079, + "max_normalized_speed": 1.6892, + "normalized_displacement": 0.1438, + "anomaly_score": 0.0158, + "is_anomaly": true + } + ] + } + } + }, + "crowd_allocation_risk_zone": { + "video_id": "match_01", + "zones": [ + { + "zone_id": "A1", + "risk_level": "very_low", + "flagged": false + } + ], + "recommendations": [ + "All zones within safe thresholds - continue monitoring" + ] + } +} +``` + +## Top-Level Output Fields + +- `video_id` - string - same video identifier from the request +- `crowd_detection` - object - people and face detection output for each processed frame +- `density_zoning` - list - zone-level person counts and density values +- `heatmap` - object - generated heatmap image path +- `crowd_behaviour_analytics` - object - crowd state, movement analytics, event flags, and artifact paths +- `crowd_allocation_risk_zone` - object - zone risk levels and recommendations + +## Crowd Detection Fields + +- `frames` - list - processed frame results +- `frame_id` - integer - frame number +- `timestamp` - number - time in seconds for the frame +- `frame_path` - string or null - extracted frame image path +- `annotated_frame_path` - string or null - default annotated frame path +- `face_annotated_frame_path` - string or null - face detection annotated image path +- `people_annotated_frame_path` - string or null - people detection annotated image path +- `person_count` - integer - number of detected people +- `face_count` - integer or null - number of detected faces +- `face_detections` - list - detected face bounding boxes +- `people_detections` - list - detected person bounding boxes +- `bbox` - list of 4 integers - bounding box as `[x1, y1, x2, y2]` +- `confidence` - number - detection confidence score + +## Density And Heatmap Fields + +- `density_zoning` - list - density result per zone +- `zone_id` - string - zone identifier such as `A1`, `A2`, `B1`, `B2` +- `person_count` - integer - people counted in the zone +- `density` - number - calculated density value for the zone +- `heatmap.image_path` - string - saved heatmap image path + +## Behaviour Analytics Fields + +- `crowd_state` - string - high-level crowd state such as `stable`, `dispersing`, or `increasing_density` +- `zones` - list - zone density data used for behaviour analysis +- `event_flags` - list - detected event labels such as `walking_detection`, `stationary_detection`, or `motion_anomaly` +- `artifact_paths` - list - generated image artifacts, including heatmap and motion annotated frames +- `vision_metrics` - object or null - motion and tracking metrics when annotated frames are available +- `tracking` - object - track counts and per-track movement state +- `anomaly_model` - object - anomaly scores and anomaly track identifiers + +## Risk Zone Fields + +- `zones` - list - risk assessment per zone +- `risk_level` - string - zone risk label such as `very_low`, `low`, `medium`, or `high` +- `flagged` - boolean - whether the zone needs attention +- `recommendations` - list - operational recommendations based on risk + +## Notes + +- This schema is the frontend contract for the combined route. +- The individual service schemas remain useful for testing each module separately. +- `crowd_allocation_risk_zone` is generated from the behaviour analytics result. +- `artifact_paths` includes motion frame images only when behaviour analytics receives valid annotated frame paths. diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/detection_schema.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/detection_schema.md index f8bea7a5..7249c676 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/detection_schema.md +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/detection_schema.md @@ -6,7 +6,7 @@ ## Purpose -This service receives a video reference, runs video processing and crowd detection, and returns detection results per frame. +This service receives a video reference, runs video processing and crowd detection, and returns face and people detection results per processed frame. ## Input JSON @@ -31,8 +31,19 @@ This service receives a video reference, runs video processing and crowd detecti { "frame_id": 1, "timestamp": 0.04, + "frame_path": "video_processing/data/extracted_frames/frame_0001.jpg", + "annotated_frame_path": "crowd_detection_output/people_detection_results/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 @@ -53,13 +64,19 @@ This service receives a video reference, runs video processing and crowd detecti - `frames` - list - detection result for each processed frame - `frame_id` - integer - frame number - `timestamp` - number - time in seconds for the frame +- `frame_path` - string - original extracted frame path from video processing +- `annotated_frame_path` - string - default annotated frame path for downstream use; currently same as `people_annotated_frame_path` +- `face_annotated_frame_path` - string - saved frame with face boxes +- `people_annotated_frame_path` - string - saved frame with people boxes - `person_count` - integer - number of detected people in the frame -- `detections` - list - detected people in the frame +- `face_count` - integer - number of detected faces in the frame +- `face_detections` - list - detected faces in the frame +- `people_detections` - list - detected people in the frame - `bbox` - list of 4 integers - bounding box as `[x1, y1, x2, y2]` - `confidence` - number - model confidence score ## Notes -- keep field names stable -- use the same `video_id` through all services +- use `people_detections` for explicit people-detection output +- use `face_detections` for explicit face-detection output - `frames` from this output become the input for the analytics service diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/intelligence_schema.md b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/intelligence_schema.md index b7347e27..181aca81 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/intelligence_schema.md +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/schemas/intelligence_schema.md @@ -27,7 +27,33 @@ This service receives analytics output, analyses crowd behaviour, and returns ri ], "heatmap": { "image_path": "output/heatmap_match_01.png" - } + }, + "frames": [ + { + "frame_id": 1, + "timestamp": 0.04, + "annotated_frame_path": "crowd_detection_output/people_detection_results/frame_0001.jpg", + "people_detections": [ + { + "bbox": [100, 50, 160, 180], + "confidence": 0.93 + } + ], + "face_detections": [] + }, + { + "frame_id": 2, + "timestamp": 0.08, + "annotated_frame_path": "crowd_detection_output/people_detection_results/frame_0002.jpg", + "people_detections": [ + { + "bbox": [104, 52, 164, 182], + "confidence": 0.91 + } + ], + "face_detections": [] + } + ] } ``` @@ -40,6 +66,12 @@ This service receives analytics output, analyses crowd behaviour, and returns ri - `density` - number - calculated density value - `heatmap` - object - generated heatmap result - `image_path` - string - saved output path for the heatmap image +- `frames` - optional list of sequential detection-aware frame records for motion-based analysis +- `frame_id` - integer - frame number in the sequence +- `timestamp` - number - timestamp of the frame in seconds +- `annotated_frame_path` - string - people bbox-annotated frame path from `crowd_detection` +- `people_detections` - list - people detection records used for behaviour tracking +- `face_detections` - list - optional face detection records from `crowd_detection` ## Output JSON @@ -62,7 +94,76 @@ This service receives analytics output, analyses crowd behaviour, and returns ri "recommendations": [ "Monitor zone A1 closely", "Prepare crowd redirection if density increases further" - ] + ], + "event_flags": [ + "running_detection", + "crowd_surge", + "motion_anomaly" + ], + "artifact_paths": [ + "output/heatmap_match_01.png", + "crowd_behaviour_analytics/output/running_frames/motion_frame_0008.jpg" + ], + "vision_metrics": { + "vision_enabled": true, + "avg_motion_magnitude": 0.84, + "peak_motion_magnitude": 1.27, + "reverse_flow_ratio": 0.18, + "motion_intensity": 1.05, + "tracking": { + "track_count": 3, + "walking_track_count": 1, + "walking_track_ids": [2], + "running_track_count": 1, + "running_track_ids": [1], + "tracks": [ + { + "track_id": 1, + "history_length": 4, + "avg_speed": 8.4, + "max_speed": 12.6, + "avg_normalized_speed": 0.42, + "max_normalized_speed": 0.88, + "normalized_displacement": 1.24, + "height_variation": 0.08, + "is_walking": false, + "is_running": true, + "movement_state": "running" + }, + { + "track_id": 2, + "history_length": 4, + "avg_speed": 5.2, + "max_speed": 6.4, + "avg_normalized_speed": 0.22, + "max_normalized_speed": 0.36, + "normalized_displacement": 0.72, + "height_variation": 0.05, + "is_walking": true, + "is_running": false, + "movement_state": "walking" + } + ] + }, + "anomaly_model": { + "model_enabled": true, + "anomaly_track_ids": [1], + "running_track_ids": [1], + "anomaly_count": 1, + "track_scores": [ + { + "track_id": 1, + "history_length": 4, + "avg_speed": 8.4, + "avg_normalized_speed": 0.42, + "max_normalized_speed": 0.88, + "normalized_displacement": 1.24, + "anomaly_score": 0.2174, + "is_anomaly": true + } + ] + } + } } ``` @@ -75,8 +176,20 @@ This service receives analytics output, analyses crowd behaviour, and returns ri - `risk_level` - string - risk classification such as `low`, `medium`, `high` - `flagged` - boolean - whether the zone requires attention - `recommendations` - list of strings - suggested actions or notes +- `event_flags` - optional list of behaviour or anomaly labels from the behaviour analysis module +- `artifact_paths` - optional list of saved output paths for demo or frontend visualisation +- `vision_metrics` - optional summary of motion-analysis outputs from the behaviour-analysis module +- `tracking` - tracking summary generated inside `crowd_behaviour_analytics` +- `walking_track_ids` - tracked people classified as walking-like motion +- `running_track_ids` - tracked people classified as running-like motion +- `movement_state` - per-track label such as `stationary`, `walking`, or `running` +- `anomaly_model` - IsolationForest-based anomaly summary for tracked motion +- `anomaly_track_ids` - tracked people flagged as anomalous motion +- `track_scores` - per-track anomaly details including speed, normalized displacement, and anomaly score ## Notes - this service combines behaviour analysis and risk assessment - keep risk labels stable for backend and dashboard use +- the required response contract is unchanged; `event_flags`, `artifact_paths`, and `vision_metrics` are backward-compatible extensions +- walking/running labels are current movement-state outputs derived from detections, tracking, motion features, and anomaly scoring diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_detection_service.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_detection_service.py index 54972320..94c19b8b 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_detection_service.py +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_detection_service.py @@ -1,6 +1,7 @@ """Service flow for video processing and crowd detection.""" from video_processing.main import process_video +from crowd_region_preprocessing.main import prepare_crowd_frames from crowd_detection.main import detect_crowd @@ -10,7 +11,17 @@ def process_detection(data: dict): video_path = data.get("video_path") processed_video = process_video(video_id, video_path) - detection_result = detect_crowd(processed_video) + if not isinstance(processed_video, dict): + raise RuntimeError("Video processing did not return a valid response") + + if processed_video.get("error"): + raise FileNotFoundError(processed_video["error"]) + + if "video_id" not in processed_video or "frames" not in processed_video: + raise RuntimeError("Video processing returned incomplete output") + + focused_video = prepare_crowd_frames(processed_video) + detection_result = detect_crowd(focused_video) if isinstance(detection_result, dict) and "video_id" not in detection_result: detection_result["video_id"] = video_id diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_intelligence_service.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_intelligence_service.py index 65113788..c84c9762 100644 --- a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_intelligence_service.py +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_intelligence_service.py @@ -14,4 +14,7 @@ def process_intelligence(data: dict): "crowd_state": behaviour_result.get("crowd_state"), "zones": risk_result.get("zones", []), "recommendations": risk_result.get("recommendations", []), + "event_flags": behaviour_result.get("event_flags", []), + "artifact_paths": behaviour_result.get("artifact_paths", []), + "vision_metrics": behaviour_result.get("vision_metrics"), } diff --git a/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_pipeline_service.py b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_pipeline_service.py new file mode 100644 index 00000000..62d1ec31 --- /dev/null +++ b/26_T1/afl_player_tracking_and_crowd_monitoring/Crowd_Monitoring/2026_T1/shared/services/crowd_pipeline_service.py @@ -0,0 +1,183 @@ +"""End-to-end service flow for the full crowd monitoring pipeline.""" + +from pathlib import Path + +import matplotlib.pyplot as plt + +from .crowd_analytics_service import process_analytics +from .crowd_detection_service import process_detection +from crowd_allocation_risk_zone.main import assess_risk +from crowd_behaviour_analytics.main import analyze_behaviour + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _safe_round(value, digits=2): + if value is None: + return None + return round(float(value), digits) + + +def _build_summary(detection_result: dict, behaviour_result: dict, risk_result: dict, analytics_result: dict) -> dict: + frames = detection_result.get("frames", []) + counts = [frame.get("person_count", 0) for frame in frames] + zone_densities = analytics_result.get("zones", []) + flagged_zones = [zone for zone in risk_result.get("zones", []) if zone.get("flagged")] + + highest_density_zone = max(zone_densities, key=lambda zone: zone.get("density", 0), default=None) + highest_risk_zone = flagged_zones[0] if flagged_zones else None + + return { + "total_frames_processed": len(frames), + "peak_person_count": max(counts, default=0), + "crowd_state": behaviour_result.get("crowd_state", "unknown"), + "highest_density_zone": highest_density_zone.get("zone_id") if highest_density_zone else None, + "highest_risk_zone": highest_risk_zone.get("zone_id") if highest_risk_zone else None, + } + + +def _build_peak_crowd_frame(detection_result: dict) -> dict: + frames = detection_result.get("frames", []) + peak_frame = max(frames, key=lambda frame: frame.get("person_count", 0), default=None) + if not peak_frame: + return {} + + return { + "frame_id": peak_frame.get("frame_id"), + "timestamp": peak_frame.get("timestamp"), + "person_count": peak_frame.get("person_count", 0), + "annotated_frame_path": peak_frame.get("annotated_frame_path"), + } + + +def _build_anomaly_visual(behaviour_result: dict) -> dict: + artifact_paths = behaviour_result.get("artifact_paths") or [] + event_flags = behaviour_result.get("event_flags") or [] + activity_series = behaviour_result.get("frame_movement_summary") or behaviour_result.get("frame_activity_series", []) + motion_artifacts = [ + path for path in artifact_paths + if "motion_frame_" in path.replace("\\", "/") + ] + artifact_by_frame = {} + for path in motion_artifacts: + normalized_path = path.replace("\\", "/") + frame_name = normalized_path.rsplit("/", 1)[-1] + frame_token = frame_name.replace("motion_frame_", "").replace(".jpg", "") + try: + artifact_by_frame[int(frame_token)] = path + except ValueError: + continue + + preferred_frame = max( + activity_series, + key=lambda entry: ( + entry.get("walking_count", 0), + entry.get("running_count", 0), + entry.get("active_count", 0), + ), + default=None, + ) + selected_path = None + if preferred_frame: + selected_path = artifact_by_frame.get(preferred_frame.get("frame_id")) + if not selected_path: + selected_path = motion_artifacts[0] if motion_artifacts else (artifact_paths[-1] if artifact_paths else None) + if not selected_path: + return {} + + if preferred_frame and preferred_frame.get("running_count", 0) > 0: + event_type = "running_activity" + elif preferred_frame and preferred_frame.get("walking_count", 0) > 0: + event_type = "walking_or_running_activity" + else: + event_type = event_flags[0] if event_flags else "movement_alert" + + return { + "event_type": event_type, + "image_path": selected_path, + } + + +def _build_time_series_chart(detection_result: dict, behaviour_result: dict, video_id: str | None) -> dict: + frames = detection_result.get("frames", []) + if not frames: + return {} + + person_timestamps = [frame.get("timestamp", 0.0) for frame in frames] + person_counts = [frame.get("person_count", 0) for frame in frames] + + output_dir = PROJECT_ROOT / "analytics_output" / "charts" + output_dir.mkdir(parents=True, exist_ok=True) + safe_video_id = video_id or detection_result.get("video_id") or "unknown_video" + output_path = output_dir / f"{safe_video_id}_crowd_activity_chart.png" + + figure, axis = plt.subplots(figsize=(10, 4.5)) + axis.plot(person_timestamps, person_counts, color="#1f77b4", linewidth=2.4) + axis.set_xlabel("Time (s)") + axis.set_ylabel("Person count") + axis.grid(True, linestyle="--", alpha=0.35) + + figure.suptitle("Person Count Over Time") + figure.tight_layout() + figure.savefig(output_path, dpi=180, bbox_inches="tight") + plt.close(figure) + + return { + "image_path": str(output_path.relative_to(PROJECT_ROOT)).replace("\\", "/") + } + + +def _build_density_extremes(analytics_result: dict, risk_result: dict) -> dict: + risk_by_zone = { + zone.get("zone_id"): zone + for zone in risk_result.get("zones", []) + } + + zone_insights = [] + for zone in analytics_result.get("zones", []): + risk = risk_by_zone.get(zone.get("zone_id"), {}) + zone_insights.append({ + "zone_id": zone.get("zone_id"), + "person_count": zone.get("person_count", 0), + "density": _safe_round(zone.get("density", 0.0), 4), + "risk_level": risk.get("risk_level", "unknown"), + "flagged": risk.get("flagged", False), + }) + + if not zone_insights: + return { + "highest_density_zone": {}, + "lowest_density_zone": {}, + } + + highest_density_zone = max(zone_insights, key=lambda zone: zone.get("density", 0.0)) + lowest_density_zone = min(zone_insights, key=lambda zone: zone.get("density", 0.0)) + return { + "highest_density_zone": highest_density_zone, + "lowest_density_zone": lowest_density_zone, + } + + +def process_crowd_detection(data: dict): + """Run detection, analytics, and intelligence as one frontend-facing flow.""" + detection_result = process_detection(data) + analytics_result = process_analytics(detection_result) + + intelligence_input = { + "video_id": data.get("video_id"), + "zones": analytics_result.get("zones", []), + "heatmap": analytics_result.get("heatmap", {}), + "frames": detection_result.get("frames", []), + } + behaviour_result = analyze_behaviour(intelligence_input) + risk_result = assess_risk(behaviour_result) + + return { + "video_id": data.get("video_id"), + "summary": _build_summary(detection_result, behaviour_result, risk_result, analytics_result), + "peak_crowd_frame": _build_peak_crowd_frame(detection_result), + "anomaly_visual": _build_anomaly_visual(behaviour_result), + "heatmap": analytics_result.get("heatmap", {}), + "time_series_chart": _build_time_series_chart(detection_result, behaviour_result, data.get("video_id")), + "density_extremes": _build_density_extremes(analytics_result, risk_result), + } 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 a1468eb3..f52780e9 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 @@ -1,13 +1,354 @@ """FastAPI entry point for the shared service layer.""" +from pathlib import Path + from fastapi import FastAPI +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles from .routes import router +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + app = FastAPI( title="Crowd Monitoring Services", + description="Crowd monitoring APIs and demo UI.\n\n[Open Demo Page](/demo)", docs_url="/", redoc_url="/redoc", ) -app.include_router(router) \ No newline at end of file + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + """Return actual runtime errors as JSON instead of generic 500 pages.""" + return JSONResponse( + status_code=500, + content={ + "detail": str(exc), + "path": request.url.path, + }, + ) + + +app.mount("/artifacts", StaticFiles(directory=PROJECT_ROOT), name="artifacts") + + +@app.get("/demo", response_class=HTMLResponse) +def demo_page(): + """Simple demo UI for the crowd monitoring pipeline.""" + return """ + + +
+ + +Run the full crowd pipeline and preview the key outputs without working through Swagger. Swagger remains available at /.
+