| |
| """ |
| Fall Detection Inference Script |
| |
| INPUT CONTRACT (what goes in — only this much): |
| pandas DataFrame with per row: |
| x_0, y_0, conf_0, ..., x_16, y_16, conf_16 # 51 keypoint features ONLY |
| x1, y1, x2, y2 # person bounding box (pixels) |
| Total input columns: 55 (51 + 4 bbox). The extra 5 features are NOT input. |
| |
| WHAT THIS SCRIPT EXTRACTS (the extra 5 — computed here, never received): |
| aspect_ratio, nose_relative_y, torso_angle, norm_com_y, head_hip_v_dist |
| |
| FLOW: |
| upstream DataFrame (51 features + bbox) # input contract |
| → extract 5 engineered features in run.py # done here |
| → 56-column pandas DataFrame # 51 + 5 |
| → XGBoost.predict_proba(feature_df) # DataFrame, never CSV |
| |
| Upstream (out of scope): CCTV image → YOLO26x-Pose → 17 keypoints → 51 + bbox. |
| |
| Usage: |
| python scripts/run.py features.csv |
| python scripts/run.py features.csv -o predictions.csv |
| """ |
|
|
| import argparse |
| import json |
| import math |
| from pathlib import Path |
|
|
| import joblib |
| import numpy as np |
| import pandas as pd |
|
|
|
|
| |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| CONFIG_PATH = PROJECT_ROOT / 'config.json' |
| with CONFIG_PATH.open('r', encoding='utf-8') as config_file: |
| APP_CONFIG = json.load(config_file) |
|
|
|
|
| def resolve_project_path(config_value): |
| path = Path(config_value) |
| return path if path.is_absolute() else (PROJECT_ROOT / path).resolve() |
|
|
|
|
| XGBOOST_PATH = str(resolve_project_path(APP_CONFIG['paths']['classifier_model'])) |
| CONF_THRESH = float(APP_CONFIG['thresholds']['keypoint_confidence']) |
| FALL_PROB_THRESH = float(APP_CONFIG['thresholds']['fall_probability']) |
|
|
| KEYPOINT_COUNT = int(APP_CONFIG['feature_schema']['keypoint_count']) |
| MIN_VALID_KEYPOINTS = 5 |
|
|
| |
| |
| |
| |
| KEYPOINT_COLUMNS = [ |
| f'{name}_{i}' |
| for i in range(KEYPOINT_COUNT) |
| for name in ('x', 'y', 'conf') |
| ] |
| BBOX_COLUMNS = ['x1', 'y1', 'x2', 'y2'] |
| INPUT_COLUMNS = KEYPOINT_COLUMNS + BBOX_COLUMNS |
| INPUT_FEATURE_COUNT = len(KEYPOINT_COLUMNS) |
|
|
| |
| |
| |
| ENGINEERED_FEATURE_NAMES = [ |
| 'aspect_ratio', |
| 'nose_relative_y', |
| 'torso_angle', |
| 'norm_com_y', |
| 'head_hip_v_dist', |
| ] |
| ENGINEERED_COUNT = len(ENGINEERED_FEATURE_NAMES) |
|
|
| |
| FEATURE_COLS = KEYPOINT_COLUMNS + ENGINEERED_FEATURE_NAMES |
| MODEL_FEATURE_COUNT = len(FEATURE_COLS) |
|
|
| |
| UPSTREAM_COLUMNS = INPUT_COLUMNS |
|
|
|
|
| def load_model(): |
| """Load the trained XGBoost classifier.""" |
| print(f"Loading XGBoost model: {XGBOOST_PATH}") |
| model = joblib.load(XGBOOST_PATH) |
| print("Model loaded successfully!\n") |
| return model |
|
|
|
|
| def midpoint_norm(xs, ys, i, j): |
| """NaN-aware midpoint of keypoints i and j in normalized space.""" |
| points = [] |
| for idx in (i, j): |
| if not (np.isnan(xs[idx]) or np.isnan(ys[idx])): |
| points.append((xs[idx], ys[idx])) |
| if not points: |
| return np.nan, np.nan |
| return ( |
| float(np.mean([p[0] for p in points])), |
| float(np.mean([p[1] for p in points])), |
| ) |
|
|
|
|
| def extract_engineered_features(kp51, x1, y1, x2, y2, conf_threshold=CONF_THRESH): |
| """Extract the extra 5 features from the input contract (51 keypoints + bbox). |
| |
| These 5 are NOT part of the input — they are computed here: |
| aspect_ratio, nose_relative_y, torso_angle, norm_com_y, head_hip_v_dist |
| |
| Returns them in classifier column order (positions 52-56). |
| """ |
| w, h = x2 - x1, y2 - y1 |
| kpts = np.asarray(kp51, dtype=float).reshape(KEYPOINT_COUNT, 3) |
| xs = kpts[:, 0].copy() |
| ys = kpts[:, 1].copy() |
| confs = kpts[:, 2] |
|
|
| |
| low_conf = confs < conf_threshold |
| xs[low_conf] = np.nan |
| ys[low_conf] = np.nan |
|
|
| |
| sh_mid = midpoint_norm(xs, ys, 5, 6) |
| hip_mid = midpoint_norm(xs, ys, 11, 12) |
|
|
| |
| aspect_ratio = w / h if h > 0 else 0.0 |
|
|
| |
| nose_relative_y = ys[0] |
|
|
| |
| if confs[5] >= conf_threshold and confs[11] >= conf_threshold: |
| if not (np.isnan(sh_mid[0]) or np.isnan(hip_mid[0])): |
| dx = (sh_mid[0] - hip_mid[0]) * w |
| dy = (sh_mid[1] - hip_mid[1]) * h |
| torso_angle = math.degrees(math.atan2(abs(dx), abs(dy) + 1e-6)) |
| else: |
| torso_angle = np.nan |
| else: |
| torso_angle = np.nan |
|
|
| |
| |
| visible = (confs >= conf_threshold) & ~np.isnan(ys) |
| if np.any(visible) and np.sum(confs[visible]) > 0: |
| norm_com_y = float( |
| np.sum(ys[visible] * confs[visible]) / np.sum(confs[visible]) |
| ) |
| else: |
| norm_com_y = np.nan |
|
|
| |
| if confs[0] >= conf_threshold and ( |
| confs[11] >= conf_threshold or confs[12] >= conf_threshold |
| ): |
| if not (np.isnan(hip_mid[1]) or np.isnan(ys[0])): |
| head_hip_v_dist = hip_mid[1] - ys[0] |
| else: |
| head_hip_v_dist = np.nan |
| else: |
| head_hip_v_dist = np.nan |
|
|
| return [ |
| aspect_ratio, |
| nose_relative_y, |
| torso_angle, |
| norm_com_y, |
| head_hip_v_dist, |
| ] |
|
|
|
|
| def build_feature_frame(input_df): |
| """Apply the input contract, then extract the extra 5 → 56-column DataFrame. |
| |
| INPUT CONTRACT (only what goes in — 55 columns): |
| 51 keypoint features (x_0,y_0,conf_0,...,x_16,y_16,conf_16) |
| + bbox (x1,y1,x2,y2) |
| The 5 engineered features are NOT accepted as input. |
| |
| EXTRACTED HERE (never received): |
| aspect_ratio, nose_relative_y, torso_angle, norm_com_y, head_hip_v_dist |
| |
| Returns: |
| feature_df: DataFrame (n × 56) = 51 input + 5 extracted → XGBoost |
| skip_rows: input row indices with < MIN_VALID_KEYPOINTS |
| """ |
| missing = [c for c in INPUT_COLUMNS if c not in input_df.columns] |
| if missing: |
| raise ValueError( |
| f"Input contract violated — missing {len(missing)} required column(s): " |
| f"{', '.join(missing[:10])}" |
| f"{'...' if len(missing) > 10 else ''}. " |
| f"Input contract = {INPUT_FEATURE_COUNT} keypoint features " |
| "(x_0,y_0,conf_0,...,x_16,y_16,conf_16) + bbox (x1,y1,x2,y2) only. " |
| "The extra 5 features are extracted by this script, not required as input." |
| ) |
|
|
| |
| kp_values = input_df[KEYPOINT_COLUMNS].to_numpy(dtype=float) |
| bbox = input_df[BBOX_COLUMNS].to_numpy(dtype=float) |
|
|
| conf_values = kp_values[:, 2::3] |
| valid_counts = np.sum(np.nan_to_num(conf_values, nan=0.0) > 0, axis=1) |
|
|
| rows = [] |
| skip_rows = [] |
| for row_idx, (kp51, box) in enumerate(zip(kp_values, bbox)): |
| if valid_counts[row_idx] < MIN_VALID_KEYPOINTS: |
| skip_rows.append(row_idx) |
| rows.append([np.nan] * MODEL_FEATURE_COUNT) |
| continue |
|
|
| x1, y1, x2, y2 = box |
| |
| engineered = extract_engineered_features(kp51, x1, y1, x2, y2) |
| rows.append(list(kp51) + engineered) |
|
|
| feature_df = pd.DataFrame(rows, columns=FEATURE_COLS, index=input_df.index) |
| return feature_df, skip_rows |
|
|
|
|
| def predict(model, feature_df, skip_rows=None): |
| """Run XGBoost on the 56-column DataFrame (51 input + 5 extracted here). |
| |
| XGBoost receives a pandas DataFrame (n rows × 56 columns), not a CSV. |
| Returns a result DataFrame with prediction + fall_probability columns. |
| """ |
| result = pd.DataFrame(index=feature_df.index) |
| result['prediction'] = pd.NA |
| result['fall_probability'] = np.nan |
|
|
| skip = set(skip_rows or []) |
| scorable_idx = [i for i in feature_df.index if i not in skip] |
|
|
| if not scorable_idx: |
| return result |
|
|
| |
| X = feature_df.loc[scorable_idx, FEATURE_COLS] |
| proba = model.predict_proba(X)[:, 1] |
| predictions = np.where(proba >= FALL_PROB_THRESH, 'Fall', 'Normal') |
|
|
| result.loc[scorable_idx, 'fall_probability'] = proba |
| result.loc[scorable_idx, 'prediction'] = predictions |
| return result |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description=( |
| 'Fall Detection Inference — input contract: 51 keypoint features + bbox only. ' |
| 'run.py extracts the extra 5 features, builds a 56-column DataFrame, ' |
| 'and passes that DataFrame to XGBoost.' |
| ) |
| ) |
| parser.add_argument( |
| 'input_csv', |
| help=( |
| 'Upstream CSV matching the input contract: ' |
| 'x_0..conf_16 (51) + x1,y1,x2,y2 — the 5 engineered features are NOT required' |
| ), |
| ) |
| parser.add_argument( |
| '--output', '-o', default=None, |
| help='Path to save predictions CSV (default: print only)', |
| ) |
| args = parser.parse_args() |
|
|
| input_path = Path(args.input_csv) |
| if not input_path.exists(): |
| print(f"Error: Input CSV not found: {input_path}") |
| return |
|
|
| |
| input_df = pd.read_csv(input_path) |
| if input_df.empty: |
| print("Input CSV has no rows.") |
| return |
|
|
| print(f"Input (contract: {INPUT_FEATURE_COUNT} features + bbox): " |
| f"{input_path} ({len(input_df)} rows, {len(input_df.columns)} columns)") |
|
|
| model = load_model() |
|
|
| |
| feature_df, skip_rows = build_feature_frame(input_df) |
| print(f"Extracted {ENGINEERED_COUNT} features in run.py: {ENGINEERED_FEATURE_NAMES}") |
| print(f"Model input DataFrame: {feature_df.shape[0]} rows × " |
| f"{feature_df.shape[1]} columns " |
| f"({INPUT_FEATURE_COUNT} input + {ENGINEERED_COUNT} extracted)") |
|
|
| |
| pred_df = predict(model, feature_df, skip_rows) |
|
|
| result = pd.concat([input_df, pred_df], axis=1) |
|
|
| for row_idx in skip_rows: |
| print(f" Row {row_idx + 1}: Skipped (insufficient keypoints)") |
|
|
| for row_idx in result.index: |
| if row_idx in set(skip_rows): |
| continue |
| print( |
| f" Row {row_idx + 1}: {result.at[row_idx, 'prediction']} " |
| f"(fall_prob={result.at[row_idx, 'fall_probability']:.3f})" |
| ) |
|
|
| fall_count = int((result['prediction'] == 'Fall').sum()) |
| normal_count = int((result['prediction'] == 'Normal').sum()) |
|
|
| print(f"\nTotal rows: {len(input_df)}") |
| print(f" - Skipped: {len(skip_rows)}") |
| print(f" - Fall: {fall_count}") |
| print(f" - Normal: {normal_count}") |
|
|
| scorable = result['fall_probability'].notna() |
| if scorable.any(): |
| max_fall_prob = float(result.loc[scorable, 'fall_probability'].max()) |
| print("\n" + "=" * 50) |
| if fall_count > 0: |
| print("RESULT: FALL DETECTED") |
| print(f"Confidence: {max_fall_prob:.2%}") |
| else: |
| print("RESULT: NO FALL DETECTED") |
| print(f"Max fall probability: {max_fall_prob:.2%}") |
| print("=" * 50) |
| else: |
| print("\nNo scorable rows (all skipped).") |
|
|
| if args.output: |
| output_path = Path(args.output) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| result.to_csv(output_path, index=False) |
| print(f"\nPredictions saved to: {output_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|