{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "8b935e52-caf3-4415-b835-306d4e433899",
   "metadata": {},
   "source": [
    "**Note on reproducibility and data constraints:**\n",
    "\n",
    "This notebook performs the projection of 3D acoustic spatial coordinates onto the synchronized 2D video frames. Due to the file size of the raw video recordings, they are not included in the public repository, and this notebook therefore cannot be re-executed as-is.\n",
    "\n",
    "This code below renders one GIF per acoustic track and is provided for full methodological transparency, documenting the audiovisual linear angle-to-image-plane projection logic used in the study. In place of these per-track GIFs, the `03_videos/` directory provides the finalized deliverable: nine per-scene .mp4 renderings (one per focal interaction scene, including the unresolved scenes 5 and 6), each combining all individuals present in that scene into a single video."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "75746972-98a5-4d02-8766-415a1b4e3883",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Reading consolidated file: localization_optical_dataset.csv\n",
      "\n",
      "--- PROCESSING SESSION: 081356 ---\n",
      "Analysis complete: 7 acoustic tracks found.\n",
      "List: [nan, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]\n",
      "Colors assigned automatically.\n",
      "--- Video verification ---\n",
      "Info: Raw video file not found: 20230504_GX040119.MP4\n",
      "      (Note: due to file sizes, raw videos are excluded from this repository).\n",
      "      Execution halts for this session.\n",
      "\n",
      "--- PROCESSING SESSION: 085315 ---\n",
      "Analysis complete: 8 acoustic tracks found.\n",
      "List: [nan, -1.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0]\n",
      "Colors assigned automatically.\n",
      "--- Video verification ---\n",
      "Info: Raw video file not found: 20230504_GH020332.MP4\n",
      "      (Note: due to file sizes, raw videos are excluded from this repository).\n",
      "      Execution halts for this session.\n",
      "\n",
      "--- PROCESSING SESSION: 085452 ---\n",
      "Analysis complete: 6 acoustic tracks found.\n",
      "List: [nan, 7.0, 8.0, 9.0, 10.0, 11.0]\n",
      "Colors assigned automatically.\n",
      "--- Video verification ---\n",
      "Info: Raw video file not found: 20230504_GH020332.MP4\n",
      "      (Note: due to file sizes, raw videos are excluded from this repository).\n",
      "      Execution halts for this session.\n",
      "\n",
      "--- BATCH PROCESSING COMPLETE ---\n"
     ]
    }
   ],
   "source": [
    "\"\"\"\n",
    "\n",
    "Module 05: Video projection and optical validation\n",
    "\n",
    "(c) Walter M.X. Zimmer (2025)\n",
    "Modified by Lara Berkenbaum (2025, 2026)\n",
    "Original code produced for the CIAN Tool Kit, associated with the manuscript: \n",
    "Berkenbaum, L., Glotin, H., Sarano, F., Zimmer, W.M.X., Sarano, V., Adam, O., Giraudet, P. (2026). \"Audiovisual diarization of overlapping click trains in sperm whale (Physeter macrocephalus) vocal sparring using a three-hydrophone array,\" J. Acoust. Soc. Am.\n",
    "\n",
    "Description:\n",
    "Projects the 3D acoustic spatial coordinates onto the synchronized 2D video frames to establish the optical ground truth \n",
    "and visually assign individual identities. Processes multiple recording sessions dynamically from the consolidated dataset.\n",
    "\n",
    "\"\"\"\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import imageio.v3 as iio\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.cm as cm\n",
    "import matplotlib\n",
    "from matplotlib.animation import FuncAnimation\n",
    "import sys\n",
    "import os\n",
    "import glob\n",
    "\n",
    "# =============================================================================\n",
    "# 1. CONFIGURATION: MULTI-SESSION SYNCHRONIZATION\n",
    "# =============================================================================\n",
    "# Dictionary mapping session IDs to their specific video file and synchronization offset.\n",
    "# T=0 in the CSV corresponds to T=offset in the video recording (in seconds).\n",
    "SESSION_CONFIG = {\n",
    "    \"081356\": {\"video\": \"20230504_GX040119.MP4\", \"offset\": -18.15025},\n",
    "    \"085315\": {\"video\": \"20230504_GH020332.MP4\", \"offset\": 351.00152},\n",
    "    \"085452\": {\"video\": \"20230504_GH020332.MP4\", \"offset\": 447.26496}\n",
    "}\n",
    "\n",
    "csv_dir = '../01_data/ground_truth_optical/'\n",
    "video_dir = '../03_videos/'\n",
    "\n",
    "# =============================================================================\n",
    "# 2. BATCH PROCESSING LOOP\n",
    "# =============================================================================\n",
    "\n",
    "# Reading the consolidated file directly\n",
    "master_files = glob.glob(os.path.join(csv_dir, \"*localization*dataset*.csv\"))\n",
    "\n",
    "if not master_files:\n",
    "    print(f\"Error: no consolidated dataset found in {csv_dir}.\")\n",
    "    sys.exit(0)\n",
    "\n",
    "csv_filename = master_files[0]\n",
    "print(f\"Reading consolidated file: {os.path.basename(csv_filename)}\")\n",
    "\n",
    "try:\n",
    "    # Hardcoded separator for robustness (adjust to ',' if necessary)\n",
    "    df_master = pd.read_csv(csv_filename, sep=',')\n",
    "    \n",
    "    # Ensure session_id is a 6-digit string to match SESSION_CONFIG keys\n",
    "    df_master['session_id'] = df_master['session_id'].astype(str).str.zfill(6)\n",
    "    \n",
    "except Exception as e:\n",
    "    print(f\"CSV Error: {e}\")\n",
    "    sys.exit(0)\n",
    "\n",
    "# Process each session present in the consolidated file\n",
    "for session_id in df_master['session_id'].unique():\n",
    "    \n",
    "    if session_id not in SESSION_CONFIG:\n",
    "        print(f\"Warning: session ID {session_id} not in configuration. Skipping.\")\n",
    "        continue\n",
    "\n",
    "    time_offset = SESSION_CONFIG[session_id][\"offset\"]\n",
    "    video_name = os.path.join(video_dir, SESSION_CONFIG[session_id][\"video\"])\n",
    "    \n",
    "    print(f\"\\n--- PROCESSING SESSION: {session_id} ---\")\n",
    "    \n",
    "    df = df_master[df_master['session_id'] == session_id].copy()\n",
    "    \n",
    "    required_cols = ['track', 'final_ID']\n",
    "    missing = [c for c in required_cols if c not in df.columns]\n",
    "    if missing:\n",
    "        print(f\"Error: missing column(s) {missing}.\")\n",
    "        continue\n",
    "    \n",
    "    unique_groups = sorted(df['track'].unique())\n",
    "    nb_groups = len(unique_groups)\n",
    "    \n",
    "    print(f\"Analysis complete: {nb_groups} acoustic tracks found.\")\n",
    "    print(f\"List: {unique_groups}\")\n",
    "\n",
    "    try:\n",
    "        colormap = matplotlib.colormaps['tab10']\n",
    "    except AttributeError: \n",
    "        colormap = cm.get_cmap('tab10', nb_groups)\n",
    "    \n",
    "    # Assign one color per acoustic track\n",
    "    COLOR_MAP = {}\n",
    "    for i, group_id in enumerate(unique_groups):\n",
    "        COLOR_MAP[group_id] = colormap(i % 10)\n",
    "\n",
    "    print(\"Colors assigned automatically.\")\n",
    "\n",
    "# =============================================================================\n",
    "# 3. VIDEO VERIFICATION\n",
    "# =============================================================================\n",
    "    print(\"--- Video verification ---\")\n",
    "    if not os.path.exists(video_name):\n",
    "        print(f\"Info: Raw video file not found: {os.path.basename(video_name)}\")\n",
    "        print(\"      (Note: due to file sizes, raw videos are excluded from this repository).\")\n",
    "        print(\"      Execution halts for this session.\")\n",
    "        continue\n",
    "    else:\n",
    "        print(f\"Video found: {os.path.basename(video_name)}\")\n",
    "\n",
    "    try:\n",
    "        meta = iio.immeta(video_name)\n",
    "        fps = meta['fps']\n",
    "        duration = meta.get('duration', 0) \n",
    "        print(f\"FPS: {fps}\")\n",
    "        print(f\"Estimated duration: {duration} sec\")\n",
    "    except Exception as e:\n",
    "        print(f\"Warning: unable to read metadata (using default FPS): {e}\")\n",
    "        fps = 29.97 \n",
    "        duration = 99999 \n",
    "\n",
    "\n",
    "# =============================================================================\n",
    "# 4. GIF GENERATION (PROJECTION)\n",
    "# =============================================================================\n",
    "    for target_group in unique_groups:\n",
    "        \n",
    "        print(f\"\\nProcessing track: {target_group} ...\")\n",
    "        \n",
    "        df_group = df[df['track'] == target_group].copy()\n",
    "        \n",
    "        # Filter out clicks that lack spatial coordinates to avoid empty projections\n",
    "        df_group = df_group.dropna(subset=['acoustic_x_fov', 'acoustic_y_fov'])\n",
    "        df_group = df_group.reset_index(drop=True)\n",
    "        \n",
    "        n_points = len(df_group)\n",
    "        if n_points == 0: \n",
    "            print(f\"      No valid spatial coordinates to project for this group. Skipping.\")\n",
    "            continue\n",
    "            \n",
    "        print(f\"Found {n_points} valid spatial clicks. Extracting frames...\")\n",
    "\n",
    "        images = []\n",
    "        valid_indices = []\n",
    "        \n",
    "        errors_count = 0 \n",
    "        skipped_negative = 0 \n",
    "\n",
    "        for idx, row in df_group.iterrows():\n",
    "            \n",
    "            current_video = video_name\n",
    "            current_offset = time_offset\n",
    "\n",
    "            # Using precise localization time\n",
    "            t = float(row['T_H0_loc'])\n",
    "\n",
    "            # Scene 1 of session 081356 is split across two consecutive video files:\n",
    "            # clicks before t = 18.15025 s fall in GX030119, later clicks in GX040119.   \n",
    "            split_time = -SESSION_CONFIG[\"081356\"][\"offset\"]\n",
    "            if session_id == \"081356\" and t < split_time:\n",
    "                current_video = os.path.join(video_dir, \"20230504_GX030119.MP4\")\n",
    "                current_offset = 686.18701\n",
    "            \n",
    "            # Video time calculation\n",
    "            nsec = current_offset + float(t)\n",
    "            \n",
    "            # Negative offset safety\n",
    "            if nsec < 0:\n",
    "                skipped_negative += 1\n",
    "                continue \n",
    "                \n",
    "            if duration > 0 and nsec > duration:\n",
    "                continue \n",
    "\n",
    "            # Sub-frame rounding fix to ensure accurate temporal alignment\n",
    "            nfr = int(round(nsec * fps))\n",
    "            \n",
    "            try:\n",
    "                im = iio.imread(current_video, index=nfr)\n",
    "                images.append(im)\n",
    "                valid_indices.append(idx)\n",
    "            except Exception as e:\n",
    "                if errors_count == 0:\n",
    "                    print(f\"      Warning: extraction error (T_H0_loc={t}, Frame={nfr}): {e}\")\n",
    "                errors_count += 1\n",
    "                pass\n",
    "                \n",
    "        if skipped_negative > 0:\n",
    "            print(f\"      Info: {skipped_negative} clicks ignored (occurred before video start).\")\n",
    "\n",
    "        if not images:\n",
    "            print(f\"      Failed: no valid images extracted (check synchronization).\")\n",
    "            continue\n",
    "\n",
    "        # Animation setup\n",
    "        fig, ax = plt.subplots(1, 1, figsize=(8, 4.5))\n",
    "        fovx = 118 \n",
    "        fovy = 69 \n",
    "        color_pt = COLOR_MAP.get(target_group, 'white')\n",
    "\n",
    "        # Note: 'update' captures df_group, images, and valid_indices via closure.\n",
    "        # This is strictly safe here because the rendering (ani.save) is synchronous \n",
    "        # and fully consumed within the loop iteration.\n",
    "        def update(frame):\n",
    "            ax.clear()\n",
    "            if frame >= len(valid_indices): return\n",
    "\n",
    "            row = df_group.loc[valid_indices[frame]]\n",
    "            img = images[frame]\n",
    "            \n",
    "            t_val = row['T_H0_loc']\n",
    "            track_val = row['track'] if 'track' in row else 'N/A'\n",
    "            final_id_val = row['final_ID']\n",
    "            \n",
    "            loop_err = row['loop_error_cm'] if 'loop_error_cm' in row else 0.0\n",
    "            resid_cm = row['residuals_cm'] if 'residuals_cm' in row else 0.0\n",
    "            \n",
    "            # Direct read of acoustic field of view coordinates\n",
    "            clk_x = row['acoustic_x_fov']\n",
    "            clk_y = row['acoustic_y_fov']\n",
    "            \n",
    "            ax.imshow(img, extent=[-fovx / 2, fovx / 2, -fovy / 2, fovy / 2], aspect='auto')\n",
    "            \n",
    "            ax.scatter([clk_x], [clk_y], color=color_pt, s=150, edgecolors='white', linewidth=2)\n",
    "            \n",
    "            line1 = f\"TRACK: {track_val} | ID: {final_id_val}\"\n",
    "            line2 = f\"Time: {t_val:.3f}s | LoopErr: {loop_err:.1f}cm | Resid: {resid_cm:.1f}cm\"\n",
    "            \n",
    "            ax.set_title(line1 + \"\\n\" + line2, fontsize=10, fontweight='bold', \n",
    "                         bbox=dict(facecolor='white', alpha=0.7, edgecolor='none'))\n",
    "            ax.grid(True, alpha=0.3)\n",
    "\n",
    "        ani = FuncAnimation(fig, update, frames=len(images), repeat=False)\n",
    "        \n",
    "        # Cleaned filename rendering\n",
    "        safe_group_name = str(int(target_group)).replace('/', '_')\n",
    "        output_gif = os.path.join(video_dir, f\"20230504_{session_id}_GIF_{safe_group_name}.gif\")\n",
    "        print(f\"      Saving {output_gif} ...\")\n",
    "        ani.save(output_gif, writer='pillow', fps=4) \n",
    "        plt.close()\n",
    "\n",
    "print(\"\\n--- BATCH PROCESSING COMPLETE ---\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e14dda71-e102-4e27-97e1-ca43fccaa487",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
