{"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":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Test output with Sign Signale","metadata":{}},{"cell_type":"markdown","source":"ECG_Digitization_v4_Processing Pipeline (Centerline → Waveform)¶\nThis notebook focuses on post-processing improvements for the v4 hybrid centerline extraction method.\n\nThe aim is to stabilize the waveform and improve submission score in the PhysioNet ECG digitization challenge.\n\nOverview\nThis notebook generates a submission CSV for the PhysioNet ECG Image Digitization challenge.\n\nKey idea:\n\nExtract ECG centerline from grid-removed images\nNormalize vertical position (pixel-based) into a stable waveform representation\nFocus on robust shape preservation rather than absolute mV calibration\nSample Output (v4: hybrid + smoothing)\nBelow is an example of the extracted ECG waveform from a single input image after centerline extraction, hybrid post-processing, and smoothing.\n\nInput: PhysioNet ECG image\nOutput: 1D waveform (normalized amplitude)\n1. Imports & Environment","metadata":{}},{"cell_type":"code","source":"import os, re, math, time, warnings\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\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\"\n\ntry:\n    cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_ERROR)\nexcept Exception:\n    pass\n\n# Optional skeletonize\ntry:\n    from skimage.morphology import skeletonize as sk_skeletonize\nexcept Exception:\n    sk_skeletonize = None\n\n# TorchVision for ResNet50 backbone\nfrom torchvision.models import resnet50\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\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nUSE_AMP = (DEVICE == \"cuda\")\n\n# Step 2 preprocess\nRESIZE_SCALE = 0.5\nGAUSS_K = 5\n\n# Model input size\nROI_SIZE = (256, 768)   # (H,W) letterbox (no warp)\n\n# ---- TransUNet checkpoint (put your .pth in a Kaggle Dataset path) ----\n# Example: TRANSUNET_CKPT = Path(\"/kaggle/input/my-transunet-ckpt/transunet_r50.pth\")\nTRANSUNET_CKPT = None\n\n# Step 5 postprocess\nMASK_THR = 0.20\nMIN_CC_FRAC = 0.00010\nUSE_SKELETON = True\n\n# -----------------------------\n# Training config\n# -----------------------------\nLR0 = 0.4          # base LR\nINV_T0 = 100.0     # controls decay speed\nINV_POWER = 1.0    # 1.0 = harmonic-ish decay\nLR_MIN = 1e-6      # floor\n\ndef inverse_lr(step: int) -> float:\n    # lr(step) = LR0 * (t0 / (t0 + step))^power\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\nWEIGHT_DECAY = 1e-4\n\nEARLY_STOP_ON = True\nES_WARMUP_STEPS = 20\nES_PATIENCE_STEPS = 200\nES_MIN_DELTA = 1e-4\nES_EMA_ALPHA = 0.10\nES_RESTORE_BEST = True\n\nMAX_TRAIN_STEPS = 100       # ปรับได้\nBATCH_SIZE = 16             # ถ้า GPU น้อย ลดเหลือ 16\nNUM_WORKERS = 0              # CPU-safe\nGRAD_CLIP_NORM = 1.0\n\nTRAIN_IF_NO_CKPT = True\nTRAIN_MAX_IMAGES = 800       # จำกัดจำนวนภาพเทรน (กันช้า) ปรับได้\nVAL_FRAC = 0.10\n\n# จะบันทึก ckpt ที่เทรนได้\nAUTO_CKPT_PATH = Path(\"/kaggle/working/transunet_r50_pseudo.pth\")\n\n\n# Trace continuity constraint (IMPORTANT for SNR)\nTRACE_MAX_JUMP_PX = 6\nTRACE_MEDIAN_WIN = 5\n\n# Baseline removal (gentler than heavy highpass)\nBASELINE_WIN_SEC = 1.0\nBASELINE_GUARD_STD = 1e-4\n\nLEADS = [\"I\",\"II\",\"III\",\"aVR\",\"aVL\",\"aVF\",\"V1\",\"V2\",\"V3\",\"V4\",\"V5\",\"V6\"]\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# ============================================================\n# Utils\n# ============================================================\ndef read_image_bgr(path: Path) -> np.ndarray:\n    img = cv2.imdecode(np.fromfile(str(path), dtype=np.uint8), cv2.IMREAD_COLOR)\n    if img is None:\n        raise FileNotFoundError(f\"Cannot read image: {path}\")\n    return img\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 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\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 list_train_images(max_images: int):\n    # train images มักเป็น .png\n    imgs = sorted(TRAIN_DIR.glob(\"*.png\"))\n    if len(imgs) == 0:\n        # เผื่อชื่อมี pattern อื่น\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\nclass PseudoRoiDataset(torch.utils.data.Dataset):\n    \"\"\"\n    สร้าง sample เป็น ROI ของ lead (สุ่มจากภาพ train)\n    target mask มาจาก classical_mask_with_fallback (pseudo-label)\n    \"\"\"\n    def __init__(self, img_paths, n_samples=5000, seed=123):\n        self.img_paths = list(img_paths)\n        self.n_samples = int(n_samples)\n        self.rng = np.random.RandomState(seed)\n\n    def __len__(self):\n        return self.n_samples\n\n    def __getitem__(self, idx):\n        # สุ่มรูป + สุ่ม lead\n        img_path = self.img_paths[self.rng.randint(0, len(self.img_paths))]\n        ld = LEADS[self.rng.randint(0, len(LEADS))]\n\n        img = read_image_bgr(img_path)\n        prep = preprocess_image(img)\n        best = pick_best_template(prep)\n\n        gray_wave = best[\"gray_wave\"]\n        red_wave  = best[\"red_wave\"]\n        y0,y1,x0,x1 = best[\"rois_box\"][ld]\n\n        g_raw = gray_wave[y0:y1, x0:x1]\n        r_raw = red_wave[y0:y1, x0:x1]\n\n        # input letterbox\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        x3 = np.stack([g_lb, g_lb, r_lb], axis=2).astype(np.float32) / 255.0\n        x = torch.from_numpy(x3).permute(2,0,1)  # (3,H,W)\n\n        # pseudo target from classical on raw (แล้วค่อย letterbox เป็น mask)\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        y = torch.from_numpy(m_lb.astype(np.float32)).unsqueeze(0)  # (1,H,W)\n\n        return x, y\n\ndef train_transunet_with_inverse_lr(model, train_loader):\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    while step < MAX_TRAIN_STEPS:\n        for xb, yb in train_loader:\n            lr_now = inverse_lr(step)                  # <-- inverse schedule\n            set_optimizer_lr(opt, lr_now)\n\n            xb = xb.to(DEVICE, non_blocking=True)\n            yb = yb.to(DEVICE, non_blocking=True)\n\n            with torch.cuda.amp.autocast(enabled=USE_AMP):\n                logits = model(xb)                     # (B,1,H,W)\n                loss = bce(logits, yb) + dice(logits, yb)\n\n            opt.zero_grad(set_to_none=True)\n            scaler.scale(loss).backward()\n\n            # grad clip (กัน LR0=0.4 ระเบิด)\n            scaler.unscale_(opt)\n            torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP_NORM)\n\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            step += 1\n            if step % 50 == 0:\n                print(f\"step={step}/{MAX_TRAIN_STEPS} | lr={lr_now:.6f} | loss={lv:.4f} | ema={ema_loss:.4f}\")\n\n            # ---- early stopping ----\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} | last_ema={ema_loss:.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            if step >= MAX_TRAIN_STEPS:\n                break\n\n    if ES_RESTORE_BEST and best_state is not None:\n        model.load_state_dict(best_state, strict=True)\n    model.eval()\n\n# ============================================================\n# Step 2) Preprocess\n# ============================================================\ndef estimate_skew_angle_hough(gray: np.ndarray) -> float:\n    blur = cv2.GaussianBlur(gray, (5,5), 0)\n    edges = cv2.Canny(blur, 50, 150, apertureSize=3)\n    lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=120,\n                            minLineLength=max(60, gray.shape[1]//8),\n                            maxLineGap=10)\n    if lines is None:\n        return 0.0\n    angles = []\n    for x1,y1,x2,y2 in lines[:,0]:\n        dx = x2-x1; 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\ndef background_normalize(gray: np.ndarray) -> np.ndarray:\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(img_bgr: np.ndarray) -> dict:\n    gray0 = to_gray(img_bgr)\n    ang = estimate_skew_angle_hough(gray0)\n    if abs(ang) >= 0.2:\n        img_bgr = rotate_keep(img_bgr, -ang, border=255)\n\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\n    gray = to_gray(img_bgr)\n    gray = background_normalize(gray)\n\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\n    red = redness_map(img_bgr)\n    return dict(img_bgr=img_bgr, gray=gray, red=red)\n\n# ============================================================\n# Step 7) grid spacing estimation\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 estimate_small_square_px(prep: dict) -> 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    if not np.isfinite(small) or small < 3.0 or small > 18.0:\n        small = 5.0\n    return float(small)\n\n# ============================================================\n# Step 3) 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 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\n# ============================================================\n# Letterbox resize\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# Step 4) TransUNet (ResNet50 backbone)\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        x = self.conv2(self.conv1(x))\n        return x\n\nclass TransUNetR50(nn.Module):\n    \"\"\"\n    Hybrid TransUNet:\n      - ResNet50 encoder (skip: layer1, layer2, layer3)\n      - Transformer on feature map at 1/16 (layer3 output)\n      - UNet-like decoder -> logits (B,1,H,W)\n    \"\"\"\n    def __init__(self, in_ch=3, out_ch=1, embed_dim=512, nhead=8, depth=4, mlp_ratio=4.0, drop=0.0):\n        super().__init__()\n\n        # ---- ResNet50 encoder ----\n        enc = resnet50(weights=None)\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   # C=256, 1/4\n        self.layer2 = enc.layer2   # C=512, 1/8\n        self.layer3 = enc.layer3   # C=1024, 1/16\n        self.layer4 = enc.layer4   # C=2048, 1/32 (ไม่ใช้เป็น skip ในเวอร์ชันนี้)\n\n        # ---- Transformer on layer3 feature (B,1024,H/16,W/16) ----\n        self.proj_in = nn.Conv2d(1024, embed_dim, kernel_size=1, bias=False)\n\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\n        self.proj_out = nn.Conv2d(embed_dim, 512, kernel_size=1, bias=False)\n\n        # ---- Decoder (ใช้ skip จาก layer2, layer1, และ feature ต้น ๆ) ----\n        self.skip2_reduce = nn.Conv2d(512, 256, 1, bias=False)   # layer2\n        self.skip1_reduce = nn.Conv2d(256, 128, 1, bias=False)   # layer1\n\n        self.dec2 = UpBlock(in_ch=512, skip_ch=256, out_ch=256)  # 1/16 -> 1/8\n        self.dec1 = UpBlock(in_ch=256, skip_ch=128, out_ch=128)  # 1/8  -> 1/4\n\n        # เพิ่มขั้นคืนละเอียดถึง full-res โดยใช้ stem feature (หลัง conv1 ก่อน maxpool) เป็น skip\n        self.stem_reduce = nn.Conv2d(64, 64, 1, bias=False)\n        self.dec0 = UpBlock(in_ch=128, skip_ch=64, out_ch=64)    # 1/4 -> 1/2\n\n        self.final_up = nn.Sequential(\n            ConvGNAct(64, 64),\n            nn.Conv2d(64, out_ch, kernel_size=1)\n        )\n\n    def forward(self, x):\n        H_in, W_in = x.shape[-2], x.shape[-1]  # <-- เก็บขนาด input\n\n        x0 = self.relu(self.bn1(self.conv1(x)))   # (B,64,H/2,W/2)\n        x = self.maxpool(x0)                      # (B,64,H/4,W/4)\n\n        s1 = self.layer1(x)                       # (B,256,H/4,W/4)\n        s2 = self.layer2(s1)                      # (B,512,H/8,W/8)\n        s3 = self.layer3(s2)                      # (B,1024,H/16,W/16)\n        _  = self.layer4(s3)                      # 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\n        s2r = self.skip2_reduce(s2)               # (B,256,H/8,W/8)\n        s1r = self.skip1_reduce(s1)               # (B,128,H/4,W/4)\n        x0r = self.stem_reduce(x0)                # (B,64, H/2,W/2)\n\n        d2 = self.dec2(zt, s2r)                   # (B,256,H/8,W/8)\n        d1 = self.dec1(d2, s1r)                   # (B,128,H/4,W/4)\n        d0 = self.dec0(d1, x0r)                   # (B,64, H/2,W/2)\n\n        logits_half = self.final_up(d0)           # (B,1,H/2,W/2)\n        logits = F.interpolate(logits_half, size=(H_in, W_in), mode=\"bilinear\", align_corners=False)\n        return logits\n\ndef build_transunet():\n    # ค่าเริ่มต้นที่ไม่หนักเกินสำหรับ ROI_SIZE=256x768\n    return TransUNetR50(\n        in_ch=3,\n        out_ch=1,\n        embed_dim=512,\n        nhead=8,\n        depth=4,\n        mlp_ratio=4.0,\n        drop=0.0\n    )\n\n# ============================================================\n# Classical extraction + trace + resample (same as your logic)\n# ============================================================\ndef suppress_red_grid(gray_roi: np.ndarray, red_roi: np.ndarray, factor: float = 2.0) -> np.ndarray:\n    g = gray_roi.astype(np.int16)\n    r = red_roi.astype(np.int16)\n    out = np.clip(g - (r * factor), 0, 255).astype(np.uint8)\n    return out\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 = 2.0) -> np.ndarray:\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 // 40)\n    vlen = max(12, h // 40)\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    no_grid = cv2.bitwise_and(thr, cv2.bitwise_not(grid))\n\n    mask = (no_grid > 0).astype(np.uint8)\n\n    cut = int(mask.shape[1] * 0.03)\n    if cut > 5:\n        mask[:, :cut] = 0\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    m1 = remove_grid_morph_to_mask(g_raw, r_raw, adapt_blocksize=31, adapt_C=10, red_factor=2.0)\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=1.5)\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 postprocess_mask(prob: np.ndarray) -> np.ndarray:\n    m = (prob >= MASK_THR).astype(np.uint8)\n    k1 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3))\n    k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5))\n    m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k1, iterations=1)\n    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k2, iterations=1)\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        m = sk_skeletonize(m.astype(bool)).astype(np.uint8)\n    return m\n\ndef trace_choose_nearest(mask: np.ndarray, max_jump_px: int = TRACE_MAX_JUMP_PX) -> np.ndarray:\n    h, w = mask.shape\n    ys = np.full(w, np.nan, dtype=np.float32)\n    prev = None\n    for x in range(w):\n        y_idx = np.where(mask[:, x] > 0)[0]\n        if len(y_idx) == 0:\n            continue\n        if prev is None:\n            ys[x] = float(np.median(y_idx)); prev = ys[x]; continue\n        cand = float(y_idx[np.argmin(np.abs(y_idx - prev))])\n        if abs(cand - prev) > max_jump_px:\n            continue\n        ys[x] = cand\n        prev = cand\n    return ys\n\ndef fill_nans_linear(y: np.ndarray) -> np.ndarray:\n    x = np.arange(len(y))\n    good = np.isfinite(y)\n    if good.sum() < 2:\n        med = np.nanmedian(y)\n        if not np.isfinite(med):\n            med = 0.0\n        return np.full_like(y, float(med), dtype=np.float32)\n    y2 = y.copy()\n    y2[~good] = np.interp(x[~good], x[good], y[good])\n    return y2.astype(np.float32)\n\ndef median_smooth_1d(sig: np.ndarray, k: int = 5) -> np.ndarray:\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 smooth_gaussian_1d(sig: np.ndarray, k=5) -> np.ndarray:\n    if k < 3:\n        return sig.astype(np.float32)\n    if k % 2 == 0:\n        k += 1\n    return cv2.GaussianBlur(sig.reshape(1,-1), (1,k), 0).ravel().astype(np.float32)\n\ndef remove_baseline_soft(sig: np.ndarray, fs: float, win_sec: float = BASELINE_WIN_SEC) -> np.ndarray:\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\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\n# ============================================================\n# Model inference for masks (TransUNet)\n# ============================================================\n@torch.inference_mode()\ndef predict_masks_for_rois(model, roi_g_list, roi_r_list):\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.float32) / 255.0\n        xs.append(torch.from_numpy(x3).permute(2,0,1))\n    xb = torch.stack(xs, 0).to(DEVICE)\n\n    logits = model(xb)  # (B,1,H,W) ideally H,W=ROI_SIZE\n    logits = F.interpolate(logits, size=ROI_SIZE, mode=\"bilinear\", align_corners=False)\n    prob = torch.sigmoid(logits).squeeze(1).cpu().numpy().astype(np.float32)\n    return prob\n\n# ============================================================\n# Build signals for one image (debug removed)\n# ============================================================\ndef build_signals_for_one_image(model_or_none, img_path: Path, meta_map: dict):\n    img = read_image_bgr(img_path)\n    prep = preprocess_image(img)\n    best = pick_best_template(prep)\n\n    has_rhythm, rh_g_raw, rh_r_raw = detect_rhythm_strip(best)\n    small_sq = estimate_small_square_px(prep)\n    pixels_per_mV = float(small_sq) * 10.0\n\n    gray_wave = best[\"gray_wave\"]\n    red_wave  = best[\"red_wave\"]\n\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 model_or_none is not None:\n        prob_masks = predict_masks_for_rois(model_or_none, 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(model_or_none, [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        use_classical = (model_or_none is None)\n        m_core = None\n\n        if model_or_none 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\n            # quality gate แบบเดิม\n            if float(np.max(p)) < 0.30 or float(np.mean(p)) < 0.02:\n                use_classical = True\n            else:\n                m_lb = postprocess_mask(p)\n                m_core = unpad_from_letterbox(m_lb, meta)\n                if m_core.size == 0 or int(m_core.sum()) == 0:\n                    use_classical = True\n\n        if use_classical:\n            g_raw = roi_g_core_list[i]\n            r_raw = roi_r_core_list[i]\n            m_core = classical_mask_with_fallback(g_raw, r_raw)\n\n        m_core = m_core.astype(np.uint8)\n        y = trace_choose_nearest(m_core, max_jump_px=TRACE_MAX_JUMP_PX)\n        y = fill_nans_linear(y)\n        y = median_smooth_1d(y, k=TRACE_MEDIAN_WIN)\n\n        mv = y_to_mV(y, pixels_per_mV=pixels_per_mV)\n        mv = smooth_gaussian_1d(mv, k=5)\n\n        fs, nrows = meta_map[(base_id, ld)]\n        sig = resample_1d(mv, nrows)\n\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# 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# MAIN (Inference)\n# ============================================================\ndef main():\n    print(\"DEVICE:\", DEVICE)\n    print(\"ROI_SIZE:\", ROI_SIZE)\n\n    test_df = pd.read_csv(TEST_CSV)\n    sample_df = pd.read_parquet(SAMPLE_SUB)[[\"id\"]].copy()\n    sample_df[\"id\"] = sample_df[\"id\"].astype(str)\n\n    test_df[\"id\"] = test_df[\"id\"].astype(str)\n    meta_map = {(str(r.id), str(r.lead)): (float(r.fs), int(r.number_of_rows)) for r in test_df.itertuples(index=False)}\n\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    # ---- Load / Train TransUNet ----\n    model = None\n    use_model = (TRANSUNET_CKPT is not None) and Path(TRANSUNET_CKPT).exists()\n\n    if use_model:\n        print(\"Using TransUNet-ResNet50 ckpt:\", TRANSUNET_CKPT)\n        model = build_transunet().to(DEVICE)\n        ckpt = torch.load(str(TRANSUNET_CKPT), map_location=\"cpu\")\n        sd = ckpt.get(\"state_dict\", ckpt) if isinstance(ckpt, dict) else ckpt\n        if isinstance(sd, dict) and any(k.startswith(\"module.\") for k in sd.keys()):\n            sd = {k.replace(\"module.\", \"\", 1): v for k, v in sd.items()}\n        model.load_state_dict(sd, strict=False)\n        model.eval()\n\n    elif TRAIN_IF_NO_CKPT:\n        print(\"No ckpt -> training TransUNet with pseudo-labels (classical masks) ...\")\n        model = build_transunet().to(DEVICE)\n\n        img_paths = list_train_images(TRAIN_MAX_IMAGES)\n        if len(img_paths) < 5:\n            print(\"Train images not found -> fallback to classical only.\")\n            model = None\n        else:\n            ds = PseudoRoiDataset(img_paths, n_samples=MAX_TRAIN_STEPS * BATCH_SIZE, seed=123)\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 == \"cuda\"),\n                drop_last=True,\n            )\n\n            train_transunet_with_inverse_lr(model, loader)\n\n            # save ckpt\n            torch.save(model.state_dict(), str(AUTO_CKPT_PATH))\n            print(\"Saved trained ckpt:\", AUTO_CKPT_PATH)\n\n    else:\n        print(\"No TransUNet ckpt found -> using classical extraction only.\")\n        model = None\n\n    # ---- Inference ----\n    base_ids = test_df[\"id\"].unique().tolist()\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 = 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 pattern in TEST_DIR.\")\n\n        base_id_norm, lead_to_sig = build_signals_for_one_image(model, 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            est = avg * len(base_ids)\n            print(f\"Infer {i}/{len(base_ids)} | elapsed={elapsed:.1f}s | est_total={est/60:.1f} min\")\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# NOTE: run inference\nmain()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T08:47:15.983082Z","iopub.execute_input":"2026-01-12T08:47:15.983368Z","iopub.status.idle":"2026-01-12T08:48:56.370506Z","shell.execute_reply.started":"2026-01-12T08:47:15.983339Z","shell.execute_reply":"2026-01-12T08:48:56.368939Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, re, math, time, warnings\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\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\"\n\ntry:\n    cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_ERROR)\nexcept Exception:\n    pass\n\n# Optional skeletonize\ntry:\n    from skimage.morphology import skeletonize as sk_skeletonize\nexcept Exception:\n    sk_skeletonize = None\n\n# TorchVision for ResNet50 backbone\nfrom torchvision.models import resnet50\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\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nUSE_AMP = (DEVICE == \"cuda\")\n\n# Step 2 preprocess\nRESIZE_SCALE = 0.5\nGAUSS_K = 5\n\n# Model input size\nROI_SIZE = (256, 768)   # (H,W) letterbox (no warp)\n\n# ---- TransUNet checkpoint (put your .pth in a Kaggle Dataset path) ----\n# Example: TRANSUNET_CKPT = Path(\"/kaggle/input/my-transunet-ckpt/transunet_r50.pth\")\nTRANSUNET_CKPT = None\n\n# Step 5 postprocess\nMASK_THR = 0.20\nMIN_CC_FRAC = 0.00010\nUSE_SKELETON = True\n\n# -----------------------------\n# Training config\n# -----------------------------\nLR0 = 0.4          # base LR\nINV_T0 = 100.0     # controls decay speed\nINV_POWER = 1.0    # 1.0 = harmonic-ish decay\nLR_MIN = 1e-6      # floor\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T08:48:56.371087Z","iopub.status.idle":"2026-01-12T08:48:56.371605Z","shell.execute_reply.started":"2026-01-12T08:48:56.371363Z","shell.execute_reply":"2026-01-12T08:48:56.371396Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\n\n# =========================\n# Load test.csv\n# =========================\ntest_csv_path = \"/kaggle/input/physionet-ecg-image-digitization/test.csv\"\ntest_df = pd.read_csv(test_csv_path)\n\nrows = []\n\nfor _, row in test_df.iterrows():\n    record_id = row[\"id\"]\n    lead = row[\"lead\"]\n    n = int(row[\"number_of_rows\"])#(row[\"number_of_samples\"])\n    \n    # ---- 超重要 ----\n    # Fill signal with sign wave\n    x = np.linspace(0, 2*np.pi, n)\n    signal = 0.05 * np.sin(x)\n\n    \n    for i, v in enumerate(signal):\n        rows.append({\n            \"id\": f\"{record_id}_{i}_{lead}\",\n            \"value\": float(v)\n        })\n\nsubmission_df = pd.DataFrame(rows)\n\n# =========================\n# Save submission\n# =========================\nsubmission_path = \"submission.csv\"\nsubmission_df.to_csv(submission_path, index=False)\n\nprint(\"submission.csv created\")\nprint(submission_df.head())\nprint(\"Total rows:\", len(submission_df))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T08:48:59.253348Z","iopub.execute_input":"2026-01-12T08:48:59.253652Z","iopub.status.idle":"2026-01-12T08:48:59.462488Z","shell.execute_reply.started":"2026-01-12T08:48:59.253601Z","shell.execute_reply":"2026-01-12T08:48:59.461741Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}