{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"},{"sourceId":14407528,"sourceType":"datasetVersion","datasetId":9153851},{"sourceId":4534,"sourceType":"modelInstanceVersion","modelInstanceId":3326,"modelId":986}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Scientific Image Forgery Detection with ELA\n## CNN-DINOv2 Hybrid + Error Level Analysis\n\n**ELA (Error Level Analysis)** reveals hidden image manipulations by analyzing JPEG compression artifacts.\nLike \"adding powder to paper\" to reveal fingerprints, ELA highlights regions with different compression histories.","metadata":{}},{"cell_type":"code","source":"import os, cv2, json, math, random, torch, io\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\nfrom pathlib import Path\nfrom PIL import Image\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import train_test_split\nfrom torch.utils.data import Dataset, DataLoader\nimport torch.nn as nn, torch.nn.functional as F, torch.optim as optim\nfrom transformers import AutoImageProcessor, AutoModel\n\ndef seed_everything(seed=42):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True \n    torch.backends.cudnn.benchmark = False\n\nseed_everything(42)\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nBASE_DIR  = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nAUTH_DIR  = f\"{BASE_DIR}/train_images/authentic\"\nFORG_DIR  = f\"{BASE_DIR}/train_images/forged\"\nMASK_DIR  = f\"{BASE_DIR}/train_masks\"\nTEST_DIR  = f\"{BASE_DIR}/test_images\"\nDINO_PATH = \"/kaggle/input/dinov2/pytorch/base/1\"\n\nIMG_SIZE = 518\nBATCH_SIZE = 2\nMODEL_LOC = '/kaggle/input/cnndinov2-pbd/CNNDINOv2-U52/CNNDINOv2-U52/model_seg_final.pt'\n\n# INFERENCE UTILS\nAREA_THR = 200\nMEAN_THR = 0.22\nUSE_TTA = False\nUSE_ELA = True  # NEW: Enable ELA\nELA_WEIGHT = 0.3  # Weight for ELA contribution\nELA_QUALITY = 90  # JPEG quality for ELA\n\nprint(f'Device: {device}')\nprint(f'ELA Enabled: {USE_ELA}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:36.893142Z","iopub.execute_input":"2026-01-07T10:48:36.893878Z","iopub.status.idle":"2026-01-07T10:48:36.903602Z","shell.execute_reply.started":"2026-01-07T10:48:36.893840Z","shell.execute_reply":"2026-01-07T10:48:36.902995Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Error Level Analysis (ELA) Functions\n\nELA works by:\n1. Re-compressing image at known JPEG quality\n2. Computing difference between original and re-compressed\n3. Forged regions show different error levels","metadata":{}},{"cell_type":"code","source":"def compute_ela(pil_image, quality=90):\n    \"\"\"Compute Error Level Analysis for an image.\n    \n    Like adding forensic powder to reveal hidden fingerprints,\n    ELA reveals hidden manipulations by analyzing compression artifacts.\n    \n    Args:\n        pil_image: PIL Image in RGB format\n        quality: JPEG quality for re-compression (default 90)\n    \n    Returns:\n        PIL Image showing ELA result\n    \"\"\"\n    # Re-compress at specified quality\n    buffer = io.BytesIO()\n    pil_image.save(buffer, 'JPEG', quality=quality)\n    buffer.seek(0)\n    recompressed = Image.open(buffer).convert('RGB')\n    \n    # Compute difference\n    original = np.array(pil_image, dtype=np.float32)\n    compressed = np.array(recompressed, dtype=np.float32)\n    ela = np.abs(original - compressed)\n    \n    # Scale for visibility (amplify differences)\n    scale = 255.0 / (ela.max() + 1e-6)\n    ela = np.clip(ela * scale, 0, 255).astype(np.uint8)\n    \n    return Image.fromarray(ela)\n\n\ndef compute_ela_heatmap(pil_image, quality=90):\n    \"\"\"Compute ELA and return as grayscale heatmap.\"\"\"\n    ela_img = compute_ela(pil_image, quality)\n    ela_arr = np.array(ela_img, dtype=np.float32)\n    # Convert to grayscale by taking max channel\n    heatmap = ela_arr.max(axis=2)\n    # Normalize\n    heatmap = heatmap / (heatmap.max() + 1e-6)\n    return heatmap\n\n\ndef visualize_ela(pil_image, quality=90):\n    \"\"\"Visualize original image and its ELA side by side.\"\"\"\n    ela = compute_ela(pil_image, quality)\n    \n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    \n    axes[0].imshow(pil_image)\n    axes[0].set_title('Original Image')\n    axes[0].axis('off')\n    \n    axes[1].imshow(ela)\n    axes[1].set_title(f'ELA (Quality={quality})')\n    axes[1].axis('off')\n    \n    # Heatmap version\n    heatmap = compute_ela_heatmap(pil_image, quality)\n    axes[2].imshow(heatmap, cmap='hot')\n    axes[2].set_title('ELA Heatmap')\n    axes[2].axis('off')\n    \n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:36.904602Z","iopub.execute_input":"2026-01-07T10:48:36.904840Z","iopub.status.idle":"2026-01-07T10:48:36.929033Z","shell.execute_reply.started":"2026-01-07T10:48:36.904820Z","shell.execute_reply":"2026-01-07T10:48:36.928504Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def detect_hidden_jpeg(pil_image):\n    \"\"\"Detect if a PNG/lossless image was likely originally a JPEG.\n    \n    Checks for 8x8 block artifacts using a simple horizontal/vertical gradient check.\n    \"\"\"\n    arr = np.array(pil_image.convert('L'), dtype=np.float32)\n    h, w = arr.shape\n    if h < 16 or w < 16: return False, 0.0\n    \n    # Compute gradients\n    grad_x = np.abs(arr[:, 1:] - arr[:, :-1])\n    grad_y = np.abs(arr[1:, :] - arr[:-1, :])\n    \n    # Check for 8x8 periodic peaks in gradient sums (block boundaries)\n    def check_periodicity(grads, axis=0):\n        sums = grads.sum(axis=axis)\n        if len(sums) < 16: return 0.0\n        # Look at the 8-cycle variance vs local mean\n        peaks = [sums[i::8].mean() for i in range(8)]\n        return np.max(peaks) / (np.mean(peaks) + 1e-6)\n    \n    score_x = check_periodicity(grad_x, axis=0)\n    score_y = check_periodicity(grad_y, axis=1)\n    \n    final_score = (score_x + score_y) / 2.0\n    is_hjpeg = final_score > 1.2 # Empirical threshold\n    \n    return is_hjpeg, final_score\n\ndef visualize_ela_suitability(pil_image):\n    is_hjt, score = detect_hidden_jpeg(pil_image)\n    ela = compute_ela(pil_image, quality=ELA_QUALITY)\n    \n    plt.figure(figsize=(10, 4))\n    plt.subplot(1, 2, 1)\n    plt.imshow(pil_image)\n    plt.title(f\"Original (H-JPEG: {is_hjt}, Score: {score:.2f})\")\n    plt.axis('off')\n    \n    plt.subplot(1, 2, 2)\n    plt.imshow(ela)\n    plt.title(\"ELA Transformation\")\n    plt.axis('off')\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:36.929896Z","iopub.execute_input":"2026-01-07T10:48:36.930389Z","iopub.status.idle":"2026-01-07T10:48:36.954399Z","shell.execute_reply.started":"2026-01-07T10:48:36.930366Z","shell.execute_reply":"2026-01-07T10:48:36.953873Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# MODEL (DINOv2 + Decoder)\nprocessor = AutoImageProcessor.from_pretrained(DINO_PATH, local_files_only=True, use_fast=False)\nencoder = AutoModel.from_pretrained(DINO_PATH, local_files_only=True).eval().to(device)\n\nclass DinoTinyDecoder(nn.Module):\n    def __init__(self, in_ch=768, out_ch=1):\n        super().__init__()\n        self.block1 = nn.Sequential(\n            nn.Conv2d(in_ch, 384, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True),\n            nn.Dropout2d(0.1)\n        )\n        self.block2 = nn.Sequential(\n            nn.Conv2d(384, 192, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True),\n            nn.Dropout2d(0.1)\n        )\n        self.block3 = nn.Sequential(\n            nn.Conv2d(192, 96, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True)\n        )\n        self.conv_out = nn.Conv2d(96, out_ch, kernel_size=1)\n    \n    def forward(self, f, target_size):\n        x = F.interpolate(self.block1(f), size=(74, 74), mode='bilinear', align_corners=False)\n        x = F.interpolate(self.block2(x), size=(148, 148), mode='bilinear', align_corners=False)\n        x = F.interpolate(self.block3(x), size=(296, 296), mode='bilinear', align_corners=False)\n        x = self.conv_out(x)\n        x = F.interpolate(x, size=target_size, mode='bilinear', align_corners=False)\n        return x\n    \nclass DinoSegmenter(nn.Module):\n    def __init__(self, encoder, processor):\n        super().__init__()\n        self.encoder, self.processor = encoder, processor\n        for p in self.encoder.parameters(): p.requires_grad = False\n        self.seg_head = DinoTinyDecoder(768,1)\n        \n    def forward_features(self, x):\n        imgs = (x*255).clamp(0,255).byte().permute(0,2,3,1).cpu().numpy()\n        inputs = self.processor(images=list(imgs), return_tensors=\"pt\").to(x.device)\n        feats = self.encoder(**inputs).last_hidden_state\n        B, N, C = feats.shape\n        fmap = feats[:,1:,:].permute(0,2,1)\n        s = int(math.sqrt(N-1))\n        fmap = fmap.reshape(B, C, s, s)\n        return fmap\n        \n    def forward_seg(self, x):\n        fmap = self.forward_features(x)\n        return self.seg_head(fmap, (IMG_SIZE, IMG_SIZE))\n\nmodel_seg = DinoSegmenter(encoder, processor).to(device)\n\nif MODEL_LOC is not None and os.path.exists(MODEL_LOC):\n    model_seg.load_state_dict(torch.load(MODEL_LOC, map_location=device))\n    print(f\"✅ Loaded pretrained model from: {MODEL_LOC}\")\n    model_seg.eval()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:36.955561Z","iopub.execute_input":"2026-01-07T10:48:36.955751Z","iopub.status.idle":"2026-01-07T10:48:37.499725Z","shell.execute_reply.started":"2026-01-07T10:48:36.955732Z","shell.execute_reply":"2026-01-07T10:48:37.499088Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef segment_prob_map(pil):\n    x = torch.from_numpy(np.array(pil.resize((IMG_SIZE, IMG_SIZE)), np.float32)/255.).permute(2,0,1)[None].to(device)\n    prob = torch.sigmoid(model_seg.forward_seg(x))[0,0].cpu().numpy()\n    return prob\n\n@torch.no_grad()\ndef segment_prob_map_with_tta(pil):\n    x = torch.from_numpy(np.array(pil.resize((IMG_SIZE, IMG_SIZE)), np.float32)/255.).permute(2,0,1)[None].to(device)\n    predictions = []\n    pred_orig = torch.sigmoid(model_seg.forward_seg(x))\n    predictions.append(pred_orig)\n    pred_h = torch.sigmoid(model_seg.forward_seg(torch.flip(x, dims=[3])))\n    predictions.append(torch.flip(pred_h, dims=[3]))\n    pred_v = torch.sigmoid(model_seg.forward_seg(torch.flip(x, dims=[2])))\n    predictions.append(torch.flip(pred_v, dims=[2]))\n    prob = torch.stack(predictions).mean(0)[0, 0].cpu().numpy()\n    return prob\n\ndef enhanced_adaptive_mask(prob, alpha_grad=0.45):\n    gx = cv2.Sobel(prob, cv2.CV_32F, 1, 0, ksize=3)\n    gy = cv2.Sobel(prob, cv2.CV_32F, 0, 1, ksize=3)\n    grad_mag = np.sqrt(gx**2 + gy**2)\n    grad_norm = grad_mag / (grad_mag.max() + 1e-6)\n    enhanced = (1 - alpha_grad) * prob + alpha_grad * grad_norm\n    enhanced = cv2.GaussianBlur(enhanced, (3,3), 0)\n    thr = np.mean(enhanced) + 0.3 * np.std(enhanced)\n    mask = (enhanced > thr).astype(np.uint8)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5,5), np.uint8))\n    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3,3), np.uint8))\n    return mask, thr\n\ndef finalize_mask(prob, orig_size):\n    mask, thr = enhanced_adaptive_mask(prob)\n    mask = cv2.resize(mask, orig_size, interpolation=cv2.INTER_NEAREST)\n    return mask, thr","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:37.500889Z","iopub.execute_input":"2026-01-07T10:48:37.501172Z","iopub.status.idle":"2026-01-07T10:48:37.510492Z","shell.execute_reply.started":"2026-01-07T10:48:37.501147Z","shell.execute_reply":"2026-01-07T10:48:37.509835Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Enhanced Pipeline with ELA Integration\n\nThe new pipeline combines:\n1. **RGB prediction** - Standard model prediction\n2. **ELA prediction** - Model run on ELA-transformed image\n3. **ELA heatmap boost** - Direct ELA signal to highlight compression anomalies","metadata":{}},{"cell_type":"code","source":"def pipeline_final_with_ela(pil, use_ela=True, ela_weight=0.3):\n    \"\"\"Enhanced pipeline with ELA integration and adaptive thresholds.\n    \n    Combines RGB segmentation + multi-quality ELA + adaptive postprocessing\n    for more robust forgery detection.\n    \"\"\"\n    # --- 1. RGB base prediction ---\n    if USE_TTA:\n        prob_rgb = segment_prob_map_with_tta(pil)\n    else:\n        prob_rgb = segment_prob_map(pil)\n    \n    # Early exit: imagen claramente auténtica\n    if prob_rgb.max() < 0.1:\n        return \"authentic\", None, {\"reason\": \"early_rgb\"}\n    \n    prob_combined = prob_rgb.copy()\n    ela_info = {}\n\n    # --- 2. ELA adaptativo ---\n    if use_ela:\n        is_hjpeg, hj_score = detect_hidden_jpeg(pil)\n        if is_hjpeg:\n            # Peso ELA dinámico según score H-JPEG\n            ela_weight_dyn = min(0.5, 0.2 + 0.3 * (hj_score - 1.2))\n\n            qualities = [85, 90, 95]\n            ela_probs = []\n            ela_heats = []\n\n            for q in qualities:\n                ela_img = compute_ela(pil, quality=q)\n                ela_prob = segment_prob_map_with_tta(ela_img) if USE_TTA else segment_prob_map(ela_img)\n                ela_probs.append(ela_prob)\n\n                hm = compute_ela_heatmap(pil, quality=q)\n                hm = cv2.resize(hm, (IMG_SIZE, IMG_SIZE))\n                ela_heats.append(hm)\n\n            prob_ela_avg = np.mean(ela_probs, axis=0)\n            heatmap_avg = np.mean(ela_heats, axis=0)\n\n            # --- 3. Fusión RGB + ELA ---\n            prob_combined = (\n                (1 - ela_weight_dyn) * prob_rgb +\n                ela_weight_dyn * (0.7 * prob_ela_avg + 0.3 * heatmap_avg)\n            )\n\n            ela_info = {\n                \"hjpeg_score\": float(hj_score),\n                \"ela_weight\": float(ela_weight_dyn),\n                \"ela_mean\": float(heatmap_avg.mean())\n            }\n\n    # --- 4. Postprocess adaptativo ---\n    # Alpha grad dinámico según intensidad media\n    alpha_grad = 0.45 if prob_combined.mean() < 0.2 else 0.35\n    mask, thr = enhanced_adaptive_mask(prob_combined, alpha_grad=alpha_grad)\n\n    # Máscara a tamaño original\n    mask = cv2.resize(mask, pil.size, interpolation=cv2.INTER_NEAREST)\n    area = int(mask.sum())\n    resized_prob = cv2.resize(prob_combined, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_NEAREST)\n    mean_inside = float(resized_prob[resized_prob==1].mean()) if area>0 else 0.0\n\n    # --- 5. Thresholds dinámicos ---\n    AREA_THR_DYN = int(0.0005 * IMG_SIZE * IMG_SIZE)\n    MEAN_THR_DYN = 0.18 + 0.2 * prob_combined.mean()\n\n    if area < AREA_THR_DYN or mean_inside < MEAN_THR_DYN:\n        return \"authentic\", None, {\n            \"area\": area,\n            \"mean_inside\": mean_inside,\n            \"thr\": thr,\n            **ela_info\n        }\n\n    return \"forged\", mask, {\n        \"area\": area,\n        \"mean_inside\": mean_inside,\n        \"thr\": thr,\n        **ela_info\n    }\n\n\ndef pipeline_final(pil):\n    \"\"\"Wrapper to use ELA-enhanced pipeline if enabled.\"\"\"\n    return pipeline_final_with_ela(pil, use_ela=USE_ELA, ela_weight=ELA_WEIGHT)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:37.511421Z","iopub.execute_input":"2026-01-07T10:48:37.511647Z","iopub.status.idle":"2026-01-07T10:48:37.530723Z","shell.execute_reply.started":"2026-01-07T10:48:37.511614Z","shell.execute_reply":"2026-01-07T10:48:37.530072Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Test ELA on Sample Images","metadata":{}},{"cell_type":"code","source":"# Test ELA visualization on forged images\nif os.path.exists(FORG_DIR):\n    forg_imgs = sorted([str(Path(FORG_DIR)/f) for f in os.listdir(FORG_DIR)])[:3]\n    \n    for img_path in forg_imgs:\n        print(f\"\\n📸 {Path(img_path).name}\")\n        pil = Image.open(img_path).convert(\"RGB\")\n        visualize_ela_suitability(pil)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:37.532580Z","iopub.execute_input":"2026-01-07T10:48:37.533085Z","iopub.status.idle":"2026-01-07T10:48:38.560269Z","shell.execute_reply.started":"2026-01-07T10:48:37.533063Z","shell.execute_reply":"2026-01-07T10:48:38.559486Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## A/B Comparison: ELA vs No-ELA\n\nLet's compare the performance on a few samples to see if ELA provides a boost.","metadata":{}},{"cell_type":"code","source":"if os.path.exists(FORG_DIR):\n    test_p = sorted([str(Path(FORG_DIR)/f) for f in os.listdir(FORG_DIR)])[5]\n    pil = Image.open(test_p).convert(\"RGB\")\n    \n    # 1. No ELA\n    l1, m1, d1 = pipeline_final_with_ela(pil)\n    \n    # 2. With ELA\n    l2, m2, d2 = pipeline_final_with_ela(pil)\n    \n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    axes[0].imshow(pil)\n    axes[0].set_title(\"Original\")\n    \n    if m1 is not None: axes[1].imshow(m1)\n    axes[1].set_title(f\"No ELA (Area: {d1['area']})\")\n    \n    if m2 is not None: axes[2].imshow(m2)\n    axes[2].set_title(f\"With ELA (Area: {d2['area']})\")\n    \n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:38.561259Z","iopub.execute_input":"2026-01-07T10:48:38.561562Z","iopub.status.idle":"2026-01-07T10:48:38.990210Z","shell.execute_reply.started":"2026-01-07T10:48:38.561533Z","shell.execute_reply":"2026-01-07T10:48:38.989525Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Inference on Test Set","metadata":{}},{"cell_type":"code","source":"def rle_encode(mask: np.ndarray, fg_val: int = 1) -> str:\n    pixels = mask.T.flatten()\n    dots = np.where(pixels == fg_val)[0]\n    if len(dots) == 0:\n        return \"authentic\"\n    run_lengths = []\n    prev = -2\n    for b in dots:\n        if b > prev + 1:\n            run_lengths.extend((b + 1, 0))\n        run_lengths[-1] += 1\n        prev = b\n    return json.dumps([int(x) for x in run_lengths])\n\nSAMPLE_SUB = f\"{BASE_DIR}/sample_submission.csv\"\nOUT_PATH = \"submission.csv\"\n\nrows = []\nfor f in tqdm(sorted(os.listdir(TEST_DIR)), desc=\"Inference on Test Set with ELA\"):\n    pil = Image.open(Path(TEST_DIR)/f).convert(\"RGB\")\n    label, mask, dbg = pipeline_final(pil)\n    \n    if mask is None:\n        mask = np.zeros(pil.size[::-1], np.uint8)\n    else:\n        mask = np.array(mask, dtype=np.uint8)\n    \n    if label == \"authentic\":\n        annot = \"authentic\"\n    else:\n        annot = rle_encode((mask > 0).astype(np.uint8))\n    \n    rows.append({\n        \"case_id\": Path(f).stem,\n        \"annotation\": annot,\n        \"area\": int(dbg.get(\"area\", mask.sum())),\n        \"mean\": float(dbg.get(\"mean_inside\", 0.0)),\n        \"thr\": float(dbg.get(\"thr\", 0.0))\n    })\n\nsub = pd.DataFrame(rows)\nss = pd.read_csv(SAMPLE_SUB)\nss[\"case_id\"] = ss[\"case_id\"].astype(str)\nsub[\"case_id\"] = sub[\"case_id\"].astype(str)\n\n# Correcto merge\nfinal = ss[[\"case_id\"]].merge(sub, on=\"case_id\", how=\"left\")\n\nfinal[\"annotation\"] = final[\"annotation\"].fillna(\"authentic\")\nfinal[[\"case_id\", \"annotation\"]].to_csv(OUT_PATH, index=False)\n\nprint(f\"\\n✅ Saved submission file: {OUT_PATH}\")\nprint(final.head(10))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T10:48:38.991144Z","iopub.execute_input":"2026-01-07T10:48:38.991415Z","iopub.status.idle":"2026-01-07T10:48:40.621031Z","shell.execute_reply.started":"2026-01-07T10:48:38.991377Z","shell.execute_reply":"2026-01-07T10:48:40.620310Z"}},"outputs":[],"execution_count":null}]}