{"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":[{"sourceId":97984,"databundleVersionId":14096757,"sourceType":"competition"}],"dockerImageVersionId":31234,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================\n# ECG Digitization - Unified Single Script (FAST, same concept)\n#   Geometry fallback -> Hybrid ROI model (trained in-script)\n#   -> Grid removal -> Trace (DP/Viterbi) -> mV -> Resample\n#   -> Bandpass/optional notch/SG -> Soft baseline removal -> Submission\n#\n# SPEED CHANGES (keep concept):\n# - Build pseudo ROI cache once (CPU) then train from cached tensors (fast)\n# - Smaller Transformer + smaller LSTM\n# - Fewer training steps/samples\n# - No torch.compile overhead\n# ============================================================\n\nimport os, re, math, time, warnings\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torchvision.transforms as T\nfrom torchvision.models import resnet50, convnext_tiny\n\nfrom scipy.interpolate import PchipInterpolator, CubicHermiteSpline\nfrom scipy.signal import savgol_filter, butter, filtfilt, iirnotch\n\n# Optional skeletonize (if available)\ntry:\n    from skimage.morphology import skeletonize as sk_skeletonize\nexcept Exception:\n    sk_skeletonize = None\n\n# -----------------------------\n# 0) Silence warnings/log spam\n# -----------------------------\nwarnings.filterwarnings(\"ignore\")\nnp.seterr(all=\"ignore\")\nos.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\nos.environ[\"TF_CPP_MIN_LOG_LEVEL\"] = \"3\"\nos.environ[\"OPENCV_LOG_LEVEL\"] = \"ERROR\"\ntry:\n    cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_ERROR)\nexcept Exception:\n    pass\n\n# -----------------------------\n# CONFIG\n# -----------------------------\nDATA_DIR   = Path(\"/kaggle/input/physionet-ecg-image-digitization\")\nTRAIN_DIR  = DATA_DIR / \"train\"\nTEST_DIR   = DATA_DIR / \"test\"\nTEST_CSV   = DATA_DIR / \"test.csv\"\nSAMPLE_SUB = DATA_DIR / \"sample_submission.parquet\"\n\nOUT_PQ  = Path(\"/kaggle/working/submission.parquet\")\nOUT_CSV = Path(\"/kaggle/working/submission.csv\")\n\nFORCE_GPU = False\nGPU_ID = 0\nif FORCE_GPU:\n    if not torch.cuda.is_available():\n        raise RuntimeError(\"CUDA is not available. Enable a GPU runtime.\")\n    DEVICE = f\"cuda:{GPU_ID}\"\nelse:\n    DEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nUSE_AMP = DEVICE.startswith(\"cuda\")\n\nif DEVICE.startswith(\"cuda\"):\n    torch.cuda.set_device(GPU_ID)\n    torch.backends.cudnn.benchmark = True\n    try:\n        torch.set_float32_matmul_precision(\"high\")\n    except Exception:\n        pass\n\n# -----------------------------\n# TURBO MODE (speed-first)\n# -----------------------------\nTURBO_MODE = True\n\nUSE_CHANNELS_LAST = True\nUSE_TORCH_COMPILE = False  # English: disable compile overhead\n\n# Train weights in-script (no external ckpt required)\nTRAIN_HYBRID = True\nSAVE_TRAINED_CKPT = False\nAUTO_CKPT_PATH = Path(os.environ.get(\"AUTO_CKPT_PATH\", \"/kaggle/working/hybrid_fast_pseudo.pth\"))\n\n# Backbone init (train from scratch; no pretrained weights)\nUSE_PRETRAINED_BACKBONE = False\n\n# Training config (pseudo labels)\nLR0 = 0.004\nINV_T0 = 100.0\nINV_POWER = 1.0\nLR_MIN = 1e-6\nWEIGHT_DECAY = 1e-4\nGRAD_CLIP_NORM = 1.0\n\nEARLY_STOP_ON = True\nES_WARMUP_STEPS = 10\nES_PATIENCE_STEPS = 80\nES_MIN_DELTA = 1e-4\nES_EMA_ALPHA = 0.10\nES_RESTORE_BEST = True\n\nMAX_TRAIN_STEPS = 20 if TURBO_MODE else 100\nBATCH_SIZE = 16 if (DEVICE.startswith(\"cuda\") and TURBO_MODE) else 8\nNUM_WORKERS = 4\n\nTRAIN_MAX_IMAGES = 150 if TURBO_MODE else 200\nLEADS_PER_IMAGE_CACHE = 4 if TURBO_MODE else 8  # English: sample fewer leads per image for cache speed\n\n# Geometry + preprocess (light)\nRESIZE_SCALE = 0.4 if TURBO_MODE else 0.5\nGAUSS_K = 3\nGEO_USE_DESKEW = False\n\n# Model input size (keep small for speed)\nROI_SIZE = (128, 384)  # (H,W) letterbox\n\n# Postprocess / grid suppression\nMASK_THR = 0.40\nMIN_CC_FRAC = 0.00050\nUSE_SKELETON = True\nSKELETON_IF_THICK = True\nMASK_THICK_AREA_FRAC = 0.02\nMASK_THICK_COL_FRAC = 0.08\nMASK_ANISO_CLOSE_W = 11\nMASK_ANISO_CLOSE_H = 1\nMASK_ANISO_CLOSE_ITER = 1\n\nGRID_RED_FACTOR = 2.2\nGRID_RED_GAMMA = 1.2\nGRID_RED_BLUR = 3\nGRID_RED_THR = 40\nGRID_OPEN_SCALE = 40\n\n# ROI sanity / fallback thresholds\nROI_MIN_SCORE = 0.10\nROI_MIN_INK = 0.01\nPRED_MAX_THR = 0.30\nPRED_MEAN_THR = 0.02\nMASK_MIN_COLCOV = 0.18\nMASK_MIN_INK = 0.002\n\n# Trace (DP/Viterbi)\nTRACE_USE_DP = True\nTRACE_DP_MAX_JUMP = 6 if TURBO_MODE else 8\nTRACE_DP_LAMBDA = 1.2\nTRACE_DP_MU = 0.3\nTRACE_COST_CLIP = 12.0\nTRACE_MIN_SUPPORT = 0.08\nTRACE_MIN_VALID_FRAC = 0.15\nTRACE_PROB_GAMMA = 1.3\nTRACE_GRAY_GAMMA = 1.2\nTRACE_OUTSIDE_MASK_PENALTY = 1.5\nTRACE_SOFTARGMAX_FALLBACK = True\nTRACE_GAP_SHORT_MAX = 10\nTRACE_GAP_MED_MAX = 30\nTRACE_LONG_GAP_SLOPE_SCALE = 0.5\nTRACE_MAX_JUMP_PX = 4\nTRACE_MEDIAN_WIN = 3\n\n# Signal filtering\nFILTER_ORDER = 2\nFILTER_HIGHPASS_HZ = 0.5\nFILTER_LOWPASS_HZ = 40.0\nFILTER_APPLY_NOTCH = False\nFILTER_NOTCH_HZ = 50.0\nFILTER_NOTCH_Q = 30.0\nSAVGOL_WIN_SEC = 0.1\nSAVGOL_WIN_MIN = 8\nSAVGOL_WIN_MAX = 24\nSAVGOL_POLY = 2\n\n# Baseline removal (soft)\nBASELINE_WIN_SEC = 1.0\nBASELINE_GUARD_STD = 0.5  # English: apply baseline removal less often\n\n# Calibration thresholds\nGRID_CONF_THR = 0.20\nSMALL_SQ_MIN = 4.0\nSMALL_SQ_MAX = 12.0\nPIXELS_PER_MV_FALLBACK = 78.8\nGRID_STATS = {\"total\": 0, \"fallback\": 0, \"clamped\": 0}\n\nLEADS = [\"I\",\"II\",\"III\",\"aVR\",\"aVL\",\"aVF\",\"V1\",\"V2\",\"V3\",\"V4\",\"V5\",\"V6\"]\nLEADS_ORDER = LEADS\n\nTEMPLATE_STD_RHYTHM = dict(\n    name=\"std_rhythm\",\n    has_rhythm=True,\n    grid=[\n        [\"I\",   \"aVR\", \"V1\", \"V4\"],\n        [\"II\",  \"aVL\", \"V2\", \"V5\"],\n        [\"III\", \"aVF\", \"V3\", \"V6\"],\n    ],\n)\nTEMPLATE_STD_NO_RHYTHM = dict(\n    name=\"std_no_rhythm\",\n    has_rhythm=False,\n    grid=[\n        [\"I\",   \"aVR\", \"V1\", \"V4\"],\n        [\"II\",  \"aVL\", \"V2\", \"V5\"],\n        [\"III\", \"aVF\", \"V3\", \"V6\"],\n    ],\n)\nTEMPLATES = [TEMPLATE_STD_RHYTHM, TEMPLATE_STD_NO_RHYTHM]\n\n# CPU fast profile: keep the same pipeline, reduce cost where possible.\nCPU_FAST_MODE = (DEVICE == \"cpu\")\nif CPU_FAST_MODE:\n    MAX_TRAIN_STEPS = min(MAX_TRAIN_STEPS, 30)\n    TRAIN_MAX_IMAGES = min(TRAIN_MAX_IMAGES, 80)\n    LEADS_PER_IMAGE_CACHE = min(LEADS_PER_IMAGE_CACHE, 2)\n    BATCH_SIZE = min(BATCH_SIZE, 4)\n    RESIZE_SCALE = min(RESIZE_SCALE, 0.35)\n    TRACE_DP_MAX_JUMP = min(TRACE_DP_MAX_JUMP, 4)\n    GRID_RED_BLUR = min(GRID_RED_BLUR, 1)\n    USE_SKELETON = False\n    NUM_WORKERS = 0\n\n# Run modes: all | train | infer | cache\nRUN_MODE = os.environ.get(\"RUN_MODE\", \"all\").strip().lower()\n\n# ROI/geometry cache (save/reuse preprocessed ROIs across runs)\nUSE_ROI_CACHE = True\nROI_CACHE_DIR = Path(\"/kaggle/working/roi_cache\")\nROI_CACHE_COMPRESS = True\nROI_CACHE_TAG = f\"v1_rs{RESIZE_SCALE}_g{GAUSS_K}_turbo{int(TURBO_MODE)}\"\nPRECACHE_ROI = False\nPRECACHE_WORKERS = 2 if CPU_FAST_MODE else max(2, NUM_WORKERS)\n\n# Precompute common kernels to avoid repeated allocations.\nKERNEL_OPEN_3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))\nKERNEL_CLOSE_5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))\nKERNEL_ANISO_CLOSE = (\n    cv2.getStructuringElement(cv2.MORPH_RECT, (MASK_ANISO_CLOSE_W, MASK_ANISO_CLOSE_H))\n    if MASK_ANISO_CLOSE_W > 1\n    else None\n)\n\n# Reduce OpenCV thread contention on Kaggle\ntry:\n    cv2.setNumThreads(0)\n    cv2.ocl.setUseOpenCL(False)\nexcept Exception:\n    pass\n\n# ============================================================\n# 1) Basic helpers\n# ============================================================\ndef read_image_bgr_fast(path: Path) -> np.ndarray:\n    \"\"\"English: Faster image read on Kaggle.\"\"\"\n    img = cv2.imread(str(path), cv2.IMREAD_COLOR)\n    if img is None:\n        raise FileNotFoundError(f\"Cannot read image: {path}\")\n    return img\n\ndef resolve_test_image_path(base_id: str) -> Path | None:\n    base_id = str(base_id)\n    p0 = TEST_DIR / f\"{base_id}.png\"\n    if p0.exists():\n        return p0\n    cands = sorted(TEST_DIR.glob(f\"{base_id}-*.png\"))\n    if cands:\n        for p in cands:\n            if p.name.endswith(\"-0001.png\"):\n                return p\n        return cands[0]\n    cands = sorted(TEST_DIR.glob(f\"{base_id}*.png\"))\n    if cands:\n        return cands[0]\n    return None\n\ndef to_gray(img_bgr: np.ndarray) -> np.ndarray:\n    return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)\n\ndef redness_map(img_bgr: np.ndarray) -> np.ndarray:\n    b, g, r = cv2.split(img_bgr.astype(np.int16))\n    rs = r - np.maximum(g, b)\n    return np.clip(rs, 0, 255).astype(np.uint8)\n\ndef background_normalize(gray: np.ndarray) -> np.ndarray:\n    \"\"\"English: Remove background via morphology open then normalize.\"\"\"\n    k = max(25, (min(gray.shape)//30) | 1)\n    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))\n    bg = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel)\n    norm = cv2.normalize(gray.astype(np.int32) - bg.astype(np.int32) + 255,\n                         None, 0, 255, cv2.NORM_MINMAX)\n    return norm.astype(np.uint8)\n\ndef preprocess_image_light(img_bgr: np.ndarray) -> dict:\n    \"\"\"English: Light preprocessing (resize + background normalize + mild blur + redness map).\"\"\"\n    if RESIZE_SCALE != 1.0:\n        img_bgr = cv2.resize(img_bgr, None, fx=RESIZE_SCALE, fy=RESIZE_SCALE, interpolation=cv2.INTER_AREA)\n    gray = to_gray(img_bgr)\n    gray = background_normalize(gray)\n    if GAUSS_K >= 3:\n        k = GAUSS_K + 1 if (GAUSS_K % 2 == 0) else GAUSS_K\n        gray = cv2.GaussianBlur(gray, (k, k), 0)\n    red = redness_map(img_bgr)\n    return dict(img_bgr=img_bgr, gray=gray, red=red)\n\ndef estimate_skew_angle_hough(gray: np.ndarray) -> float:\n    \"\"\"English: Optional deskew; disabled by default.\"\"\"\n    blur = cv2.GaussianBlur(gray, (5, 5), 0)\n    edges = cv2.Canny(blur, 50, 150, apertureSize=3)\n    lines = cv2.HoughLinesP(\n        edges, 1, np.pi / 180, threshold=120,\n        minLineLength=max(60, gray.shape[1] // 8),\n        maxLineGap=10,\n    )\n    if lines is None:\n        return 0.0\n    angles = []\n    for x1, y1, x2, y2 in lines[:, 0]:\n        dx = x2 - x1\n        dy = y2 - y1\n        if dx == 0:\n            continue\n        ang = math.degrees(math.atan2(dy, dx))\n        if abs(ang) < 20:\n            angles.append(ang)\n    return float(np.median(angles)) if angles else 0.0\n\ndef rotate_keep(img: np.ndarray, angle_deg: float, border=255) -> np.ndarray:\n    h, w = img.shape[:2]\n    center = (w // 2, h // 2)\n    M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)\n    cos = abs(M[0, 0]); sin = abs(M[0, 1])\n    new_w = int(h * sin + w * cos)\n    new_h = int(h * cos + w * sin)\n    M[0, 2] += (new_w / 2) - center[0]\n    M[1, 2] += (new_h / 2) - center[1]\n    bv = border if img.ndim == 2 else (border, border, border)\n    return cv2.warpAffine(img, M, (new_w, new_h),\n                          flags=cv2.INTER_LINEAR,\n                          borderMode=cv2.BORDER_CONSTANT,\n                          borderValue=bv)\n\nclass GeoPipelineIdentity:\n    \"\"\"English: Geometry fallback (no external weights).\"\"\"\n    def __init__(self, use_deskew: bool = False):\n        self.use_deskew = bool(use_deskew)\n\n    def run(self, img_bgr: np.ndarray) -> np.ndarray:\n        if not self.use_deskew:\n            return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n        gray = to_gray(img_bgr)\n        ang = estimate_skew_angle_hough(gray)\n        if abs(ang) < 0.2:\n            return cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n        rotated = rotate_keep(img_bgr, -ang, border=255)\n        return cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB)\n\n# ============================================================\n# 2) ROI localization (template scoring)\n# ============================================================\ndef crop_waveform_area_with_bbox(gray: np.ndarray, red: np.ndarray):\n    thr = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\n                                cv2.THRESH_BINARY_INV, 51, 10)\n    row_mean = thr.mean(axis=1) / 255.0\n    idx = np.where(row_mean > 0.02)[0]\n    if len(idx) < 10:\n        return gray, red, 0, gray.shape[0]\n    y0 = int(max(0, idx[0] - 8))\n    y1 = int(min(gray.shape[0], idx[-1] + 8))\n    return gray[y0:y1, :], red[y0:y1, :], y0, y1\n\ndef split_template_boxes(gray_wave: np.ndarray, template: dict):\n    h, w = gray_wave.shape\n    if template[\"has_rhythm\"]:\n        band_h = h // 4\n        band_boxes = [(i*band_h, (i+1)*band_h) for i in range(3)]\n        rhythm_box = (3*band_h, h, 0, w)\n    else:\n        band_h = h // 3\n        band_boxes = [(i*band_h, (i+1)*band_h) for i in range(3)]\n        rhythm_box = None\n\n    grid = template[\"grid\"]\n    rois_box = {}\n    for r in range(3):\n        y0, y1 = band_boxes[r]\n        for c in range(4):\n            x0 = int(c*w/4); x1 = int((c+1)*w/4)\n            rois_box[grid[r][c]] = (y0, y1, x0, x1)\n    return rois_box, rhythm_box\n\ndef roi_score(gray_roi: np.ndarray) -> float:\n    thr = cv2.adaptiveThreshold(gray_roi, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\n                                cv2.THRESH_BINARY_INV, 51, 10)\n    ink = thr.mean() / 255.0\n    col = (thr.mean(axis=0) / 255.0)\n    col_has = (col > 0.02).mean()\n    return float((1.0 - abs(ink - 0.08))*0.6 + col_has*0.4)\n\ndef pick_best_template(prep: dict) -> dict:\n    gray = prep[\"gray\"]\n    red  = prep[\"red\"]\n    gray_wave, red_wave, y0, y1 = crop_waveform_area_with_bbox(gray, red)\n\n    best = None\n    best_score = -1e9\n    for tpl in TEMPLATES:\n        rois_box, rhythm_box = split_template_boxes(gray_wave, tpl)\n        s = 0.0\n        for ld in LEADS:\n            b = rois_box[ld]\n            g = gray_wave[b[0]:b[1], b[2]:b[3]]\n            s += roi_score(g)\n        if tpl[\"has_rhythm\"] and rhythm_box is not None:\n            rb = rhythm_box\n            s += 0.2 * roi_score(gray_wave[rb[0]:rb[1], rb[2]:rb[3]])\n        if s > best_score:\n            best_score = s\n            best = dict(\n                template=tpl,\n                score=s,\n                gray_wave=gray_wave,\n                red_wave=red_wave,\n                wave_y0=y0,\n                wave_y1=y1,\n                rois_box=rois_box,\n                rhythm_box=rhythm_box,\n            )\n    return best\n\ndef roi_cache_path_for_image(img_path: Path) -> Path:\n    key = f\"{img_path.parent.name}_{img_path.stem}\"\n    safe = re.sub(r\"[^A-Za-z0-9_.-]\", \"_\", key)\n    return ROI_CACHE_DIR / f\"{safe}_roi.npz\"\n\ndef _save_roi_cache(cache_path: Path, cache_tag: str, prep: dict, best: dict) -> None:\n    ROI_CACHE_DIR.mkdir(parents=True, exist_ok=True)\n    rois_box = np.array([best[\"rois_box\"][ld] for ld in LEADS], dtype=np.int32)\n    rhythm_box = best[\"rhythm_box\"] if best[\"rhythm_box\"] is not None else (-1, -1, -1, -1)\n    payload = dict(\n        cache_tag=np.array([cache_tag]),\n        template_name=np.array([best[\"template\"][\"name\"]]),\n        score=np.array([best[\"score\"]], dtype=np.float32),\n        gray_wave=best[\"gray_wave\"].astype(np.uint8),\n        red_wave=best[\"red_wave\"].astype(np.uint8),\n        prep_red=prep[\"red\"].astype(np.uint8),\n        rois_box=rois_box,\n        rhythm_box=np.array(rhythm_box, dtype=np.int32),\n    )\n    if ROI_CACHE_COMPRESS:\n        np.savez_compressed(cache_path, **payload)\n    else:\n        np.savez(cache_path, **payload)\n\ndef _load_roi_cache(cache_path: Path, cache_tag: str) -> tuple[dict, dict] | None:\n    if not cache_path.exists():\n        return None\n    try:\n        data = np.load(cache_path, allow_pickle=False)\n        if str(data[\"cache_tag\"][0]) != cache_tag:\n            return None\n        tpl_name = str(data[\"template_name\"][0])\n        tpl = next((t for t in TEMPLATES if t[\"name\"] == tpl_name), TEMPLATE_STD_RHYTHM)\n        rois_arr = data[\"rois_box\"]\n        rois_box = {ld: tuple(int(x) for x in rois_arr[i]) for i, ld in enumerate(LEADS)}\n        rb = tuple(int(x) for x in data[\"rhythm_box\"].tolist())\n        rhythm_box = None if rb[0] < 0 else rb\n        best = dict(\n            template=tpl,\n            score=float(data[\"score\"][0]),\n            gray_wave=data[\"gray_wave\"],\n            red_wave=data[\"red_wave\"],\n            rois_box=rois_box,\n            rhythm_box=rhythm_box,\n        )\n        prep = dict(red=data[\"prep_red\"])\n        return prep, best\n    except Exception:\n        return None\n\ndef get_prep_and_best_from_path(img_path: Path, geo_pipe, use_geo: bool = True) -> tuple[dict, dict]:\n    cache_tag = f\"{ROI_CACHE_TAG}_geo{int(use_geo)}\"\n    cache_path = roi_cache_path_for_image(img_path)\n    if USE_ROI_CACHE:\n        cached = _load_roi_cache(cache_path, cache_tag)\n        if cached is not None:\n            return cached\n\n    img_bgr = read_image_bgr_fast(img_path)\n    if use_geo:\n        stage1_rgb = geo_pipe.run(img_bgr)\n        img_bgr = cv2.cvtColor(stage1_rgb, cv2.COLOR_RGB2BGR)\n    prep = preprocess_image_light(img_bgr)\n    best = pick_best_template(prep)\n\n    if USE_ROI_CACHE:\n        _save_roi_cache(cache_path, cache_tag, prep, best)\n    return prep, best\n\ndef precache_roi_for_paths(img_paths: list[Path], geo_pipe, use_geo: bool, workers: int) -> None:\n    if not USE_ROI_CACHE:\n        return\n    if not img_paths:\n        return\n    if workers <= 1:\n        for p in img_paths:\n            _ = get_prep_and_best_from_path(p, geo_pipe, use_geo=use_geo)\n        return\n    with ThreadPoolExecutor(max_workers=int(workers)) as ex:\n        list(ex.map(lambda p: get_prep_and_best_from_path(p, geo_pipe, use_geo=use_geo), img_paths))\n\ndef detect_rhythm_strip(best: dict):\n    tpl = best[\"template\"]\n    rb = best[\"rhythm_box\"]\n    if (not tpl[\"has_rhythm\"]) or (rb is None):\n        return False, None, None\n    gray_wave = best[\"gray_wave\"]\n    red_wave  = best[\"red_wave\"]\n    y0,y1,x0,x1 = rb\n    rh_g = gray_wave[y0:y1, x0:x1]\n    thr = cv2.adaptiveThreshold(rh_g, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\n                                cv2.THRESH_BINARY_INV, 51, 10)\n    cont = ((thr.mean(axis=0)/255.0) > 0.02).mean()\n    if cont > 0.35:\n        rh_r = red_wave[y0:y1, x0:x1]\n        return True, rh_g, rh_r\n    return False, None, None\n\ndef template_ink_mean(gray_wave: np.ndarray, rois_box: dict) -> float:\n    inks = []\n    for ld in LEADS:\n        y0,y1,x0,x1 = rois_box[ld]\n        roi = gray_wave[y0:y1, x0:x1]\n        if roi.size == 0:\n            continue\n        thr = cv2.adaptiveThreshold(roi, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\n                                    cv2.THRESH_BINARY_INV, 51, 10)\n        inks.append(float(thr.mean() / 255.0))\n    return float(np.mean(inks)) if inks else 0.0\n\n# ============================================================\n# 3) Grid spacing estimation (for pixels_per_mV)\n# ============================================================\ndef autocorr_1d(x: np.ndarray) -> np.ndarray:\n    x = x.astype(np.float32)\n    x = x - x.mean()\n    n = len(x)\n    if n < 64:\n        return np.zeros(n, dtype=np.float32)\n    f = np.fft.rfft(x, n=2*n)\n    ac = np.fft.irfft(f * np.conj(f))[:n].astype(np.float32)\n    ac[0] = 0.0\n    return ac\n\ndef pick_small_square_from_ac(ac: np.ndarray, min_lag: int, max_lag: int) -> float:\n    n = len(ac)\n    min_lag = int(max(1, min_lag))\n    max_lag = int(min(n-1, max_lag))\n    seg = ac[min_lag:max_lag+1]\n    if seg.size == 0:\n        return float(min_lag)\n    mx = float(seg.max())\n    if mx <= 1e-6:\n        return float(min_lag)\n    thr = 0.55 * mx\n    idxs = np.where(seg >= thr)[0]\n    if len(idxs) > 0:\n        return float(idxs[0] + min_lag)\n    return float(np.argmax(seg) + min_lag)\n\ndef _autocorr_conf(ac: np.ndarray, min_lag: int, max_lag: int) -> float:\n    n = len(ac)\n    min_lag = int(max(1, min_lag))\n    max_lag = int(min(n - 1, max_lag))\n    seg = ac[min_lag:max_lag + 1]\n    if seg.size == 0:\n        return 0.0\n    mx = float(seg.max())\n    mean = float(seg.mean())\n    if mx <= 1e-6:\n        return 0.0\n    return float((mx - mean) / (mx + 1e-6))\n\ndef estimate_small_square_with_confidence(prep: dict) -> tuple[float, float]:\n    red = prep[\"red\"]\n    px = red.sum(axis=0).astype(np.float32)\n    py = red.sum(axis=1).astype(np.float32)\n\n    px = cv2.GaussianBlur(px.reshape(1, -1), (1, 31), 0).ravel()\n    py = cv2.GaussianBlur(py.reshape(-1, 1), (31, 1), 0).ravel()\n\n    acx = autocorr_1d(px)\n    acy = autocorr_1d(py)\n\n    lag_x = pick_small_square_from_ac(acx, 3, 30)\n    lag_y = pick_small_square_from_ac(acy, 3, 30)\n\n    small = float(min(lag_x, lag_y))\n    if small > 18.0:\n        small = small / 5.0\n    conf = float(min(_autocorr_conf(acx, 3, 30), _autocorr_conf(acy, 3, 30)))\n\n    if not np.isfinite(small) or small < 3.0 or small > 18.0:\n        small = 5.0\n        conf = 0.0\n    return float(small), conf\n\n# ============================================================\n# 4) Letterbox resize utilities\n# ============================================================\ndef letterbox_resize(img: np.ndarray, target_hw: tuple, border_value=255, is_mask=False):\n    th, tw = target_hw\n    h, w = img.shape[:2]\n    if h <= 0 or w <= 0:\n        raise ValueError(\"Invalid image size in letterbox_resize\")\n\n    scale = min(tw / w, th / h)\n    nw = max(1, int(round(w * scale)))\n    nh = max(1, int(round(h * scale)))\n\n    interp = cv2.INTER_NEAREST if is_mask else cv2.INTER_AREA\n    resized = cv2.resize(img, (nw, nh), interpolation=interp)\n\n    out = np.full((th, tw) if img.ndim == 2 else (th, tw, img.shape[2]),\n                  border_value, dtype=resized.dtype)\n\n    top = (th - nh) // 2\n    left = (tw - nw) // 2\n    out[top:top+nh, left:left+nw] = resized\n    meta = dict(top=top, left=left, nh=nh, nw=nw, scale=scale)\n    return out, meta\n\ndef unpad_from_letterbox(arr2d: np.ndarray, meta: dict):\n    t, l, nh, nw = meta[\"top\"], meta[\"left\"], meta[\"nh\"], meta[\"nw\"]\n    return arr2d[t:t+nh, l:l+nw]\n\n# ============================================================\n# 5) Grid suppression + classical mask\n# ============================================================\ndef suppress_red_grid(gray_roi: np.ndarray, red_roi: np.ndarray,\n                      factor: float = GRID_RED_FACTOR,\n                      gamma: float = GRID_RED_GAMMA,\n                      blur: int = GRID_RED_BLUR) -> np.ndarray:\n    \"\"\"English: Penalize red grid, so ECG ink dominates.\"\"\"\n    g = gray_roi.astype(np.float32)\n    r = red_roi.astype(np.float32) / 255.0\n    if blur and blur > 1:\n        k = blur if (blur % 2 == 1) else (blur + 1)\n        r = cv2.GaussianBlur(r, (k, k), 0)\n    penalty = (r ** float(gamma)) * float(factor) * 255.0\n    out = g - penalty\n    return np.clip(out, 0, 255).astype(np.uint8)\n\ndef grid_mask_from_red(red_roi: np.ndarray, hlen: int, vlen: int) -> np.ndarray:\n    if red_roi.size == 0:\n        return np.zeros_like(red_roi, dtype=np.uint8)\n    _, rbin = cv2.threshold(red_roi, GRID_RED_THR, 255, cv2.THRESH_BINARY)\n    if float(rbin.mean()) < 1.0:\n        return np.zeros_like(red_roi, dtype=np.uint8)\n    hk = cv2.getStructuringElement(cv2.MORPH_RECT, (hlen, 1))\n    vk = cv2.getStructuringElement(cv2.MORPH_RECT, (1, vlen))\n    horiz = cv2.morphologyEx(rbin, cv2.MORPH_OPEN, hk, iterations=1)\n    vert = cv2.morphologyEx(rbin, cv2.MORPH_OPEN, vk, iterations=1)\n    return cv2.bitwise_or(horiz, vert)\n\ndef remove_grid_morph_to_mask(gray_roi: np.ndarray, red_roi: np.ndarray,\n                              adapt_blocksize: int = 31, adapt_C: int = 10, red_factor: float = GRID_RED_FACTOR) -> np.ndarray:\n    \"\"\"English: Remove grid then threshold to get ECG mask.\"\"\"\n    gray_roi = suppress_red_grid(gray_roi, red_roi, factor=red_factor)\n\n    bs = int(adapt_blocksize)\n    if bs % 2 == 0:\n        bs += 1\n    bs = max(21, min(bs, 71))\n    C = int(adapt_C)\n\n    thr = cv2.adaptiveThreshold(gray_roi, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\n                                cv2.THRESH_BINARY_INV, bs, C)\n\n    h, w = thr.shape\n    hlen = max(12, w // GRID_OPEN_SCALE)\n    vlen = max(12, h // GRID_OPEN_SCALE)\n\n    hk = cv2.getStructuringElement(cv2.MORPH_RECT, (hlen, 1))\n    vk = cv2.getStructuringElement(cv2.MORPH_RECT, (1, vlen))\n    horiz = cv2.morphologyEx(thr, cv2.MORPH_OPEN, hk, iterations=1)\n    vert = cv2.morphologyEx(thr, cv2.MORPH_OPEN, vk, iterations=1)\n    grid = cv2.bitwise_or(horiz, vert)\n\n    grid_red = grid_mask_from_red(red_roi, hlen, vlen)\n    if grid_red.size == grid.size:\n        grid = cv2.bitwise_or(grid, grid_red)\n\n    no_grid = cv2.bitwise_and(thr, cv2.bitwise_not(grid))\n    mask = (no_grid > 0).astype(np.uint8)\n\n    # Anisotropic horizontal close to connect broken ECG strokes\n    if MASK_ANISO_CLOSE_W > 1:\n        hk2 = cv2.getStructuringElement(cv2.MORPH_RECT, (MASK_ANISO_CLOSE_W, MASK_ANISO_CLOSE_H))\n        mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, hk2, iterations=MASK_ANISO_CLOSE_ITER)\n\n    # Cut left margin noise\n    cut = int(mask.shape[1] * 0.03)\n    if cut > 5:\n        mask[:, :cut] = 0\n\n    return mask\n\ndef mask_quality(mask: np.ndarray):\n    col_cov = float((mask.mean(axis=0) > 0.002).mean())\n    ink = float(mask.mean())\n    return col_cov, ink\n\ndef classical_mask_with_fallback(g_raw: np.ndarray, r_raw: np.ndarray) -> np.ndarray:\n    \"\"\"English: Two-pass classical mask to reduce empty masks.\"\"\"\n    m1 = remove_grid_morph_to_mask(g_raw, r_raw, adapt_blocksize=31, adapt_C=10, red_factor=GRID_RED_FACTOR)\n    col1, ink1 = mask_quality(m1)\n    if (col1 < 0.18) or (ink1 < 0.002):\n        m2 = remove_grid_morph_to_mask(g_raw, r_raw, adapt_blocksize=51, adapt_C=8, red_factor=GRID_RED_FACTOR * 0.7)\n        col2, ink2 = mask_quality(m2)\n        if (col2 > col1) or (ink2 > ink1):\n            return m2.astype(np.uint8)\n    return m1.astype(np.uint8)\n\ndef prob_is_bad(prob: np.ndarray) -> bool:\n    return (float(np.max(prob)) < PRED_MAX_THR) or (float(np.mean(prob)) < PRED_MEAN_THR)\n\ndef mask_is_bad(mask: np.ndarray) -> bool:\n    col_cov, ink = mask_quality(mask.astype(np.uint8))\n    return (col_cov < MASK_MIN_COLCOV) or (ink < MASK_MIN_INK)\n\n# ============================================================\n# 6) Postprocess mask\n# ============================================================\ndef postprocess_mask(prob: np.ndarray) -> np.ndarray:\n    \"\"\"English: Threshold + morph + CC filter + optional skeletonize.\"\"\"\n    m = (prob >= MASK_THR).astype(np.uint8)\n\n    m = cv2.morphologyEx(m, cv2.MORPH_OPEN, KERNEL_OPEN_3, iterations=1)\n    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, KERNEL_CLOSE_5, iterations=1)\n\n    if KERNEL_ANISO_CLOSE is not None:\n        m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, KERNEL_ANISO_CLOSE, iterations=MASK_ANISO_CLOSE_ITER)\n\n    num, lbl, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)\n    if num > 1:\n        min_area = max(10, int(MIN_CC_FRAC * m.size))\n        keep = np.where(stats[1:, cv2.CC_STAT_AREA] >= min_area)[0] + 1\n        m2 = np.zeros_like(m)\n        for k in keep:\n            m2[lbl == k] = 1\n        m = m2\n\n    if USE_SKELETON and sk_skeletonize is not None:\n        if SKELETON_IF_THICK:\n            h, w = m.shape\n            thick_area = float(m.mean()) >= MASK_THICK_AREA_FRAC\n            thick_col = float(m.sum(axis=0).mean() / max(1.0, float(h))) >= MASK_THICK_COL_FRAC\n            if thick_area or thick_col:\n                m = sk_skeletonize(m.astype(bool)).astype(np.uint8)\n        else:\n            m = sk_skeletonize(m.astype(bool)).astype(np.uint8)\n\n    return m\n\n# ============================================================\n# 7) Trace extraction (DP/Viterbi + smart gap filling)\n# ============================================================\ndef trace_soft_argmax(prob: np.ndarray) -> np.ndarray:\n    h, w = prob.shape\n    y = np.arange(h, dtype=np.float32).reshape(h, 1)\n    p = prob.astype(np.float32)\n    denom = p.sum(axis=0) + 1e-6\n    return ((p * y).sum(axis=0) / denom).astype(np.float32)\n\ndef build_trace_prob(mask: np.ndarray | None, prob: np.ndarray | None, gray: np.ndarray | None) -> np.ndarray | None:\n    \"\"\"English: Build evidence map from prob or grayscale; reinforce inside mask.\"\"\"\n    if prob is not None:\n        p = prob.astype(np.float32)\n    elif gray is not None:\n        g = gray.astype(np.float32) / 255.0\n        p = np.clip(1.0 - g, 0.0, 1.0) ** float(TRACE_GRAY_GAMMA)\n    elif mask is not None:\n        p = mask.astype(np.float32)\n    else:\n        return None\n\n    if mask is not None:\n        p = np.clip(p, 0.0, 1.0)\n        p = np.clip(p + 0.35 * mask.astype(np.float32), 0.0, 1.0)\n\n    if TRACE_PROB_GAMMA != 1.0:\n        p = np.clip(p, 1e-6, 1.0) ** float(TRACE_PROB_GAMMA)\n    return p.astype(np.float32)\n\n_TRACE_VITERBI_CACHE: dict[int, tuple[np.ndarray, np.ndarray]] = {}\n\ndef _get_viterbi_cache(max_jump: int) -> tuple[np.ndarray, np.ndarray]:\n    key = int(max_jump)\n    cached = _TRACE_VITERBI_CACHE.get(key)\n    if cached is None:\n        dy_vals = np.arange(-key, key + 1, dtype=np.int32)\n        abs_dy = np.abs(dy_vals[:, None] - dy_vals[None, :]).astype(np.float32)\n        cached = (dy_vals, abs_dy)\n        _TRACE_VITERBI_CACHE[key] = cached\n    return cached\n\ndef trace_viterbi(cost: np.ndarray, max_jump: int, lam: float, mu: float) -> np.ndarray:\n    \"\"\"English: Global path finding with smoothness + curvature penalty (DP/Viterbi).\"\"\"\n    h, w = cost.shape\n    if w <= 0 or h <= 0:\n        return np.array([], dtype=np.float32)\n\n    max_jump = int(max(1, max_jump))\n    dy_vals, abs_dy = _get_viterbi_cache(max_jump)\n    j = len(dy_vals)\n    dy0_idx = max_jump\n\n    cost = np.clip(cost.astype(np.float32), 0.0, float(TRACE_COST_CLIP))\n\n    dp_prev = np.full((h, j), np.inf, dtype=np.float32)\n    dp_prev[:, dy0_idx] = cost[:, 0]\n    back = np.full((w, h, j), -1, dtype=np.int16)\n\n    for x in range(1, w):\n        dp_curr = np.full((h, j), np.inf, dtype=np.float32)\n        for y_prev in range(h):\n            prev_vec = dp_prev[y_prev]\n            if not np.isfinite(prev_vec).any():\n                continue\n            trans = prev_vec[None, :] + (mu * abs_dy)\n            best_prev_cost = trans.min(axis=1)\n            best_prev_idx = trans.argmin(axis=1).astype(np.int16)\n\n            for dy_idx, dy in enumerate(dy_vals):\n                y = y_prev + dy\n                if y < 0 or y >= h:\n                    continue\n                c = best_prev_cost[dy_idx] + cost[y, x] + (lam * abs(float(dy)))\n                if c < dp_curr[y, dy_idx]:\n                    dp_curr[y, dy_idx] = c\n                    back[x, y, dy_idx] = best_prev_idx[dy_idx]\n        dp_prev = dp_curr\n\n    if not np.isfinite(dp_prev).any():\n        return np.full(w, np.nan, dtype=np.float32)\n\n    y_end, dy_end_idx = np.unravel_index(np.argmin(dp_prev), dp_prev.shape)\n    y_end = int(y_end); dy_end_idx = int(dy_end_idx)\n\n    path = np.full(w, np.nan, dtype=np.float32)\n    path[w - 1] = float(y_end)\n    y = y_end\n    dy_idx = dy_end_idx\n    for x in range(w - 1, 0, -1):\n        dy = int(dy_vals[dy_idx])\n        y_prev = y - dy\n        if y_prev < 0 or y_prev >= h:\n            break\n        dy_prev_idx = int(back[x, y, dy_idx])\n        if dy_prev_idx < 0:\n            break\n        path[x - 1] = float(y_prev)\n        y = y_prev\n        dy_idx = dy_prev_idx\n\n    return path.astype(np.float32)\n\ndef trace_intensity_fallback(gray: np.ndarray) -> np.ndarray:\n    \"\"\"English: Fallback trace = darkest pixel per column.\"\"\"\n    if gray is None or gray.size == 0:\n        return np.array([], dtype=np.float32)\n    g = cv2.GaussianBlur(gray.astype(np.uint8), (3, 3), 0)\n    y = np.argmin(g, axis=0).astype(np.float32)\n    return y\n\ndef fill_trace_gaps(y: np.ndarray, gray: np.ndarray | None) -> np.ndarray:\n    \"\"\"English: Fill gaps with: short=linear, medium=PCHIP, long=Hermite(slope-aware), fallback=darkest.\"\"\"\n    n = len(y)\n    if n == 0:\n        return y.astype(np.float32)\n\n    out = y.astype(np.float32).copy()\n    valid = np.isfinite(out)\n\n    if valid.sum() < 2:\n        if gray is not None:\n            fb = trace_intensity_fallback(gray)\n            if len(fb) == n:\n                return fb.astype(np.float32)\n        return out.astype(np.float32)\n\n    idx = np.arange(n)\n    gaps = []\n    i = 0\n    while i < n:\n        if valid[i]:\n            i += 1\n            continue\n        j = i\n        while j < n and not valid[j]:\n            j += 1\n        gaps.append((i, j))\n        i = j\n\n    for g0, g1 in gaps:\n        gap_len = g1 - g0\n        left = g0 - 1\n        right = g1\n\n        if left < 0 and right >= n:\n            break\n\n        # edge gaps: extrapolate using local slope\n        if left < 0:\n            y0 = out[right]\n            slope = 0.0\n            if right + 1 < n and np.isfinite(out[right + 1]):\n                slope = float(out[right + 1] - out[right])\n            for k in range(g0, g1):\n                out[k] = y0 - slope * float(right - k)\n            continue\n\n        if right >= n:\n            y0 = out[left]\n            slope = 0.0\n            if left - 1 >= 0 and np.isfinite(out[left - 1]):\n                slope = float(out[left] - out[left - 1])\n            for k in range(g0, g1):\n                out[k] = y0 + slope * float(k - left)\n            continue\n\n        y_left = float(out[left])\n        y_right = float(out[right])\n\n        if gap_len <= TRACE_GAP_SHORT_MAX:\n            out[g0:g1] = np.interp(idx[g0:g1], [left, right], [y_left, y_right]).astype(np.float32)\n            continue\n\n        if gap_len <= TRACE_GAP_MED_MAX:\n            win_lo = max(0, left - 2)\n            win_hi = min(n - 1, right + 2)\n            pts = idx[win_lo:win_hi + 1]\n            vals = out[win_lo:win_hi + 1]\n            keep = np.isfinite(vals)\n            if keep.sum() >= 3:\n                try:\n                    pchip = PchipInterpolator(pts[keep], vals[keep])\n                    out[g0:g1] = pchip(idx[g0:g1]).astype(np.float32)\n                    continue\n                except Exception:\n                    pass\n            out[g0:g1] = np.interp(idx[g0:g1], [left, right], [y_left, y_right]).astype(np.float32)\n            continue\n\n        # long gap: Hermite with damped slopes (avoid overshoot)\n        slope_l = 0.0\n        if left - 1 >= 0 and np.isfinite(out[left - 1]):\n            slope_l = float(out[left] - out[left - 1])\n        slope_r = 0.0\n        if right + 1 < n and np.isfinite(out[right + 1]):\n            slope_r = float(out[right + 1] - out[right])\n        slope_l *= TRACE_LONG_GAP_SLOPE_SCALE\n        slope_r *= TRACE_LONG_GAP_SLOPE_SCALE\n        try:\n            herm = CubicHermiteSpline([left, right], [y_left, y_right], [slope_l, slope_r])\n            out[g0:g1] = herm(idx[g0:g1]).astype(np.float32)\n        except Exception:\n            out[g0:g1] = np.interp(idx[g0:g1], [left, right], [y_left, y_right]).astype(np.float32)\n\n    # If still NaN, fill with intensity fallback per missing positions\n    if np.isnan(out).any() and gray is not None:\n        fb = trace_intensity_fallback(gray)\n        if len(fb) == n:\n            miss = np.isnan(out)\n            out[miss] = fb[miss]\n\n    return out.astype(np.float32)\n\ndef trace_ecg_path(mask: np.ndarray, prob: np.ndarray | None, gray: np.ndarray | None) -> np.ndarray:\n    \"\"\"English: Build cost map then run DP/Viterbi; gate weak columns; fill gaps smartly.\"\"\"\n    if mask is None or mask.size == 0:\n        return trace_intensity_fallback(gray).astype(np.float32) if gray is not None else np.array([], dtype=np.float32)\n\n    m = mask.astype(np.uint8)\n\n    if prob is not None and prob.shape != m.shape:\n        prob = cv2.resize(prob.astype(np.float32), (m.shape[1], m.shape[0]), interpolation=cv2.INTER_LINEAR)\n    if gray is not None and gray.shape != m.shape:\n        gray = cv2.resize(gray.astype(np.uint8), (m.shape[1], m.shape[0]), interpolation=cv2.INTER_AREA)\n\n    # Conditional skeletonize if mask is thick\n    if USE_SKELETON and sk_skeletonize is not None and SKELETON_IF_THICK:\n        h, w = m.shape\n        thick_area = float(m.mean()) >= MASK_THICK_AREA_FRAC\n        thick_col = float(m.sum(axis=0).mean() / max(1.0, float(h))) >= MASK_THICK_COL_FRAC\n        if thick_area or thick_col:\n            sk = sk_skeletonize(m.astype(bool)).astype(np.uint8)\n            if sk.sum() > 0:\n                m = sk\n\n    prob_map = build_trace_prob(m, prob, gray)\n    if prob_map is None:\n        return trace_intensity_fallback(gray).astype(np.float32) if gray is not None else np.array([], dtype=np.float32)\n\n    cost = -np.log(np.clip(prob_map, 1e-6, 1.0)).astype(np.float32)\n    if TRACE_OUTSIDE_MASK_PENALTY > 0:\n        cost = cost + (1.0 - m.astype(np.float32)) * float(TRACE_OUTSIDE_MASK_PENALTY)\n\n    y = trace_viterbi(cost, TRACE_DP_MAX_JUMP, TRACE_DP_LAMBDA, TRACE_DP_MU) if TRACE_USE_DP else trace_soft_argmax(prob_map)\n\n    support = prob_map.max(axis=0).astype(np.float32)\n    support = np.maximum(support, m.max(axis=0).astype(np.float32))\n    y = y.astype(np.float32)\n    y[support < TRACE_MIN_SUPPORT] = np.nan\n\n    valid_frac = float(np.isfinite(y).mean()) if len(y) > 0 else 0.0\n    if valid_frac < TRACE_MIN_VALID_FRAC:\n        if TRACE_SOFTARGMAX_FALLBACK:\n            y = trace_soft_argmax(prob_map)\n        elif gray is not None:\n            y = trace_intensity_fallback(gray)\n\n    y = fill_trace_gaps(y, gray)\n    return y.astype(np.float32)\n\ndef median_smooth_1d(sig: np.ndarray, k: int = 5) -> np.ndarray:\n    \"\"\"English: median smoothing on trace y (pixel domain).\"\"\"\n    if k < 3:\n        return sig.astype(np.float32)\n    if k % 2 == 0:\n        k += 1\n    s = sig.astype(np.float32)\n    mn = float(np.min(s)); mx = float(np.max(s))\n    if not np.isfinite(mn) or not np.isfinite(mx) or (mx - mn) < 1e-6:\n        return s.astype(np.float32)\n    t = ((s - mn) / (mx - mn) * 255.0).clip(0, 255).astype(np.uint8)\n    t2 = cv2.medianBlur(t.reshape(1, -1), k).ravel()\n    out = (t2.astype(np.float32) / 255.0) * (mx - mn) + mn\n    return out.astype(np.float32)\n\ndef y_to_mV(y_pix: np.ndarray, pixels_per_mV: float) -> np.ndarray:\n    baseline = np.median(y_pix)\n    mv = (baseline - y_pix) / max(pixels_per_mV, 1e-6)\n    return mv.astype(np.float32)\n\ndef resample_1d(sig: np.ndarray, target_len: int) -> np.ndarray:\n    n = len(sig)\n    target_len = int(target_len)\n    if n == target_len:\n        return sig.astype(np.float32)\n    x_old = np.linspace(0, 1, n, endpoint=True)\n    x_new = np.linspace(0, 1, target_len, endpoint=True)\n    return np.interp(x_new, x_old, sig).astype(np.float32)\n\ndef _safe_filtfilt(b: np.ndarray, a: np.ndarray, sig: np.ndarray) -> np.ndarray:\n    padlen = 3 * max(len(a), len(b))\n    if len(sig) <= padlen:\n        return sig.astype(np.float32)\n    return filtfilt(b, a, sig).astype(np.float32)\n\ndef bandpass_filter(sig: np.ndarray, fs: float, low_hz: float, high_hz: float, order: int = 2) -> np.ndarray:\n    if fs is None or fs <= 0:\n        return sig.astype(np.float32)\n    nyq = 0.5 * float(fs)\n    low = float(low_hz) / nyq if low_hz else 0.0\n    high = float(high_hz) / nyq if high_hz else 0.0\n    if high >= 1.0:\n        high = 0.99\n    if low <= 0.0:\n        low = 0.0\n    if (low > 0.0) and (high > 0.0):\n        if low >= high:\n            return sig.astype(np.float32)\n        b, a = butter(int(order), [low, high], btype=\"band\")\n        return _safe_filtfilt(b, a, sig)\n    if high > 0.0:\n        b, a = butter(int(order), high, btype=\"low\")\n        return _safe_filtfilt(b, a, sig)\n    if low > 0.0:\n        b, a = butter(int(order), low, btype=\"high\")\n        return _safe_filtfilt(b, a, sig)\n    return sig.astype(np.float32)\n\ndef notch_filter(sig: np.ndarray, fs: float, notch_hz: float, q: float = 30.0) -> np.ndarray:\n    if fs is None or fs <= 0:\n        return sig.astype(np.float32)\n    w0 = float(notch_hz) / (0.5 * float(fs))\n    if w0 <= 0.0 or w0 >= 1.0:\n        return sig.astype(np.float32)\n    b, a = iirnotch(w0, q)\n    return _safe_filtfilt(b, a, sig)\n\ndef _savgol_window(fs: float, n: int) -> int:\n    if fs is None or fs <= 0 or n < 7:\n        return 0\n    win = int(round(float(fs) * float(SAVGOL_WIN_SEC)))\n    win = max(int(SAVGOL_WIN_MIN), min(int(SAVGOL_WIN_MAX), win))\n    if win % 2 == 0:\n        win += 1\n    if win >= n:\n        win = n - 1 if (n % 2 == 0) else n\n    if win < 5:\n        return 0\n    return int(win)\n\ndef apply_ecg_filters(sig: np.ndarray, fs: float) -> np.ndarray:\n    \"\"\"English: zero-phase bandpass (+ optional notch + optional SavGol).\"\"\"\n    s = sig.astype(np.float32)\n    s = bandpass_filter(s, fs, FILTER_HIGHPASS_HZ, FILTER_LOWPASS_HZ, order=FILTER_ORDER)\n    if FILTER_APPLY_NOTCH:\n        s = notch_filter(s, fs, FILTER_NOTCH_HZ, q=FILTER_NOTCH_Q)\n    win = _savgol_window(fs, len(s))\n    if win > 0 and len(s) > win:\n        s = savgol_filter(s, window_length=win, polyorder=SAVGOL_POLY).astype(np.float32)\n    return s.astype(np.float32)\n\ndef remove_baseline_soft(sig: np.ndarray, fs: float, win_sec: float = BASELINE_WIN_SEC) -> np.ndarray:\n    \"\"\"English: moving-average baseline subtraction (gentle).\"\"\"\n    w = int(max(3, fs * win_sec))\n    if w % 2 == 0:\n        w += 1\n    if w >= len(sig):\n        return sig.astype(np.float32)\n    kernel = np.ones(w, dtype=np.float32) / w\n    baseline = np.convolve(sig, kernel, mode=\"same\")\n    return (sig - baseline).astype(np.float32)\n\n# ============================================================\n# 8) Hybrid model (ConvNeXt + TransUNet + BiLSTM) - FAST\n# ============================================================\n# Smaller transformer + smaller LSTM for speed (keep hybrid concept)\nTUNET_EMBED_DIM = 256 if TURBO_MODE else 512\nTUNET_NHEAD = 4 if TURBO_MODE else 8\nTUNET_DEPTH = 2 if TURBO_MODE else 4\nTUNET_MLP_RATIO = 3.0 if TURBO_MODE else 4.0\nLSTM_EMBED_DIM = 32 if TURBO_MODE else 64\nLSTM_HIDDEN = 64 if TURBO_MODE else 128\nPATCH_SIZE = 16  # keep stride=16 so token grid aligns to /16 feature map\n\ndef build_convnext_tiny(pretrained: bool):\n    if not pretrained:\n        return convnext_tiny(weights=None)\n    return convnext_tiny(weights=None)\n\ndef build_resnet50(pretrained: bool):\n    if not pretrained:\n        return resnet50(weights=None)\n    return resnet50(weights=None)\n\nclass ConvNeXtEncoder(nn.Module):\n    \"\"\"English: ConvNeXt-Tiny encoder returning multi-scale features.\"\"\"\n    def __init__(self, in_ch=3):\n        super().__init__()\n        m = build_convnext_tiny(pretrained=USE_PRETRAINED_BACKBONE)\n        # ConvNeXt stem: m.features[0] -> (Conv2d stride 4 + LN)\n        if in_ch != 3:\n            m.features[0][0] = nn.Conv2d(in_ch, 96, kernel_size=4, stride=4)\n        self.features = m.features\n\n    def forward(self, x):\n        x = self.features[0](x)\n        x = self.features[1](x); s1 = x   # /4, 96\n        x = self.features[2](x)\n        x = self.features[3](x); s2 = x   # /8, 192\n        x = self.features[4](x)\n        x = self.features[5](x); s3 = x   # /16, 384\n        x = self.features[6](x)\n        x = self.features[7](x); s4 = x   # /32, 768 (unused)\n        return s1, s2, s3, s4\n\nclass TransUNetBackbone(nn.Module):\n    \"\"\"English: ResNet50 to /16 + Transformer over tokens, returning /16 feature.\"\"\"\n    def __init__(self, in_ch=3, embed_dim=256, nhead=4, depth=2, mlp_ratio=3.0, drop=0.0):\n        super().__init__()\n        enc = build_resnet50(pretrained=USE_PRETRAINED_BACKBONE)\n        if in_ch != 3:\n            enc.conv1 = nn.Conv2d(in_ch, 64, kernel_size=7, stride=2, padding=3, bias=False)\n\n        self.conv1 = enc.conv1\n        self.bn1 = enc.bn1\n        self.relu = enc.relu\n        self.maxpool = enc.maxpool\n        self.layer1 = enc.layer1\n        self.layer2 = enc.layer2\n        self.layer3 = enc.layer3\n        self.layer4 = enc.layer4\n\n        self.proj_in = nn.Conv2d(1024, embed_dim, kernel_size=1, bias=False)\n        encoder_layer = nn.TransformerEncoderLayer(\n            d_model=embed_dim,\n            nhead=nhead,\n            dim_feedforward=int(embed_dim * mlp_ratio),\n            dropout=drop,\n            activation=\"gelu\",\n            batch_first=True,\n            norm_first=True,\n        )\n        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=depth)\n        self.proj_out = nn.Conv2d(embed_dim, 512, kernel_size=1, bias=False)\n\n    def forward(self, x):\n        x0 = self.relu(self.bn1(self.conv1(x)))   # /2\n        x = self.maxpool(x0)                      # /4\n        s1 = self.layer1(x)                       # /4\n        s2 = self.layer2(s1)                      # /8\n        s3 = self.layer3(s2)                      # /16, 1024\n        _ = self.layer4(s3)                       # /32 (unused)\n\n        z = self.proj_in(s3)                      # (B,D,H/16,W/16)\n        B, D, Ht, Wt = z.shape\n        tokens = z.flatten(2).transpose(1, 2)     # (B,N,D)\n        tokens = self.transformer(tokens)\n        zt = tokens.transpose(1, 2).reshape(B, D, Ht, Wt)\n        zt = self.proj_out(zt)                    # (B,512,H/16,W/16)\n        return zt\n\nclass ConvGNAct(nn.Module):\n    def __init__(self, in_ch, out_ch, k=3, s=1, p=1, groups=8):\n        super().__init__()\n        g = min(groups, out_ch)\n        self.conv = nn.Conv2d(in_ch, out_ch, k, s, p, bias=False)\n        self.gn = nn.GroupNorm(g, out_ch)\n        self.act = nn.SiLU(inplace=True)\n    def forward(self, x):\n        return self.act(self.gn(self.conv(x)))\n\nclass UpBlock(nn.Module):\n    def __init__(self, in_ch, skip_ch, out_ch):\n        super().__init__()\n        self.conv1 = ConvGNAct(in_ch + skip_ch, out_ch)\n        self.conv2 = ConvGNAct(out_ch, out_ch)\n    def forward(self, x, skip):\n        x = F.interpolate(x, size=skip.shape[-2:], mode=\"bilinear\", align_corners=False)\n        x = torch.cat([x, skip], dim=1)\n        return self.conv2(self.conv1(x))\n\nclass HybridConvNeXtTransUNet(nn.Module):\n    \"\"\"English: Hybrid feature fusion then lightweight decoder -> mask logits.\"\"\"\n    def __init__(self, in_ch=3, out_ch=1):\n        super().__init__()\n        self.cnext = ConvNeXtEncoder(in_ch=in_ch)\n        self.tunet = TransUNetBackbone(\n            in_ch=in_ch,\n            embed_dim=TUNET_EMBED_DIM,\n            nhead=TUNET_NHEAD,\n            depth=TUNET_DEPTH,\n            mlp_ratio=TUNET_MLP_RATIO,\n            drop=0.0,\n        )\n        self.patch_embed = nn.Conv2d(in_ch, LSTM_EMBED_DIM, kernel_size=PATCH_SIZE, stride=PATCH_SIZE)\n        self.bilstm = nn.LSTM(\n            input_size=LSTM_EMBED_DIM,\n            hidden_size=LSTM_HIDDEN,\n            num_layers=1,\n            bidirectional=True,\n            batch_first=True,\n        )\n\n        self.fuse = ConvGNAct(384 + 512 + (2 * LSTM_HIDDEN), 512)\n        self.dec2 = UpBlock(in_ch=512, skip_ch=192, out_ch=256)\n        self.dec1 = UpBlock(in_ch=256, skip_ch=96,  out_ch=128)\n        self.dec0 = ConvGNAct(128, 64)\n        self.final = nn.Conv2d(64, out_ch, kernel_size=1)\n\n    def forward(self, x):\n        # x: float in [0,1]\n        H_in, W_in = x.shape[-2], x.shape[-1]\n        s1, s2, s3, _ = self.cnext(x)\n        zt = self.tunet(x)\n\n        p = self.patch_embed(x)                   # (B,C,Hp,Wp)\n        B, C, Hp, Wp = p.shape\n        seq = p.flatten(2).transpose(1, 2)        # (B,N,C)\n        seq, _ = self.bilstm(seq)\n        lstm_feat = seq.transpose(1, 2).reshape(B, 2 * LSTM_HIDDEN, Hp, Wp)\n        # align to /16 map\n        lstm_feat = F.interpolate(lstm_feat, size=s3.shape[-2:], mode=\"bilinear\", align_corners=False)\n\n        zf = self.fuse(torch.cat([s3, zt, lstm_feat], dim=1))  # /16\n        d2 = self.dec2(zf, s2)                                 # /8\n        d1 = self.dec1(d2, s1)                                 # /4\n        d0 = F.interpolate(d1, scale_factor=2, mode=\"bilinear\", align_corners=False)  # /2\n        d0 = self.dec0(d0)\n\n        logits_half = self.final(d0)                            # /2\n        logits = F.interpolate(logits_half, size=(H_in, W_in), mode=\"bilinear\", align_corners=False)\n        return logits\n\ndef build_hybrid():\n    return HybridConvNeXtTransUNet(in_ch=3, out_ch=1)\n\n@torch.inference_mode()\ndef predict_masks_for_rois(model, roi_g_list, roi_r_list):\n    \"\"\"English: Batch predict ROI masks (probability).\"\"\"\n    xs = []\n    for g, r in zip(roi_g_list, roi_r_list):\n        x3 = np.stack([g, g, r], axis=2).astype(np.uint8)\n        xs.append(torch.from_numpy(x3).permute(2,0,1))  # uint8\n    xb = torch.stack(xs, 0).to(DEVICE, non_blocking=True)\n    if USE_CHANNELS_LAST and xb.is_cuda:\n        xb = xb.to(memory_format=torch.channels_last)\n    xb = xb.float().div_(255.0)\n\n    with torch.cuda.amp.autocast(enabled=USE_AMP):\n        logits = model(xb)  # (B,1,H,W)\n    prob = torch.sigmoid(logits).squeeze(1).cpu().numpy().astype(np.float32)\n    return prob\n\n# ============================================================\n# 9) Training: pseudo cache + inverse LR\n# ============================================================\nclass DiceLoss(nn.Module):\n    def __init__(self, eps=1e-6):\n        super().__init__()\n        self.eps = eps\n    def forward(self, logits, targets):\n        probs = torch.sigmoid(logits)\n        probs = probs.view(probs.size(0), -1)\n        targets = targets.view(targets.size(0), -1)\n        inter = (probs * targets).sum(dim=1)\n        union = probs.sum(dim=1) + targets.sum(dim=1)\n        dice = (2*inter + self.eps) / (union + self.eps)\n        return 1.0 - dice.mean()\n\ndef inverse_lr(step: int) -> float:\n    lr = LR0 * (INV_T0 / (INV_T0 + float(step))) ** float(INV_POWER)\n    return float(max(LR_MIN, lr))\n\ndef set_optimizer_lr(optimizer, lr: float):\n    for pg in optimizer.param_groups:\n        pg[\"lr\"] = lr\n\ndef list_train_images(max_images: int):\n    imgs = sorted(TRAIN_DIR.glob(\"*.png\"))\n    if len(imgs) == 0:\n        imgs = sorted(TRAIN_DIR.rglob(\"*.png\"))\n    if max_images is not None and len(imgs) > max_images:\n        imgs = imgs[:max_images]\n    return imgs\n\ndef build_pseudo_cache(img_paths, geo_pipe, leads_per_image=4, seed=123, use_geo=True):\n    \"\"\"\n    English:\n    Build cached training samples once to avoid repeated heavy preprocessing.\n    X_u8: (N,H,W,3) uint8, channels=[gray, gray, red]\n    Y_u8: (N,H,W,1) uint8, mask in {0,1}\n    \"\"\"\n    rng = np.random.RandomState(seed)\n    X_list, Y_list = [], []\n\n    for p in img_paths:\n        _prep, best = get_prep_and_best_from_path(p, geo_pipe, use_geo=use_geo)\n        gray_wave = best[\"gray_wave\"]\n        red_wave  = best[\"red_wave\"]\n\n        chosen = rng.choice(LEADS, size=min(leads_per_image, len(LEADS)), replace=False)\n\n        for ld in chosen:\n            y0, y1, x0, x1 = best[\"rois_box\"][ld]\n            g_raw = gray_wave[y0:y1, x0:x1]\n            r_raw = red_wave[y0:y1, x0:x1]\n            if g_raw.size == 0 or r_raw.size == 0:\n                continue\n\n            g_lb, _ = letterbox_resize(g_raw, ROI_SIZE, border_value=255, is_mask=False)\n            r_lb, _ = letterbox_resize(r_raw, ROI_SIZE, border_value=0,   is_mask=False)\n\n            m_core = classical_mask_with_fallback(g_raw, r_raw).astype(np.uint8)\n            m_lb, _ = letterbox_resize(m_core, ROI_SIZE, border_value=0, is_mask=True)\n\n            x3 = np.stack([g_lb, g_lb, r_lb], axis=2).astype(np.uint8)\n            y1c = m_lb.astype(np.uint8)[..., None]  # (H,W,1)\n\n            X_list.append(x3)\n            Y_list.append(y1c)\n\n    if len(X_list) == 0:\n        raise RuntimeError(\"Pseudo cache is empty. Check preprocessing/ROI split.\")\n\n    X = np.stack(X_list, axis=0)\n    Y = np.stack(Y_list, axis=0)\n    return X, Y\n\nclass CachedRoiDataset(torch.utils.data.Dataset):\n    def __init__(self, X_u8: np.ndarray, Y_u8: np.ndarray):\n        self.X = X_u8\n        self.Y = Y_u8\n    def __len__(self):\n        return int(self.X.shape[0])\n    def __getitem__(self, idx):\n        x = torch.from_numpy(self.X[idx]).permute(2,0,1)      # uint8\n        y = torch.from_numpy(self.Y[idx]).permute(2,0,1).float()  # float 0/1\n        return x, y\n\ndef train_hybrid_with_inverse_lr(model, train_loader):\n    \"\"\"English: Fast training loop (uint8->float on GPU).\"\"\"\n    model.train()\n    opt = torch.optim.AdamW(model.parameters(), lr=LR0, weight_decay=WEIGHT_DECAY)\n    bce = nn.BCEWithLogitsLoss()\n    dice = DiceLoss()\n    scaler = torch.cuda.amp.GradScaler(enabled=USE_AMP)\n\n    step = 0\n    ema_loss = None\n    best_ema = float(\"inf\")\n    bad_steps = 0\n    best_state = None\n\n    for epoch in range(999999):\n        for xb_u8, yb in train_loader:\n            lr_now = inverse_lr(step)\n            set_optimizer_lr(opt, lr_now)\n\n            xb = xb_u8.to(DEVICE, non_blocking=True)\n            if USE_CHANNELS_LAST and xb.is_cuda:\n                xb = xb.to(memory_format=torch.channels_last)\n            xb = xb.float().div_(255.0)\n\n            yb = yb.to(DEVICE, non_blocking=True)\n\n            with torch.cuda.amp.autocast(enabled=USE_AMP):\n                logits = model(xb)\n                loss = bce(logits, yb) + dice(logits, yb)\n\n            opt.zero_grad(set_to_none=True)\n            scaler.scale(loss).backward()\n            scaler.unscale_(opt)\n            torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP_NORM)\n            scaler.step(opt)\n            scaler.update()\n\n            lv = float(loss.item())\n            ema_loss = lv if ema_loss is None else (1 - ES_EMA_ALPHA) * ema_loss + ES_EMA_ALPHA * lv\n\n            if step % 10 == 0:\n                print(f\"step={step}/{MAX_TRAIN_STEPS} | lr={lr_now:.6f} | loss={lv:.4f} | ema={ema_loss:.4f}\")\n\n            if EARLY_STOP_ON and step >= ES_WARMUP_STEPS:\n                improved = (ema_loss < (best_ema - ES_MIN_DELTA))\n                if improved:\n                    best_ema = ema_loss\n                    bad_steps = 0\n                    if ES_RESTORE_BEST:\n                        best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}\n                else:\n                    bad_steps += 1\n\n                if bad_steps >= ES_PATIENCE_STEPS:\n                    print(f\"Early stop at step={step} | best_ema={best_ema:.6f}\")\n                    if ES_RESTORE_BEST and best_state is not None:\n                        model.load_state_dict(best_state, strict=True)\n                    model.eval()\n                    return\n\n            step += 1\n            if step >= MAX_TRAIN_STEPS:\n                if ES_RESTORE_BEST and best_state is not None:\n                    model.load_state_dict(best_state, strict=True)\n                model.eval()\n                return\n\n# ============================================================\n# 10) Build signals for one image\n# ============================================================\ndef build_signals_for_one_image(geo_pipe, hybrid_model, img_path: Path, meta_map: dict):\n    # Geometry + ROI (cached if enabled)\n    prep, best = get_prep_and_best_from_path(img_path, geo_pipe, use_geo=True)\n\n    has_rhythm, rh_g_raw, rh_r_raw = detect_rhythm_strip(best)\n\n    # Grid-based calibration for pixels_per_mV\n    small_sq, grid_conf = estimate_small_square_with_confidence(prep)\n    GRID_STATS[\"total\"] += 1\n    if (grid_conf < GRID_CONF_THR) or (not np.isfinite(small_sq)):\n        GRID_STATS[\"fallback\"] += 1\n        pixels_per_mV = float(PIXELS_PER_MV_FALLBACK)\n    else:\n        clamped = float(np.clip(small_sq, SMALL_SQ_MIN, SMALL_SQ_MAX))\n        if abs(clamped - small_sq) > 1e-3:\n            GRID_STATS[\"clamped\"] += 1\n        pixels_per_mV = float(clamped) * 10.0\n\n    gray_wave = best[\"gray_wave\"]\n    red_wave  = best[\"red_wave\"]\n\n    roi_score_mean = float(best[\"score\"]) / float(len(LEADS))\n    roi_ink = template_ink_mean(gray_wave, best[\"rois_box\"])\n    roi_ok = (roi_score_mean >= ROI_MIN_SCORE) and (roi_ink >= ROI_MIN_INK)\n    use_hybrid = roi_ok and (hybrid_model is not None)\n\n    # Prepare ROI letterbox for hybrid prediction\n    roi_g_lb, roi_r_lb, metas = [], [], []\n    roi_g_core_list, roi_r_core_list = [], []\n\n    for ld in LEADS:\n        y0,y1,x0,x1 = best[\"rois_box\"][ld]\n        g_raw = gray_wave[y0:y1, x0:x1]\n        r_raw = red_wave[y0:y1, x0:x1]\n        g_lb, meta = letterbox_resize(g_raw, ROI_SIZE, border_value=255, is_mask=False)\n        r_lb, _    = letterbox_resize(r_raw, ROI_SIZE, border_value=0,   is_mask=False)\n        roi_g_lb.append(g_lb); roi_r_lb.append(r_lb); metas.append(meta)\n        roi_g_core_list.append(g_raw); roi_r_core_list.append(r_raw)\n\n    prob_masks = None\n    prob_r, rh_meta = None, None\n    if use_hybrid:\n        prob_masks = predict_masks_for_rois(hybrid_model, roi_g_lb, roi_r_lb)\n        if has_rhythm:\n            rh_g_lb, rh_meta = letterbox_resize(rh_g_raw, ROI_SIZE, border_value=255, is_mask=False)\n            rh_r_lb, _       = letterbox_resize(rh_r_raw, ROI_SIZE, border_value=0,   is_mask=False)\n            prob_r = predict_masks_for_rois(hybrid_model, [rh_g_lb], [rh_r_lb])[0]\n\n    base_id = str(img_path.stem.split(\"-\")[0])\n    out = {}\n\n    for i, ld in enumerate(LEADS):\n        meta = metas[i]\n        m_core = None\n        p_core = None\n\n        if (ld == \"II\") and has_rhythm:\n            g_raw = rh_g_raw\n            r_raw = rh_r_raw\n        else:\n            g_raw = roi_g_core_list[i]\n            r_raw = roi_r_core_list[i]\n\n        # Hybrid mask (if valid)\n        if prob_masks is not None:\n            p = prob_masks[i]\n            if (ld == \"II\") and (prob_r is not None):\n                p = prob_r\n                meta = rh_meta\n            if not prob_is_bad(p):\n                p_core = unpad_from_letterbox(p, meta).astype(np.float32)\n                m_lb = postprocess_mask(p)\n                m_core = unpad_from_letterbox(m_lb, meta).astype(np.uint8)\n                if m_core.size == 0 or mask_is_bad(m_core):\n                    m_core = None\n\n        # Classical fallback\n        if m_core is None:\n            m_core = classical_mask_with_fallback(g_raw, r_raw).astype(np.uint8)\n            if mask_is_bad(m_core):\n                m_core = None\n\n        if m_core is None or m_core.size == 0:\n            m_core = np.zeros_like(g_raw, dtype=np.uint8) if (g_raw is not None and g_raw.size > 0) else np.zeros((10,10), np.uint8)\n\n        # Trace gray: suppress grid strongly before tracing\n        trace_gray = suppress_red_grid(g_raw, r_raw, factor=GRID_RED_FACTOR)\n        y = trace_ecg_path(m_core, p_core, trace_gray)\n        y = median_smooth_1d(y, k=TRACE_MEDIAN_WIN)\n\n        # Convert to mV and post-filter\n        mv = y_to_mV(y, pixels_per_mV=pixels_per_mV)\n\n        fs, nrows = meta_map[(base_id, ld)]\n        sig = resample_1d(mv, nrows)\n        sig = apply_ecg_filters(sig, fs)\n\n        # Soft baseline removal with guard\n        if float(np.std(sig)) > BASELINE_GUARD_STD:\n            sig = remove_baseline_soft(sig, fs=fs, win_sec=BASELINE_WIN_SEC)\n\n        out[ld] = sig.astype(np.float32)\n\n    return base_id, out\n\n# ============================================================\n# 11) Submission builder\n# ============================================================\ndef build_submission_template(test_df: pd.DataFrame) -> pd.DataFrame:\n    rep = test_df[\"number_of_rows\"].to_numpy()\n    exp = test_df.loc[test_df.index.repeat(rep)].copy()\n    exp[\"row_id\"] = exp.groupby([\"id\",\"lead\"]).cumcount().astype(np.int32)\n    exp = exp.rename(columns={\"id\":\"base_id\"})\n    exp[\"base_id\"] = exp[\"base_id\"].astype(str)\n    exp[\"id\"] = exp[\"base_id\"].astype(str) + \"_\" + exp[\"row_id\"].astype(str) + \"_\" + exp[\"lead\"].astype(str)\n    exp[\"value\"] = 0.0\n    return exp[[\"id\",\"base_id\",\"lead\",\"row_id\",\"number_of_rows\",\"fs\",\"value\"]]\n\ndef build_index_map(sub: pd.DataFrame) -> dict:\n    tmp = sub.dropna(subset=[\"base_id\",\"lead\",\"row_id\"]).copy()\n    tmp[\"base_id\"] = tmp[\"base_id\"].astype(str)\n    tmp = tmp.sort_values([\"base_id\",\"lead\",\"row_id\"])\n    return tmp.groupby([\"base_id\",\"lead\"])[\"__idx__\"].apply(list).to_dict()\n\n# ============================================================\n# 12) MAIN\n# ============================================================\ndef main(run_mode: str = \"all\"):\n    print(\"DEVICE:\", DEVICE)\n    mode = str(run_mode).strip().lower()\n    if mode not in {\"all\", \"train\", \"infer\", \"cache\"}:\n        raise RuntimeError(f\"Invalid RUN_MODE={mode}. Use: all | train | infer | cache\")\n    print(\"RUN_MODE:\", mode)\n\n    # ---- Geometry pipeline (no external weights) ----\n    geo = GeoPipelineIdentity(use_deskew=GEO_USE_DESKEW)\n    print(\"Using geometry fallback (no external weights)\")\n\n    # ---- Load test metadata when needed ----\n    test_df = None\n    sample_df = None\n    meta_map = None\n    sub = None\n    idx_map = None\n    base_ids = []\n    test_path_map = {}\n\n    if mode in {\"infer\", \"all\", \"cache\"}:\n        test_df = pd.read_csv(TEST_CSV)\n        test_df[\"id\"] = test_df[\"id\"].astype(str)\n        base_ids = test_df[\"id\"].unique().tolist()\n\n        if mode in {\"infer\", \"all\"}:\n            sample_df = pd.read_parquet(SAMPLE_SUB)[[\"id\"]].copy()\n            sample_df[\"id\"] = sample_df[\"id\"].astype(str)\n            meta_map = {(str(r.id), str(r.lead)): (float(r.fs), int(r.number_of_rows))\n                        for r in test_df.itertuples(index=False)}\n            exp = build_submission_template(test_df)\n            sub = sample_df.merge(exp, on=\"id\", how=\"left\")\n\n            miss = int(sub[\"base_id\"].isna().sum())\n            if miss > 0:\n                raise RuntimeError(f\"sample_submission IDs do not match expanded template. Missing rows: {miss}\")\n\n            sub[\"__idx__\"] = np.arange(len(sub), dtype=np.int32)\n            sub[\"value\"] = sub[\"value\"].astype(np.float32)\n            idx_map = build_index_map(sub)\n\n    if mode == \"cache\":\n        train_paths = list_train_images(TRAIN_MAX_IMAGES)\n        precache_roi_for_paths(train_paths, geo, use_geo=True, workers=PRECACHE_WORKERS)\n        test_paths = []\n        for bid in base_ids:\n            p = resolve_test_image_path(str(bid))\n            if p is None:\n                raise FileNotFoundError(f\"Test image not found for id={bid}. Check naming in TEST_DIR.\")\n            test_paths.append(p)\n            test_path_map[str(bid)] = p\n        precache_roi_for_paths(test_paths, geo, use_geo=True, workers=PRECACHE_WORKERS)\n        return\n\n    # ---- Train Hybrid Stage2 (in-script) ----\n    hybrid = None\n    hybrid_loaded = False\n\n    if TRAIN_HYBRID and mode in {\"train\", \"all\", \"infer\"}:\n        hybrid = build_hybrid().to(DEVICE)\n        if USE_CHANNELS_LAST and DEVICE.startswith(\"cuda\"):\n            hybrid = hybrid.to(memory_format=torch.channels_last)\n\n    if TRAIN_HYBRID and mode in {\"train\", \"all\"}:\n        if AUTO_CKPT_PATH.exists():\n            print(\"Found cached ckpt in working dir, loading:\", AUTO_CKPT_PATH)\n            sd = torch.load(str(AUTO_CKPT_PATH), map_location=\"cpu\")\n            hybrid.load_state_dict(sd, strict=False)\n            hybrid.eval()\n            hybrid_loaded = True\n        else:\n            img_paths = list_train_images(TRAIN_MAX_IMAGES)\n            if len(img_paths) < 5:\n                raise RuntimeError(\"Train images not found. Check TRAIN_DIR or dataset mount.\")\n            if PRECACHE_ROI:\n                precache_roi_for_paths(img_paths, geo, use_geo=True, workers=PRECACHE_WORKERS)\n\n            print(\"Building pseudo cache (fast) ...\")\n            X_u8, Y_u8 = build_pseudo_cache(\n                img_paths,\n                geo_pipe=geo,\n                leads_per_image=LEADS_PER_IMAGE_CACHE,\n                seed=123,\n                use_geo=True,\n            )\n            ds = CachedRoiDataset(X_u8, Y_u8)\n            loader = torch.utils.data.DataLoader(\n                ds,\n                batch_size=BATCH_SIZE,\n                shuffle=True,\n                num_workers=NUM_WORKERS,\n                pin_memory=DEVICE.startswith(\"cuda\"),\n                drop_last=True,\n                persistent_workers=(NUM_WORKERS > 0),\n            )\n            print(\"Training Hybrid with pseudo-labels (cached) ...\")\n            train_hybrid_with_inverse_lr(hybrid, loader)\n            hybrid_loaded = True\n\n            if SAVE_TRAINED_CKPT:\n                torch.save(hybrid.state_dict(), str(AUTO_CKPT_PATH))\n                print(\"Saved trained ckpt:\", AUTO_CKPT_PATH)\n\n    if mode == \"train\":\n        return\n\n    # ---- Inference ----\n    if TRAIN_HYBRID:\n        if hybrid is None:\n            hybrid = build_hybrid().to(DEVICE)\n            if USE_CHANNELS_LAST and DEVICE.startswith(\"cuda\"):\n                hybrid = hybrid.to(memory_format=torch.channels_last)\n        if not hybrid_loaded:\n            if AUTO_CKPT_PATH.exists():\n                print(\"Loading ckpt for inference:\", AUTO_CKPT_PATH)\n                sd = torch.load(str(AUTO_CKPT_PATH), map_location=\"cpu\")\n                hybrid.load_state_dict(sd, strict=False)\n                hybrid_loaded = True\n            else:\n                raise RuntimeError(\"No checkpoint found. Run with RUN_MODE=train to create one.\")\n        hybrid.eval()\n    else:\n        hybrid = None\n\n    if PRECACHE_ROI and base_ids:\n        test_paths = []\n        for bid in base_ids:\n            p = resolve_test_image_path(str(bid))\n            if p is None:\n                raise FileNotFoundError(f\"Test image not found for id={bid}. Check naming in TEST_DIR.\")\n            test_paths.append(p)\n            test_path_map[str(bid)] = p\n        precache_roi_for_paths(test_paths, geo, use_geo=True, workers=PRECACHE_WORKERS)\n\n    print(\"Unique test base_ids:\", len(base_ids))\n\n    start = time.time()\n    for i, bid in enumerate(base_ids, 1):\n        img_path = test_path_map.get(str(bid)) or resolve_test_image_path(str(bid))\n        if img_path is None:\n            raise FileNotFoundError(f\"Test image not found for id={bid}. Check naming in TEST_DIR.\")\n\n        base_id_norm, lead_to_sig = build_signals_for_one_image(geo, hybrid, img_path, meta_map)\n\n        for ld in LEADS:\n            indices = idx_map.get((base_id_norm, ld), None)\n            if indices is None:\n                raise KeyError(f\"Index map missing (base_id={base_id_norm}, lead={ld})\")\n            vals = lead_to_sig[ld]\n            if len(vals) != len(indices):\n                raise RuntimeError(f\"Length mismatch for {base_id_norm}-{ld}: pred={len(vals)} vs rows={len(indices)}\")\n            sub.loc[indices, \"value\"] = vals.astype(np.float32)\n\n        if i % 20 == 0:\n            elapsed = time.time() - start\n            avg = elapsed / i\n            print(f\"Infer {i}/{len(base_ids)} | elapsed={elapsed:.1f}s | avg={avg:.3f}s/img\")\n\n    out = sub[[\"id\",\"value\"]].copy()\n    out[\"id\"] = out[\"id\"].astype(str)\n    out[\"value\"] = out[\"value\"].astype(np.float32)\n\n    out.to_parquet(OUT_PQ, index=False)\n    out.to_csv(OUT_CSV, index=False)\n\n    print(\"Saved:\", OUT_PQ)\n    print(\"Saved:\", OUT_CSV)\n\n    vmin, vmax, vmean = float(out[\"value\"].min()), float(out[\"value\"].max()), float(out[\"value\"].mean())\n    nunq = int(out[\"value\"].nunique())\n    print(\"value stats:\", vmin, vmax, vmean, \"| unique values:\", nunq)\n    if nunq <= 1:\n        raise RuntimeError(\"Sanity check failed: value is constant (likely all zeros).\")\n\n    if GRID_STATS[\"total\"] > 0:\n        print(f\"grid_stats: total={GRID_STATS['total']} fallback={GRID_STATS['fallback']} clamped={GRID_STATS['clamped']}\")\n\n# Run\nif __name__ == \"__main__\":\n    main(RUN_MODE)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-22T04:46:02.753628Z","iopub.execute_input":"2026-01-22T04:46:02.756233Z","iopub.status.idle":"2026-01-22T04:47:54.367516Z","shell.execute_reply.started":"2026-01-22T04:46:02.756165Z","shell.execute_reply":"2026-01-22T04:47:54.366314Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}