Last active
December 23, 2025 22:31
-
-
Save suobset/4100058dfd3e6a46100e709040002491 to your computer and use it in GitHub Desktop.
SaberStat: Interim Data Analysis | Read more on https://skushagra.com
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Lightsaber Hit Detection - Data Visualization & Analysis | |
| Run: python lightsaber_viz.py | |
| Dependencies: pip install pandas numpy matplotlib scipy | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| from scipy.signal import find_peaks | |
| from pathlib import Path | |
| import glob | |
| # This function detects all the Log files using Regex | |
| def load_all_logs(pattern="../logs/Log*.csv"): | |
| """Load all matching CSV files into a dict keyed by filename.""" | |
| files = glob.glob(pattern) | |
| if not files: | |
| # Try alternate patterns | |
| files = glob.glob("**/Log*.csv", recursive=True) | |
| datasets = {} | |
| for f in sorted(files): | |
| try: | |
| df = pd.read_csv(f) | |
| datasets[Path(f).stem] = df | |
| print(f"Loaded {f}: {len(df)} samples") | |
| except Exception as e: | |
| print(f"Failed to load {f}: {e}") | |
| return datasets | |
| def compute_features(df): | |
| """Derive key features for hit detection.""" | |
| # Acceleration magnitude (user acceleration, excluding gravity) | |
| df['acc_mag'] = np.sqrt( | |
| df['accelerationX']**2 + | |
| df['accelerationY']**2 + | |
| df['accelerationZ']**2 | |
| ) | |
| # Rotation rate magnitude | |
| df['rot_mag'] = np.sqrt( | |
| df['rotationRateX']**2 + | |
| df['rotationRateY']**2 + | |
| df['rotationRateZ']**2 | |
| ) | |
| # Jerk (derivative of acceleration magnitude) | |
| # Using time delta from seconds_elapsed | |
| dt = df['seconds_elapsed'].diff().fillna(0.01) # ~100Hz assumed | |
| df['jerk'] = df['acc_mag'].diff().abs() / dt | |
| df['jerk'] = df['jerk'].fillna(0).replace([np.inf, -np.inf], 0) | |
| # Combined "impact score" - empirical weighting | |
| df['impact_score'] = df['acc_mag'] * 0.6 + df['rot_mag'] * 0.3 + df['jerk'] * 0.0001 | |
| return df | |
| def find_potential_hits(df, acc_threshold=1.5, rot_threshold=2.0, min_distance_ms=200): | |
| """Find peaks that likely correspond to hits.""" | |
| # Convert min_distance to samples (assuming ~100Hz) | |
| sample_rate = 1 / df['seconds_elapsed'].diff().median() | |
| min_samples = int(min_distance_ms / 1000 * sample_rate) | |
| # Find peaks in acceleration magnitude | |
| peaks, properties = find_peaks( | |
| df['acc_mag'], | |
| height=acc_threshold, | |
| distance=max(1, min_samples), | |
| prominence=0.5 | |
| ) | |
| # Filter by rotation threshold | |
| valid_peaks = [p for p in peaks if df.iloc[p]['rot_mag'] > rot_threshold] | |
| return valid_peaks, peaks | |
| def plot_session(df, name, peaks=None, valid_peaks=None): | |
| """Create visualization of a recording session.""" | |
| fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True) | |
| fig.suptitle(f'Lightsaber Session: {name}', fontsize=14, fontweight='bold') | |
| t = df['seconds_elapsed'] | |
| # Plot 1: Acceleration magnitude | |
| axes[0].plot(t, df['acc_mag'], 'b-', linewidth=0.8, label='Acceleration Mag') | |
| axes[0].set_ylabel('Acc Mag (g)') | |
| axes[0].legend(loc='upper right') | |
| axes[0].grid(True, alpha=0.3) | |
| if peaks is not None: | |
| axes[0].plot(t.iloc[peaks], df['acc_mag'].iloc[peaks], 'ro', | |
| markersize=4, alpha=0.5, label='All peaks') | |
| if valid_peaks is not None: | |
| axes[0].plot(t.iloc[valid_peaks], df['acc_mag'].iloc[valid_peaks], 'g^', | |
| markersize=8, label='Likely hits') | |
| axes[0].legend(loc='upper right') | |
| # Plot 2: Rotation magnitude | |
| axes[1].plot(t, df['rot_mag'], 'r-', linewidth=0.8) | |
| axes[1].set_ylabel('Rot Mag (rad/s)') | |
| axes[1].grid(True, alpha=0.3) | |
| if valid_peaks is not None: | |
| axes[1].plot(t.iloc[valid_peaks], df['rot_mag'].iloc[valid_peaks], 'g^', markersize=8) | |
| # Plot 3: Jerk (clipped for visibility) | |
| jerk_clipped = df['jerk'].clip(upper=df['jerk'].quantile(0.99)) | |
| axes[2].plot(t, jerk_clipped, 'purple', linewidth=0.8) | |
| axes[2].set_ylabel('Jerk (clipped)') | |
| axes[2].grid(True, alpha=0.3) | |
| # Plot 4: Impact score | |
| axes[3].plot(t, df['impact_score'], 'green', linewidth=0.8) | |
| axes[3].set_ylabel('Impact Score') | |
| axes[3].set_xlabel('Time (seconds)') | |
| axes[3].grid(True, alpha=0.3) | |
| if valid_peaks is not None: | |
| axes[3].plot(t.iloc[valid_peaks], df['impact_score'].iloc[valid_peaks], 'g^', markersize=8) | |
| plt.tight_layout() | |
| return fig | |
| def analyze_hits(df, valid_peaks, name): | |
| """Print statistics about detected hits.""" | |
| print(f"\n{'='*50}") | |
| print(f"Analysis: {name}") | |
| print(f"{'='*50}") | |
| print(f"Total samples: {len(df)}") | |
| print(f"Duration: {df['seconds_elapsed'].max():.1f} seconds") | |
| print(f"Sample rate: {1/df['seconds_elapsed'].diff().median():.0f} Hz") | |
| print(f"Detected hits: {len(valid_peaks)}") | |
| if valid_peaks: | |
| hit_data = df.iloc[valid_peaks] | |
| print(f"\nHit statistics:") | |
| print(f" Acc magnitude - mean: {hit_data['acc_mag'].mean():.2f}, max: {hit_data['acc_mag'].max():.2f}") | |
| print(f" Rot magnitude - mean: {hit_data['rot_mag'].mean():.2f}, max: {hit_data['rot_mag'].max():.2f}") | |
| print(f" Impact score - mean: {hit_data['impact_score'].mean():.2f}, max: {hit_data['impact_score'].max():.2f}") | |
| print(f"\nHit timestamps (seconds):") | |
| for i, peak in enumerate(valid_peaks[:10]): # Show first 10 | |
| print(f" {i+1}. t={df.iloc[peak]['seconds_elapsed']:.2f}s, " | |
| f"acc={df.iloc[peak]['acc_mag']:.2f}, " | |
| f"rot={df.iloc[peak]['rot_mag']:.2f}") | |
| if len(valid_peaks) > 10: | |
| print(f" ... and {len(valid_peaks)-10} more") | |
| def interactive_threshold_tuning(df, name): | |
| """Helper to experiment with thresholds.""" | |
| print(f"\nData ranges for {name}:") | |
| print(f" Acceleration: {df['acc_mag'].min():.2f} - {df['acc_mag'].max():.2f}") | |
| print(f" Rotation: {df['rot_mag'].min():.2f} - {df['rot_mag'].max():.2f}") | |
| print(f" Jerk: {df['jerk'].min():.2f} - {df['jerk'].quantile(0.99):.2f} (99th pctl)") | |
| print(f"\nSuggested starting thresholds:") | |
| print(f" acc_threshold: {df['acc_mag'].quantile(0.95):.2f} (95th percentile)") | |
| print(f" rot_threshold: {df['rot_mag'].quantile(0.90):.2f} (90th percentile)") | |
| # Main entrypoint of the script. | |
| def main(): | |
| # Load all datasets | |
| print("Loading datasets...") | |
| datasets = load_all_logs() | |
| if not datasets: | |
| print("\nNo CSV files found! Place your Log files in the ../logs directory.") | |
| print("Expected files like: '../logs/Log 2 Frank.csv', '../logs/Log 2 Kush.csv', etc.") | |
| return | |
| # Process each dataset | |
| # I do not think we would need to store all figures, but just in case | |
| all_figures = [] | |
| for name, df in datasets.items(): | |
| print(f"\nProcessing {name}...") | |
| # Compute features | |
| # This computes acceleration magnitude, rotation magnitude, jerk, and impact score | |
| # and adds them as new columns to the dataframe | |
| df = compute_features(df) | |
| # Show data ranges for threshold tuning | |
| # This function prints out the min/max ranges of key features | |
| # and suggests starting thresholds based on percentiles | |
| interactive_threshold_tuning(df, name) | |
| # Find hits with default thresholds (adjust these!) | |
| valid_peaks, all_peaks = find_potential_hits( | |
| df, | |
| acc_threshold=1.5, # Adjust based on your data | |
| rot_threshold=2.0 # Adjust based on your data | |
| ) | |
| # Analyze | |
| analyze_hits(df, valid_peaks, name) | |
| # Plot | |
| fig = plot_session(df, name, all_peaks, valid_peaks) | |
| all_figures.append(fig) | |
| # Save plot | |
| fig.savefig(f'{name}_analysis.png', dpi=150, bbox_inches='tight') | |
| print(f"Saved {name}_analysis.png") | |
| plt.show() | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Loading datasets... | |
| Loaded ../logs/Log 2 Frank.csv: 1825 samples | |
| Loaded ../logs/Log 2 Kush.csv: 756 samples | |
| Loaded ../logs/Log 3 Frank.csv: 512 samples | |
| Loaded ../logs/Log 3 Kush.csv: 439 samples | |
| Loaded ../logs/Log 4 Frank.csv: 1689 samples | |
| Loaded ../logs/Log 4 Kush.csv: 1596 samples | |
| Loaded ../logs/Log1.csv: 2379 samples | |
| Processing Log 2 Frank... | |
| Data ranges for Log 2 Frank: | |
| Acceleration: 0.00 - 14.34 | |
| Rotation: 0.03 - 19.01 | |
| Jerk: 0.00 - 277.42 (99th pctl) | |
| Suggested starting thresholds: | |
| acc_threshold: 3.49 (95th percentile) | |
| rot_threshold: 8.44 (90th percentile) | |
| ================================================== | |
| Analysis: Log 2 Frank | |
| ================================================== | |
| Total samples: 1825 | |
| Duration: 29.3 seconds | |
| Sample rate: 100 Hz | |
| Detected hits: 19 | |
| Hit statistics: | |
| Acc magnitude - mean: 4.38, max: 14.34 | |
| Rot magnitude - mean: 6.81, max: 13.41 | |
| Impact score - mean: 4.69, max: 12.14 | |
| Hit timestamps (seconds): | |
| 1. t=5.32s, acc=5.07, rot=6.38 | |
| 2. t=5.52s, acc=2.23, rot=3.99 | |
| 3. t=5.87s, acc=4.66, rot=6.06 | |
| 4. t=6.23s, acc=1.86, rot=6.72 | |
| 5. t=6.46s, acc=5.16, rot=2.84 | |
| 6. t=6.66s, acc=2.05, rot=2.50 | |
| 7. t=7.02s, acc=5.34, rot=5.88 | |
| 8. t=7.55s, acc=1.55, rot=3.37 | |
| 9. t=16.63s, acc=4.97, rot=9.40 | |
| 10. t=17.56s, acc=3.73, rot=12.71 | |
| ... and 9 more | |
| Saved Log 2 Frank_analysis.png | |
| Processing Log 2 Kush... | |
| Data ranges for Log 2 Kush: | |
| Acceleration: 0.01 - 2.75 | |
| Rotation: 0.07 - 10.39 | |
| Jerk: 0.00 - 47.44 (99th pctl) | |
| Suggested starting thresholds: | |
| acc_threshold: 0.84 (95th percentile) | |
| rot_threshold: 2.42 (90th percentile) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment