{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceType":"competition","sourceId":46105,"databundleVersionId":5087314}],"dockerImageVersionId":31286,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"**WITH LIPS UPDATE**","metadata":{}},{"cell_type":"markdown","source":",\n    # --- 15 new one-handed signs ---\n    \"drink\", \"milk\", \"water\", \"please\", \"thankyou\",\n    \"sorry\", \"mom\", \"dad\", \"boy\", \"girl\",\n    \"red\", \"black\", \"white\", \"happy\", \"bad\",","metadata":{}},{"cell_type":"code","source":"\"\"\"\nTAKALAM – ASL one-handed sign model (v2)\n========================================\nOne-handed signs. Same dataset (Kaggle: asl-signs).\nAdd or remove signs freely in SELECTED_SIGNS – the code adapts automatically.\n\nWHY v2 IS MORE ACCURATE THAN v1 (summary):\n  1. Landmark NORMALIZATION (per-axis center + scale) -> removes signer\n     position/distance/camera differences. Biggest single win.\n  2. DOMINANT-HAND consolidation + left-hand MIRRORING -> a left-handed and a\n     right-handed signer now look identical to the model. Critical for\n     one-handed vocab. Also shrinks features 246 -> 183 (smaller + faster).\n  3. RESAMPLE each sequence to a fixed length (not truncate) -> captures the\n     WHOLE sign regardless of speed/length, and normalizes signing speed.\n  4. NO DATA LEAKAGE: split by participant_id (GroupShuffleSplit) BEFORE\n     augmenting.\n  5. LSTM activation fixed: 'relu' -> default tanh.\n  6. Richer augmentation (jitter + scale + small rotation), TRAIN ONLY.\n  7. class_weight to balance signs that have fewer samples.\n  8. Prints a per-sign classification report so you can see weak signs.\n\n!!! CRITICAL – MATCH THIS ON ANDROID !!!\n  - Feature order per frame = [dominant_hand(21 pts x,y,z) , lips(40 pts x,y,z)]\n    => FEATURES is now 183 (was 246). Update your Android input buffer.\n  - Pick the dominant hand (more detected frames). If it's the LEFT hand,\n    mirror every x as x = 1 - x (hand AND lips) so it looks right-handed.\n  - Per-axis standardize over the window: x,y,z each -> (v - mean)/std.\n  - Resample the collected frames to exactly MAX_FRAMES (=30).\n  - Use the class order saved in labels.json for the output names.\n\"\"\"\n\nimport os\nimport json\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\nfrom tensorflow.keras.models import Sequential\nfrom tensorflow.keras.layers import LSTM, Dense, Dropout, Input\nfrom tensorflow.keras.callbacks import EarlyStopping\nfrom tensorflow.keras.optimizers.schedules import CosineDecay\nfrom tensorflow.keras.optimizers import Adam\nfrom tensorflow.keras.losses import CategoricalCrossentropy\nfrom tensorflow.keras.regularizers import l2\n\nfrom sklearn.model_selection import GroupShuffleSplit\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.metrics import classification_report, confusion_matrix\n\n# --- 1. DYNAMIC KAGGLE PATHS ---\nBASE_DIR = '/kaggle/input'\nDATA_DIR = None\nfor root, dirs, files in os.walk(BASE_DIR):\n    if 'train.csv' in files:\n        DATA_DIR = root\n        break\nif DATA_DIR is None:\n    raise FileNotFoundError(\"Could not find train.csv!\")\n\nTRAIN_CSV = f'{DATA_DIR}/train.csv'\nprint(f\"Dataset successfully found at: {DATA_DIR}\")\n\n# --- 2. CONFIGURATION ---\nMAX_FRAMES = 30\nN_HAND = 21          # MediaPipe hand landmarks\nN_LIPS = 40          # selected lip landmarks\nFEATURES = (N_HAND + N_LIPS) * 3   # 183  (one hand + lips)\n\n# Add or remove signs here. Must match dataset sign names exactly (lowercase).\nSELECTED_SIGNS = [\n    # --- your original 15 ---\n    \"airplane\", \"yellow\", \"blue\", \"green\", \"hello\",\n    \"yes\", \"no\", \"time\", \"wait\", \"fish\",\n    \"scissors\", \"callonphone\", \"bird\", \"finger\", \"cow\",\n    # --- add more one-handed signs below ---\n]\nNUM_CLASSES = len(SELECTED_SIGNS)\nprint(f\"Training on {NUM_CLASSES} signs.\")\n\nLIP_INDICES = [\n    0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178,\n    181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321,\n    324, 375, 402, 405, 409, 415\n]\nassert len(LIP_INDICES) == N_LIPS\nLIP_SET = set(LIP_INDICES)\n\n\n# --- 3. EXTRACTOR (dominant-hand + mirror + normalize + resample) ---\ndef _type_tensor(df, type_name, n_landmarks):\n    \"\"\"Return (T, n_landmarks, 3); NaN where a landmark was not detected.\"\"\"\n    sub = df[df['type'] == type_name].sort_values(['frame', 'landmark_index'])\n    arr = sub[['x', 'y', 'z']].to_numpy(dtype=np.float32)\n    if arr.shape[0] == 0:\n        return None\n    return arr.reshape(-1, n_landmarks, 3)\n\n\ndef _resample(seq, target_len):\n    \"\"\"Linearly resample a (T, F) sequence to (target_len, F).\"\"\"\n    T = seq.shape[0]\n    if T == target_len:\n        return seq\n    if T < 2:\n        return np.repeat(seq, target_len, axis=0)[:target_len]\n    old = np.linspace(0.0, 1.0, T)\n    new = np.linspace(0.0, 1.0, target_len)\n    out = np.empty((target_len, seq.shape[1]), dtype=np.float32)\n    for c in range(seq.shape[1]):\n        out[:, c] = np.interp(new, old, seq[:, c])\n    return out\n\n\ndef load_and_process_parquet(file_path):\n    df = pd.read_parquet(file_path,\n                         columns=['frame', 'type', 'landmark_index', 'x', 'y', 'z'])\n\n    left = _type_tensor(df, 'left_hand', N_HAND)     # (T,21,3) or None\n    right = _type_tensor(df, 'right_hand', N_HAND)\n    face = df[(df['type'] == 'face') & (df['landmark_index'].isin(LIP_SET))]\n    face = face.sort_values(['frame', 'landmark_index'])\n    lips = face[['x', 'y', 'z']].to_numpy(dtype=np.float32)\n    if lips.shape[0] == 0:\n        return np.zeros((MAX_FRAMES, FEATURES), dtype=np.float32)\n    lips = lips.reshape(-1, N_LIPS, 3)              # (T,40,3)\n\n    T = lips.shape[0]\n    if left is None:\n        left = np.full((T, N_HAND, 3), np.nan, dtype=np.float32)\n    if right is None:\n        right = np.full((T, N_HAND, 3), np.nan, dtype=np.float32)\n\n    # --- pick the dominant hand (the one detected in more frames/points) ---\n    left_count = np.count_nonzero(~np.isnan(left))\n    right_count = np.count_nonzero(~np.isnan(right))\n    if left_count > right_count:\n        hand = left\n        # mirror so a left-handed signer looks right-handed (flip x in 0..1 space)\n        hand = hand.copy(); hand[:, :, 0] = 1.0 - hand[:, :, 0]\n        lips = lips.copy();  lips[:, :, 0] = 1.0 - lips[:, :, 0]\n    else:\n        hand = right\n\n    # --- assemble (T, 183): hand first, then lips ---\n    seq = np.concatenate(\n        [hand.reshape(T, -1), lips.reshape(T, -1)], axis=1).astype(np.float32)\n\n    # fill detection gaps over time\n    seq = (pd.DataFrame(seq)\n             .interpolate(method='linear', limit_direction='both', axis=0)\n             .to_numpy(dtype=np.float32))\n\n    # --- per-axis standardize (center + scale): kills position/distance bias ---\n    arr = seq.reshape(T, -1, 3)\n    for a in range(3):\n        v = arr[:, :, a]\n        m = np.nanmean(v); s = np.nanstd(v)\n        if not np.isfinite(m): m = 0.0\n        if (not np.isfinite(s)) or s < 1e-6: s = 1.0\n        arr[:, :, a] = (v - m) / s\n    seq = np.nan_to_num(arr.reshape(T, -1), nan=0.0)\n\n    # --- resample whole sign to fixed length ---\n    return _resample(seq, MAX_FRAMES)\n\n\n# --- 4. FAST DATA LOADER (cache to .npy) ---\n# Cache filename includes the sign count so changing the vocab forces a re-extract.\nTAG = f'{NUM_CLASSES}signs_183'\nX_FILE, Y_FILE, G_FILE = f'X_{TAG}.npy', f'y_{TAG}.npy', f'groups_{TAG}.npy'\n\nif os.path.exists(X_FILE) and os.path.exists(Y_FILE) and os.path.exists(G_FILE):\n    print(\">>> FAST LOAD: cached numpy data found, skipping extraction. <<<\")\n    X = np.load(X_FILE)\n    y_int = np.load(Y_FILE)\n    groups = np.load(G_FILE)\nelse:\n    print(\">>> FIRST RUN: extracting from parquet (~10 min)... <<<\")\n    meta = pd.read_csv(TRAIN_CSV)\n    meta = meta[meta['sign'].isin(SELECTED_SIGNS)].reset_index(drop=True)\n    label_map = {label: i for i, label in enumerate(SELECTED_SIGNS)}\n\n    X, y_int, groups = [], [], []\n    for _, row in meta.iterrows():\n        X.append(load_and_process_parquet(f\"{DATA_DIR}/{row['path']}\"))\n        y_int.append(label_map[row['sign']])\n        groups.append(row['participant_id'])\n\n    X = np.asarray(X, dtype=np.float32)\n    y_int = np.asarray(y_int, dtype=np.int32)\n    groups = np.asarray(groups)\n    np.save(X_FILE, X); np.save(Y_FILE, y_int); np.save(G_FILE, groups)\n    print(\">>> SUCCESS: cached to disk. Next run is instant. <<<\")\n\nwith open('labels.json', 'w') as f:\n    json.dump(SELECTED_SIGNS, f)\nprint(f\"Loaded {X.shape[0]} samples, shape {X.shape}\")\n\n\n# --- 5. SPLIT BY PARTICIPANT (no leakage), THEN augment train only ---\ngss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\ntrain_idx, val_idx = next(gss.split(X, y_int, groups=groups))\nX_train, y_train = X[train_idx], y_int[train_idx]\nX_val, y_val = X[val_idx], y_int[val_idx]\nprint(f\"Train: {X_train.shape[0]} (signers held out for val) | Val: {X_val.shape[0]}\")\n\n\ndef _augment(batch):\n    \"\"\"jitter + random scale + small 2D rotation, on standardized coords.\"\"\"\n    out = batch.copy()\n    out += np.random.normal(0, 0.05, out.shape).astype(np.float32)      # jitter\n    out *= np.random.uniform(0.9, 1.1, (out.shape[0], 1, 1)).astype(np.float32)  # scale\n    ang = np.random.uniform(-0.17, 0.17, out.shape[0]).astype(np.float32)  # ~±10°\n    cos, sin = np.cos(ang), np.sin(ang)\n    a = out.reshape(out.shape[0], out.shape[1], -1, 3)\n    x, yv = a[..., 0].copy(), a[..., 1].copy()\n    a[..., 0] = cos[:, None, None] * x - sin[:, None, None] * yv\n    a[..., 1] = sin[:, None, None] * x + cos[:, None, None] * yv\n    return a.reshape(out.shape)\n\n\nN_AUG = 3  # 3 extra copies -> 4x training data (more regularization)\nX_aug = [X_train] + [_augment(X_train) for _ in range(N_AUG)]\ny_aug = [y_train] * (N_AUG + 1)\nX_train = np.concatenate(X_aug, axis=0)\ny_train = np.concatenate(y_aug, axis=0)\nprint(f\"Training data after augmentation: {X_train.shape}\")\n\ny_train_cat = tf.keras.utils.to_categorical(y_train, NUM_CLASSES)\ny_val_cat = tf.keras.utils.to_categorical(y_val, NUM_CLASSES)\n\nclass_weights = compute_class_weight('balanced',\n                                     classes=np.arange(NUM_CLASSES), y=y_train)\nclass_weight = {i: w for i, w in enumerate(class_weights)}\n\n\n# --- 6. MODEL (built fresh each time so weight transfer is a clean copy) ---\ndef build_model(batch_size=None):\n    inp = (Input(batch_shape=(batch_size, MAX_FRAMES, FEATURES))\n           if batch_size else Input(shape=(MAX_FRAMES, FEATURES)))\n    return Sequential([\n        inp,\n        LSTM(192, return_sequences=True, unroll=True, recurrent_dropout=0.2),\n        Dropout(0.4),\n        LSTM(96, return_sequences=False, unroll=True, recurrent_dropout=0.2),\n        Dropout(0.4),\n        Dense(64, activation='relu', kernel_regularizer=l2(1e-4)),\n        Dropout(0.3),\n        Dense(NUM_CLASSES, activation='softmax'),\n    ])\n\n\nprint(\"Building training model...\")\ntrain_model = build_model()\n\nsteps_per_epoch = max(1, len(X_train) // 32)\nlr_schedule = CosineDecay(initial_learning_rate=1e-3,\n                          decay_steps=steps_per_epoch * 100, alpha=0.01)\ntrain_model.compile(optimizer=Adam(learning_rate=lr_schedule, clipnorm=1.0),\n                    loss=CategoricalCrossentropy(label_smoothing=0.1),\n                    metrics=['accuracy'])\n\nearly_stop = EarlyStopping(monitor='val_accuracy', patience=15,\n                           restore_best_weights=True)\n\nprint(\"Training (up to 100 epochs)...\")\nhistory = train_model.fit(\n    X_train, y_train_cat,\n    validation_data=(X_val, y_val_cat),   # honest, participant-held-out val\n    epochs=100, batch_size=32,\n    class_weight=class_weight,\n    callbacks=[early_stop],\n)\n\n# --- per-sign report: shows you exactly which signs are confused ---\nprint(\"\\n===== VALIDATION REPORT (per sign) =====\")\nval_pred = train_model.predict(X_val, verbose=0).argmax(axis=1)\nprint(classification_report(y_val, val_pred,\n                            labels=list(range(NUM_CLASSES)),\n                            target_names=SELECTED_SIGNS, zero_division=0))\n\n# which sign is each one most confused with? (very useful for picking vocab)\ncm = confusion_matrix(y_val, val_pred, labels=list(range(NUM_CLASSES)))\nprint(\"Most common mistake per sign:\")\nfor i in range(NUM_CLASSES):\n    row = cm[i].copy(); row[i] = 0\n    j = int(row.argmax())\n    if row[j] > 0:\n        print(f\"  {SELECTED_SIGNS[i]:>12}  ->  {SELECTED_SIGNS[j]:<12} ({row[j]} of {cm[i].sum()})\")\n\n\n# --- transfer weights into the fixed-batch Android skeleton ---\nprint(\"Transferring weights to Android (batch=1) model...\")\nandroid_model = build_model(batch_size=1)\nandroid_model.set_weights(train_model.get_weights())\n\n\n# --- 7. EXPORT TFLITE (float16) ---\nprint(\"Converting to TFLite (float16)...\")\nconverter = tf.lite.TFLiteConverter.from_keras_model(android_model)\nconverter.target_spec.supported_ops = [\n    tf.lite.OpsSet.TFLITE_BUILTINS,\n    tf.lite.OpsSet.SELECT_TF_OPS,\n]\nconverter.optimizations = [tf.lite.Optimize.DEFAULT]\nconverter.target_spec.supported_types = [tf.float16]\ntflite_model = converter.convert()\n\nOUT_NAME = 'takalam_sign_model_v2.tflite'\nwith open(OUT_NAME, 'wb') as f:\n    f.write(tflite_model)\n\nprint(f\"\\n>>> DONE! Download '{OUT_NAME}' and 'labels.json' <<<\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\nfrom tensorflow.keras.models import Sequential\nfrom tensorflow.keras.layers import LSTM, Dense, Dropout, Input\nfrom tensorflow.keras.callbacks import EarlyStopping\nfrom tensorflow.keras.optimizers.schedules import CosineDecay\nfrom tensorflow.keras.optimizers import Adam\n\n# --- 1. DYNAMIC KAGGLE PATHS ---\nBASE_DIR = '/kaggle/input'\nDATA_DIR = None\n\nfor root, dirs, files in os.walk(BASE_DIR):\n    if 'train.csv' in files:\n        DATA_DIR = root\n        break\n\nif DATA_DIR is None:\n    raise FileNotFoundError(\"Could not find train.csv!\")\n\nTRAIN_CSV = f'{DATA_DIR}/train.csv'\nprint(f\"Dataset successfully found at: {DATA_DIR}\")\n\n# --- 2. CONFIGURATION (HANDS + LIPS) ---\nMAX_FRAMES = 30\nFEATURES = 246 # 120 (Lips) + 126 (Hands)\n\nSELECTED_SIGNS = [\n    \"airplane\", \"yellow\", \"blue\", \"green\", \"hello\", \n    \"yes\", \"no\", \"time\", \"wait\", \"fish\", \n    \"scissors\", \"callonphone\", \"bird\", \"finger\", \"cow\"\n]\nNUM_CLASSES = len(SELECTED_SIGNS)\n\nLIP_INDICES = [\n    0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178, \n    181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321, \n    324, 375, 402, 405, 409, 415\n]\n\n# --- 3. THE HANDS + LIPS EXTRACTOR ---\ndef load_and_process_parquet(file_path):\n    df = pd.read_parquet(file_path, columns=['frame', 'type', 'landmark_index', 'x', 'y', 'z'])\n    hand_df = df[df['type'].isin(['left_hand', 'right_hand'])]\n    face_df = df[(df['type'] == 'face') & (df['landmark_index'].isin(LIP_INDICES))]\n    relevant_df = pd.concat([hand_df, face_df])\n    \n    frames = relevant_df['frame'].unique()\n    sequence_data = []\n    \n    for frame in frames:\n        frame_data = relevant_df[relevant_df['frame'] == frame]\n        frame_data = frame_data.sort_values(by=['type', 'landmark_index'])\n        coords = frame_data[['x', 'y', 'z']].values.flatten()\n        \n        if len(coords) == FEATURES:\n            sequence_data.append(coords)\n            \n    if len(sequence_data) == 0:\n        return np.zeros((MAX_FRAMES, FEATURES))\n        \n    seq_df = pd.DataFrame(sequence_data).replace(0.0, np.nan).interpolate(method='linear', limit_direction='both').fillna(0.0)\n    sequence_data = seq_df.values\n    \n    if len(sequence_data) < MAX_FRAMES:\n        pad_length = MAX_FRAMES - len(sequence_data)\n        sequence_data = np.pad(sequence_data, ((0, pad_length), (0, 0)), mode='edge')\n    else:\n        sequence_data = sequence_data[:MAX_FRAMES]\n        \n    return sequence_data\n\n# --- 4. THE FAST DATA LOADER (NUMPY SAVE TRICK) ---\nX_FILE = 'X_246_base.npy'\nY_FILE = 'y_246_base.npy'\n\nif os.path.exists(X_FILE) and os.path.exists(Y_FILE):\n    print(\">>> FAST LOAD: Found saved Numpy data! Bypassing 10-minute extraction... <<<\")\n    X = np.load(X_FILE)\n    y = np.load(Y_FILE)\nelse:\n    print(\">>> FIRST RUN: Extracting from parquet files (This will take ~10 mins)... <<<\")\n    train_metadata = pd.read_csv(TRAIN_CSV)\n    subset_metadata = train_metadata[train_metadata['sign'].isin(SELECTED_SIGNS)]\n    label_map = {label:num for num, label in enumerate(SELECTED_SIGNS)}\n\n    X, y = [], []\n    for index, row in subset_metadata.iterrows():\n        pq_path = f\"{DATA_DIR}/{row['path']}\"\n        X.append(load_and_process_parquet(pq_path))\n        y.append(label_map[row['sign']])\n\n    X = np.array(X)\n    y = tf.keras.utils.to_categorical(y, num_classes=NUM_CLASSES)\n    \n    np.save(X_FILE, X)\n    np.save(Y_FILE, y)\n    print(\">>> SUCCESS: Data saved to disk. Next run will be instant! <<<\")\n\n# --- 5. THE CAMERA JITTER SIMULATOR ---\nprint(\"Simulating low-quality camera jitter...\")\nnoise = np.random.normal(0, 0.02, X.shape) \nX_noisy = X + noise\n\nX_combined = np.vstack((X, X_noisy))\ny_combined = np.vstack((y, y))\n\nprint(f\"Data ready! Final Training Shape: {X_combined.shape}\")\n\n\n# --- 6. THE SAFE MODEL (WEIGHTS TRANSFER TRICK) ---\nprint(\"Building the Master Training Model...\")\n\ntrain_model = Sequential([\n    Input(shape=(MAX_FRAMES, FEATURES)), \n    LSTM(256, return_sequences=True, activation='relu', unroll=True),\n    Dropout(0.4),\n    LSTM(128, return_sequences=False, activation='relu', unroll=True),\n    Dropout(0.4),\n    Dense(64, activation='relu'),\n    Dense(NUM_CLASSES, activation='softmax')\n])\n\n# UPGRADE 1: Adjusted decay steps for 100 epochs\ntotal_steps = (len(X_combined) // 32) * 100\nlr_schedule = CosineDecay(initial_learning_rate=0.001, decay_steps=total_steps, alpha=0.01)\noptimizer = Adam(learning_rate=lr_schedule, clipnorm=1.0)\n\ntrain_model.compile(optimizer=optimizer, loss='categorical_crossentropy', metrics=['accuracy'])\n\n# UPGRADE 2: Added EarlyStopping to grab the absolute best version\nearly_stop = EarlyStopping(monitor='val_accuracy', patience=15, restore_best_weights=True)\n\nprint(\"Training the brain for up to 100 epochs...\")\n# UPGRADE 3: Added the callback to the fit function\nhistory = train_model.fit(X_combined, y_combined, epochs=500, validation_split=0.2, batch_size=32, callbacks=[early_stop])\n\nprint(\"Transferring brain to locked Android skeleton...\")\nandroid_model = Sequential([\n    Input(batch_shape=(1, MAX_FRAMES, FEATURES)), \n    LSTM(256, return_sequences=True, activation='relu', unroll=True),\n    Dropout(0.4),\n    LSTM(128, return_sequences=False, activation='relu', unroll=True),\n    Dropout(0.4),\n    Dense(64, activation='relu'),\n    Dense(NUM_CLASSES, activation='softmax')\n])\nandroid_model.set_weights(train_model.get_weights())\n\n\n# --- 7. EXPORT FOR ANDROID (QUANTIZED) ---\nprint(\"Converting and Compressing to TFLite (Float16 Quantization)...\")\n\nconverter = tf.lite.TFLiteConverter.from_keras_model(android_model)\nconverter.target_spec.supported_ops = [\n    tf.lite.OpsSet.TFLITE_BUILTINS, \n    tf.lite.OpsSet.SELECT_TF_OPS \n]\n\n# UPGRADE 4: Float16 Post-Training Quantization\n# This makes the file 50% smaller and much faster on Android CPUs\nconverter.optimizations = [tf.lite.Optimize.DEFAULT]\nconverter.target_spec.supported_types = [tf.float16]\n\ntflite_model = converter.convert()\n\nwith open('takalam_sign_model_v1.tflite', 'wb') as f:\n    f.write(tflite_model)\n\nprint(\"\\n>>> DONE! DOWNLOAD 'takalam_sign_model_v1.tflite' <<<\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-02T22:29:53.191386Z","iopub.execute_input":"2026-03-02T22:29:53.192230Z","iopub.status.idle":"2026-03-02T23:24:26.966537Z","shell.execute_reply.started":"2026-03-02T22:29:53.192190Z","shell.execute_reply":"2026-03-02T23:24:26.964910Z"}},"outputs":[],"execution_count":null}]}