Spaces:
Running
Running
| """MLAF Training Pipeline — 1D CNN Gesture Classifier. | |
| Trains a lightweight 1D Convolutional Neural Network on MediaPipe hand | |
| landmarks (21 joints x 3 axes) and exports to ONNX for browser inference | |
| via ONNX Runtime Web. | |
| Architecture: | |
| Input: (batch, 21, 3) — 21 landmarks as spatial sequence, 3 channels | |
| → Conv1D(3→32, k=3) → BN → ReLU → Conv1D(32→64, k=3) → BN → ReLU → Pool | |
| → Conv1D(64→128, k=3) → BN → ReLU → GlobalAvgPool | |
| → FC(128→64) → ReLU → Dropout → FC(64→19) | |
| Total params: ~50K — runs <1ms on CPU, <100KB ONNX file. | |
| The CNN operates on the raw 21×3 normalized landmark tensor rather than | |
| the 86-feature engineered vector. This lets the convolution filters learn | |
| spatial patterns across neighboring joints (wrist→thumb→index→…) directly, | |
| which is more expressive than hand-crafted inter-finger distances. | |
| Usage: | |
| python -m training.train_cnn_classifier | |
| python training/train_cnn_classifier.py | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import json | |
| import logging | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| from .config import ( | |
| EXPERIMENT_REGISTRY_PATH, | |
| GESTURE_IDS, | |
| ID_TO_IDX, | |
| IDX_TO_ID, | |
| INSTITUTION, | |
| LOGS_DIR, | |
| MODELS_DIR, | |
| NUM_GESTURE_CLASSES, | |
| NUM_HAND_LANDMARKS, | |
| HAND_LANDMARK_DIMS, | |
| PROJECT_NAME, | |
| RANDOM_SEED, | |
| SPLITS_DIR, | |
| ) | |
| from .preprocess import LANDMARK_COLS | |
| logger = logging.getLogger(__name__) | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| # --------------------------------------------------------------------------- | |
| # Hyperparameters | |
| # --------------------------------------------------------------------------- | |
| CNN_LEARNING_RATE = 3e-4 | |
| CNN_EPOCHS = 200 | |
| CNN_BATCH_SIZE = 64 | |
| CNN_EARLY_STOPPING_PATIENCE = 20 | |
| CNN_WEIGHT_DECAY = 1e-4 | |
| # Data augmentation | |
| AUG_NOISE_STD = 0.01 # Gaussian noise on landmark coords | |
| AUG_SCALE_RANGE = (0.85, 1.15) # Random scale factor | |
| AUG_ROTATION_DEG = 15 # Random rotation around Z axis | |
| # --------------------------------------------------------------------------- | |
| # Data loading — landmarks only (no engineered features) | |
| # --------------------------------------------------------------------------- | |
| def _load_landmarks(split_name: str) -> tuple[np.ndarray, np.ndarray]: | |
| """Load a split and return raw normalized landmarks as (N, 21, 3) and labels.""" | |
| path = SPLITS_DIR / f"{split_name}.csv" | |
| if not path.exists(): | |
| raise FileNotFoundError(f"Split not found: {path}. Run preprocess.py first.") | |
| df = pd.read_csv(path) | |
| # Extract only the 63 landmark columns | |
| lm_data = df[LANDMARK_COLS].values.astype(np.float32) | |
| X = lm_data.reshape(-1, NUM_HAND_LANDMARKS, HAND_LANDMARK_DIMS) | |
| y = df["gesture_id"].map(ID_TO_IDX).values.astype(np.int64) | |
| # Handle NaN | |
| nan_mask = np.isnan(X) | |
| if nan_mask.any(): | |
| logger.warning(" %d NaN values in %s, replacing with 0", nan_mask.sum(), split_name) | |
| X = np.nan_to_num(X, nan=0.0) | |
| return X, y | |
| # --------------------------------------------------------------------------- | |
| # Data augmentation | |
| # --------------------------------------------------------------------------- | |
| def augment_batch(X: np.ndarray, rng: np.random.Generator) -> np.ndarray: | |
| """Apply random augmentations to a batch of (B, 21, 3) landmarks. | |
| Augmentations are rotation-invariant-friendly: | |
| - Gaussian noise on coordinates | |
| - Random uniform scaling | |
| - Random Z-axis rotation (simulates different camera angles) | |
| """ | |
| B = X.shape[0] | |
| X_aug = X.copy() | |
| # 1. Gaussian noise | |
| X_aug += rng.normal(0, AUG_NOISE_STD, X_aug.shape).astype(np.float32) | |
| # 2. Random scale | |
| scales = rng.uniform(*AUG_SCALE_RANGE, size=(B, 1, 1)).astype(np.float32) | |
| X_aug *= scales | |
| # 3. Random Z-axis rotation | |
| angles = rng.uniform(-AUG_ROTATION_DEG, AUG_ROTATION_DEG, size=B) | |
| angles_rad = np.deg2rad(angles).astype(np.float32) | |
| cos_a = np.cos(angles_rad) | |
| sin_a = np.sin(angles_rad) | |
| x_rot = X_aug[:, :, 0] * cos_a[:, None] - X_aug[:, :, 1] * sin_a[:, None] | |
| y_rot = X_aug[:, :, 0] * sin_a[:, None] + X_aug[:, :, 1] * cos_a[:, None] | |
| X_aug[:, :, 0] = x_rot | |
| X_aug[:, :, 1] = y_rot | |
| return X_aug | |
| # --------------------------------------------------------------------------- | |
| # CNN Model Definition | |
| # --------------------------------------------------------------------------- | |
| def _build_model(): | |
| """Build the 1D CNN model using PyTorch.""" | |
| import torch | |
| import torch.nn as nn | |
| class GestureCNN(nn.Module): | |
| """Lightweight 1D CNN for hand gesture classification. | |
| Input: (batch, 21, 3) → permuted to (batch, 3, 21) for Conv1d | |
| Output: (batch, 19) logits | |
| """ | |
| def __init__(self, num_classes: int = NUM_GESTURE_CLASSES): | |
| super().__init__() | |
| # Conv blocks: 3→32→64→128 with batch norm | |
| self.features = nn.Sequential( | |
| # Block 1: (3, 21) → (32, 19) | |
| nn.Conv1d(HAND_LANDMARK_DIMS, 32, kernel_size=3, padding=0), | |
| nn.BatchNorm1d(32), | |
| nn.ReLU(inplace=True), | |
| # Block 2: (32, 19) → (64, 17) | |
| nn.Conv1d(32, 64, kernel_size=3, padding=0), | |
| nn.BatchNorm1d(64), | |
| nn.ReLU(inplace=True), | |
| nn.MaxPool1d(kernel_size=2), # (64, 8) | |
| # Block 3: (64, 8) → (128, 6) | |
| nn.Conv1d(64, 128, kernel_size=3, padding=0), | |
| nn.BatchNorm1d(128), | |
| nn.ReLU(inplace=True), | |
| # Global average pooling → (128,) | |
| nn.AdaptiveAvgPool1d(1), | |
| ) | |
| self.classifier = nn.Sequential( | |
| nn.Flatten(), | |
| nn.Linear(128, 64), | |
| nn.ReLU(inplace=True), | |
| nn.Dropout(0.3), | |
| nn.Linear(64, num_classes), | |
| ) | |
| def forward(self, x): | |
| # x: (batch, 21, 3) → (batch, 3, 21) for Conv1d | |
| x = x.permute(0, 2, 1) | |
| x = self.features(x) | |
| x = self.classifier(x) | |
| return x | |
| return GestureCNN() | |
| # --------------------------------------------------------------------------- | |
| # Training loop | |
| # --------------------------------------------------------------------------- | |
| def train_cnn( | |
| X_train: np.ndarray, y_train: np.ndarray, | |
| X_val: np.ndarray, y_val: np.ndarray, | |
| ) -> tuple[object, dict]: | |
| """Train 1D CNN gesture classifier with data augmentation.""" | |
| try: | |
| import torch | |
| import torch.nn as nn | |
| from torch.utils.data import DataLoader, TensorDataset | |
| except ImportError: | |
| logger.error("PyTorch not installed — cannot train CNN") | |
| return None, {"model": "CNN", "error": "torch not installed"} | |
| logger.info("=== Training 1D CNN Gesture Classifier ===") | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| logger.info(" Device: %s", device) | |
| model = _build_model().to(device) | |
| # Count parameters | |
| num_params = sum(p.numel() for p in model.parameters()) | |
| num_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) | |
| logger.info(" Parameters: %d total, %d trainable", num_params, num_trainable) | |
| logger.info(" Model:\n%s", model) | |
| criterion = nn.CrossEntropyLoss() | |
| optimizer = torch.optim.AdamW( | |
| model.parameters(), | |
| lr=CNN_LEARNING_RATE, | |
| weight_decay=CNN_WEIGHT_DECAY, | |
| ) | |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=CNN_EPOCHS) | |
| # Validation loader (no augmentation) | |
| val_ds = TensorDataset( | |
| torch.tensor(X_val, dtype=torch.float32), | |
| torch.tensor(y_val, dtype=torch.long), | |
| ) | |
| val_loader = DataLoader(val_ds, batch_size=CNN_BATCH_SIZE) | |
| rng = np.random.default_rng(RANDOM_SEED) | |
| history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": [], "lr": []} | |
| best_val_acc = 0.0 | |
| patience_counter = 0 | |
| best_state = None | |
| t0 = time.perf_counter() | |
| for epoch in range(CNN_EPOCHS): | |
| # --- Train with augmentation --- | |
| model.train() | |
| # Shuffle and augment training data each epoch | |
| perm = rng.permutation(len(X_train)) | |
| X_shuffled = X_train[perm] | |
| y_shuffled = y_train[perm] | |
| train_loss_sum = 0.0 | |
| train_correct = 0 | |
| train_total = 0 | |
| for start in range(0, len(X_shuffled), CNN_BATCH_SIZE): | |
| end = min(start + CNN_BATCH_SIZE, len(X_shuffled)) | |
| X_batch_np = augment_batch(X_shuffled[start:end], rng) | |
| y_batch_np = y_shuffled[start:end] | |
| X_batch = torch.tensor(X_batch_np, dtype=torch.float32).to(device) | |
| y_batch = torch.tensor(y_batch_np, dtype=torch.long).to(device) | |
| optimizer.zero_grad() | |
| logits = model(X_batch) | |
| loss = criterion(logits, y_batch) | |
| loss.backward() | |
| optimizer.step() | |
| train_loss_sum += loss.item() * len(y_batch) | |
| train_correct += (logits.argmax(1) == y_batch).sum().item() | |
| train_total += len(y_batch) | |
| scheduler.step() | |
| # --- Validate --- | |
| model.eval() | |
| val_loss_sum = 0.0 | |
| val_correct = 0 | |
| val_total = 0 | |
| with torch.no_grad(): | |
| for X_batch, y_batch in val_loader: | |
| X_batch, y_batch = X_batch.to(device), y_batch.to(device) | |
| logits = model(X_batch) | |
| loss = criterion(logits, y_batch) | |
| val_loss_sum += loss.item() * len(y_batch) | |
| val_correct += (logits.argmax(1) == y_batch).sum().item() | |
| val_total += len(y_batch) | |
| train_loss = train_loss_sum / train_total | |
| train_acc = train_correct / train_total | |
| val_loss = val_loss_sum / max(val_total, 1) | |
| val_acc = val_correct / max(val_total, 1) | |
| current_lr = scheduler.get_last_lr()[0] | |
| history["train_loss"].append(train_loss) | |
| history["train_acc"].append(train_acc) | |
| history["val_loss"].append(val_loss) | |
| history["val_acc"].append(val_acc) | |
| history["lr"].append(current_lr) | |
| if (epoch + 1) % 10 == 0 or epoch == 0: | |
| logger.info( | |
| " Epoch %3d/%d | loss=%.4f acc=%.4f | val_loss=%.4f val_acc=%.4f | lr=%.6f", | |
| epoch + 1, CNN_EPOCHS, train_loss, train_acc, val_loss, val_acc, current_lr, | |
| ) | |
| # Early stopping | |
| if val_acc > best_val_acc: | |
| best_val_acc = val_acc | |
| patience_counter = 0 | |
| best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} | |
| else: | |
| patience_counter += 1 | |
| if patience_counter >= CNN_EARLY_STOPPING_PATIENCE: | |
| logger.info(" Early stopping at epoch %d", epoch + 1) | |
| break | |
| train_time = time.perf_counter() - t0 | |
| # Load best model | |
| if best_state: | |
| model.load_state_dict(best_state) | |
| # Final validation metrics | |
| model.eval() | |
| all_preds = [] | |
| with torch.no_grad(): | |
| for X_batch, _ in val_loader: | |
| X_batch = X_batch.to(device) | |
| logits = model(X_batch) | |
| all_preds.extend(logits.argmax(1).cpu().numpy()) | |
| y_val_pred = np.array(all_preds) | |
| from sklearn.metrics import ( | |
| accuracy_score, f1_score, precision_recall_fscore_support, confusion_matrix, | |
| ) | |
| val_acc_final = accuracy_score(y_val, y_val_pred) | |
| val_f1_final = f1_score(y_val, y_val_pred, average="macro") | |
| precision, recall, f1, support = precision_recall_fscore_support( | |
| y_val, y_val_pred, labels=list(range(NUM_GESTURE_CLASSES)), zero_division=0, | |
| ) | |
| cm = confusion_matrix(y_val, y_val_pred, labels=list(range(NUM_GESTURE_CLASSES))) | |
| metrics = { | |
| "model": "CNN_1D", | |
| "architecture": "Conv1d(3→32→64→128) + GAP + FC(128→64→19)", | |
| "num_params": num_params, | |
| "num_trainable_params": num_trainable, | |
| "augmentation": { | |
| "noise_std": AUG_NOISE_STD, | |
| "scale_range": list(AUG_SCALE_RANGE), | |
| "rotation_deg": AUG_ROTATION_DEG, | |
| }, | |
| "learning_rate": CNN_LEARNING_RATE, | |
| "weight_decay": CNN_WEIGHT_DECAY, | |
| "batch_size": CNN_BATCH_SIZE, | |
| "epochs_run": len(history["train_loss"]), | |
| "train_time_sec": train_time, | |
| "val_accuracy": val_acc_final, | |
| "val_f1_macro": val_f1_final, | |
| "best_val_accuracy": best_val_acc, | |
| "per_class": { | |
| IDX_TO_ID.get(i, f"class_{i}"): { | |
| "precision": float(precision[i]), | |
| "recall": float(recall[i]), | |
| "f1": float(f1[i]), | |
| "support": int(support[i]), | |
| } | |
| for i in range(NUM_GESTURE_CLASSES) | |
| if support[i] > 0 | |
| }, | |
| "confusion_matrix": cm.tolist(), | |
| "training_curves": history, | |
| } | |
| logger.info(" CNN val accuracy: %.4f | F1 macro: %.4f", val_acc_final, val_f1_final) | |
| return model, metrics | |
| # --------------------------------------------------------------------------- | |
| # ONNX Export | |
| # --------------------------------------------------------------------------- | |
| def export_to_onnx(model, output_path: Path) -> Path: | |
| """Export trained CNN to ONNX format for ONNX Runtime Web inference. | |
| The exported model expects input shape (1, 21, 3) — a single hand's | |
| normalized landmarks — and outputs (1, 19) logits. | |
| """ | |
| import torch | |
| model.eval() | |
| model.cpu() | |
| dummy_input = torch.randn(1, NUM_HAND_LANDMARKS, HAND_LANDMARK_DIMS) | |
| torch.onnx.export( | |
| model, | |
| dummy_input, | |
| str(output_path), | |
| export_params=True, | |
| opset_version=13, | |
| do_constant_folding=True, | |
| input_names=["landmarks"], | |
| output_names=["logits"], | |
| dynamic_axes={ | |
| "landmarks": {0: "batch_size"}, | |
| "logits": {0: "batch_size"}, | |
| }, | |
| ) | |
| size_kb = output_path.stat().st_size / 1024 | |
| logger.info(" ONNX model saved: %s (%.1f KB)", output_path, size_kb) | |
| return output_path | |
| def export_metadata_json(metrics: dict, onnx_path: Path) -> Path: | |
| """Export model metadata as JSON for the browser-side loader. | |
| This file sits alongside the ONNX model and tells the JS inference | |
| module the class mapping, input shape, and normalization params. | |
| """ | |
| meta = { | |
| "model_type": "CNN_1D", | |
| "onnx_file": onnx_path.name, | |
| "input_shape": [1, NUM_HAND_LANDMARKS, HAND_LANDMARK_DIMS], | |
| "output_shape": [1, NUM_GESTURE_CLASSES], | |
| "num_classes": NUM_GESTURE_CLASSES, | |
| "gesture_ids": GESTURE_IDS, | |
| "class_names": GESTURE_IDS, | |
| "normalization": "wrist_origin_unit_scale", | |
| "val_accuracy": metrics.get("val_accuracy"), | |
| "val_f1_macro": metrics.get("val_f1_macro"), | |
| } | |
| meta_path = onnx_path.with_suffix(".json") | |
| with open(meta_path, "w") as f: | |
| json.dump(meta, f, indent=2) | |
| logger.info(" Metadata saved: %s", meta_path) | |
| return meta_path | |
| # --------------------------------------------------------------------------- | |
| # Experiment logging (reuses infrastructure from train_gesture_classifier) | |
| # --------------------------------------------------------------------------- | |
| def _new_experiment_id() -> str: | |
| if EXPERIMENT_REGISTRY_PATH.exists(): | |
| with open(EXPERIMENT_REGISTRY_PATH) as f: | |
| registry = json.load(f) | |
| n = len(registry.get("experiments", [])) | |
| else: | |
| n = 0 | |
| return f"EXP_{n + 1:03d}" | |
| def _register_experiment(exp_id: str, description: str, log_file: str, status: str) -> None: | |
| if EXPERIMENT_REGISTRY_PATH.exists(): | |
| with open(EXPERIMENT_REGISTRY_PATH) as f: | |
| registry = json.load(f) | |
| else: | |
| registry = {"project": PROJECT_NAME, "institution": INSTITUTION, "experiments": []} | |
| registry["experiments"].append({ | |
| "id": exp_id, | |
| "date": datetime.datetime.now().isoformat(), | |
| "description": description, | |
| "log_file": log_file, | |
| "status": status, | |
| }) | |
| with open(EXPERIMENT_REGISTRY_PATH, "w") as f: | |
| json.dump(registry, f, indent=2) | |
| def _save_training_log(log: dict, exp_id: str) -> Path: | |
| timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S") | |
| filename = f"cnn_training_log_{timestamp}_{exp_id}.json" | |
| path = LOGS_DIR / filename | |
| with open(path, "w") as f: | |
| json.dump(log, f, indent=2, default=str) | |
| logger.info("Training log saved: %s", path) | |
| return path | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main() -> dict: | |
| """Run CNN training pipeline: load data → train → export ONNX.""" | |
| logger.info("MLAF Training Pipeline — 1D CNN Gesture Classifier") | |
| exp_id = _new_experiment_id() | |
| logger.info("Experiment: %s", exp_id) | |
| # Load data (raw landmarks only, reshaped to 21×3) | |
| X_train, y_train = _load_landmarks("train") | |
| X_val, y_val = _load_landmarks("val") | |
| X_test, y_test = _load_landmarks("test") | |
| logger.info("Data: train=%d, val=%d, test=%d | shape=%s", | |
| X_train.shape[0], X_val.shape[0], X_test.shape[0], X_train.shape) | |
| # Class distribution | |
| dataset_stats = { | |
| "train_samples": int(X_train.shape[0]), | |
| "val_samples": int(X_val.shape[0]), | |
| "test_samples": int(X_test.shape[0]), | |
| "input_shape": list(X_train.shape[1:]), | |
| "num_classes": NUM_GESTURE_CLASSES, | |
| "class_distribution_train": { | |
| IDX_TO_ID.get(i, f"class_{i}"): int((y_train == i).sum()) | |
| for i in range(NUM_GESTURE_CLASSES) | |
| }, | |
| } | |
| training_log = { | |
| "experiment_id": exp_id, | |
| "project": PROJECT_NAME, | |
| "model_type": "CNN_1D", | |
| "timestamp": datetime.datetime.now().isoformat(), | |
| "dataset": dataset_stats, | |
| "hyperparameters": { | |
| "learning_rate": CNN_LEARNING_RATE, | |
| "epochs": CNN_EPOCHS, | |
| "batch_size": CNN_BATCH_SIZE, | |
| "weight_decay": CNN_WEIGHT_DECAY, | |
| "early_stopping_patience": CNN_EARLY_STOPPING_PATIENCE, | |
| "augmentation": { | |
| "noise_std": AUG_NOISE_STD, | |
| "scale_range": list(AUG_SCALE_RANGE), | |
| "rotation_deg": AUG_ROTATION_DEG, | |
| }, | |
| }, | |
| } | |
| # ---- Train CNN ---- | |
| model, cnn_metrics = train_cnn(X_train, y_train, X_val, y_val) | |
| training_log["cnn_metrics"] = cnn_metrics | |
| if model is None: | |
| training_log["status"] = "failed" | |
| _save_training_log(training_log, exp_id) | |
| return training_log | |
| # ---- Test evaluation ---- | |
| logger.info("=== Final Test Evaluation (CNN) ===") | |
| import torch | |
| from sklearn.metrics import accuracy_score, f1_score, precision_recall_fscore_support, confusion_matrix | |
| model.eval() | |
| model.cpu() | |
| with torch.no_grad(): | |
| X_t = torch.tensor(X_test, dtype=torch.float32) | |
| logits = model(X_t) | |
| y_test_pred = logits.argmax(1).numpy() | |
| test_acc = accuracy_score(y_test, y_test_pred) | |
| test_f1 = f1_score(y_test, y_test_pred, average="macro") | |
| precision, recall, f1, support = precision_recall_fscore_support( | |
| y_test, y_test_pred, labels=list(range(NUM_GESTURE_CLASSES)), zero_division=0, | |
| ) | |
| test_cm = confusion_matrix(y_test, y_test_pred, labels=list(range(NUM_GESTURE_CLASSES))) | |
| training_log["test_evaluation"] = { | |
| "test_accuracy": test_acc, | |
| "test_f1_macro": test_f1, | |
| "per_class": { | |
| IDX_TO_ID.get(i, f"class_{i}"): { | |
| "precision": float(precision[i]), | |
| "recall": float(recall[i]), | |
| "f1": float(f1[i]), | |
| "support": int(support[i]), | |
| } | |
| for i in range(NUM_GESTURE_CLASSES) | |
| if support[i] > 0 | |
| }, | |
| "confusion_matrix": test_cm.tolist(), | |
| } | |
| logger.info(" Test accuracy: %.4f | F1 macro: %.4f", test_acc, test_f1) | |
| # ---- Export to ONNX ---- | |
| logger.info("=== Exporting to ONNX ===") | |
| onnx_path = MODELS_DIR / f"gesture_cnn_{exp_id}.onnx" | |
| export_to_onnx(model, onnx_path) | |
| meta_path = export_metadata_json(cnn_metrics, onnx_path) | |
| # Also save a "latest" copy for the frontend | |
| latest_onnx = MODELS_DIR / "gesture_cnn_latest.onnx" | |
| latest_meta = MODELS_DIR / "gesture_cnn_latest.json" | |
| import shutil | |
| shutil.copy2(onnx_path, latest_onnx) | |
| shutil.copy2(meta_path, latest_meta) | |
| logger.info(" Latest copies: %s, %s", latest_onnx.name, latest_meta.name) | |
| # Save PyTorch checkpoint | |
| pt_path = MODELS_DIR / f"gesture_cnn_{exp_id}.pt" | |
| torch.save(model.state_dict(), pt_path) | |
| training_log["model_artifacts"] = { | |
| "onnx": str(onnx_path), | |
| "onnx_latest": str(latest_onnx), | |
| "metadata": str(meta_path), | |
| "pytorch_checkpoint": str(pt_path), | |
| } | |
| training_log["status"] = "completed" | |
| # ---- Save log & register ---- | |
| log_path = _save_training_log(training_log, exp_id) | |
| _register_experiment( | |
| exp_id, | |
| f"CNN gesture classifier — test acc {test_acc:.4f}, F1 {test_f1:.4f}", | |
| str(log_path), | |
| "completed", | |
| ) | |
| logger.info("Training complete. Experiment: %s", exp_id) | |
| # Print summary | |
| print("\n" + "=" * 60) | |
| print(" CNN Training Summary") | |
| print("=" * 60) | |
| print(f" Experiment: {exp_id}") | |
| print(f" Val accuracy: {cnn_metrics['val_accuracy']:.4f}") | |
| print(f" Test accuracy: {test_acc:.4f}") | |
| print(f" Test F1 macro: {test_f1:.4f}") | |
| print(f" Parameters: {cnn_metrics['num_params']:,}") | |
| print(f" ONNX model: {onnx_path} ({onnx_path.stat().st_size / 1024:.1f} KB)") | |
| print(f" Metadata: {meta_path}") | |
| print("=" * 60) | |
| return training_log | |
| if __name__ == "__main__": | |
| result = main() | |
| sys.exit(0 if result.get("status") == "completed" else 1) | |