{"cells":[{"cell_type":"markdown","id":"3b4cf0cd","metadata":{},"source":"# Sartorius Cell Instance Segmentation: DINO vs MaskDINO\n\n이 노트북은 기존 Mask R-CNN 실험과 **동일한 데이터 분할 및 Sartorius mask AP**로 DINO 계열을 비교한다.\n\n- **DINO (detection-only)**: box만 예측하므로 대회의 instance-mask RLE 제출을 직접 생성할 수 없다. box AP 및 사각형 pseudo-mask의 하한선만 측정한다.\n- **MaskDINO**: mask query를 예측하므로 대회의 instance segmentation 및 RLE 제출에 직접 사용할 수 있다.\n- 결론은 실행 후 생성되는 `experiment_registry.csv`의 교차검증 결과로만 결정한다. 실행 전 점수를 추정하거나 만들어 내지 않는다."},{"cell_type":"code","execution_count":null,"id":"cfdb7d9a","metadata":{},"outputs":[],"source":"from __future__ import annotations\n\nimport importlib.util\nimport platform\nfrom pathlib import Path\nfrom zipfile import ZipFile\n\nimport torch\nimport torchvision\n\nLOCAL_DATA_DIR = Path('/home/Data_Drive_8TB/kykim/7. Kaggle/Cell_Instance_Segmentation')\nKAGGLE_INPUT_ROOT = Path('/kaggle/input')\nKAGGLE_DATA_CANDIDATES = [\n    path for path in KAGGLE_INPUT_ROOT.glob('*')\n    if (path / 'train.csv').exists() and (path / 'train').is_dir()\n]\nif not KAGGLE_DATA_CANDIDATES and KAGGLE_INPUT_ROOT.exists():\n    for input_dir in KAGGLE_INPUT_ROOT.iterdir():\n        train_zip = input_dir / 'train.zip'\n        test_zip = input_dir / 'test.zip'\n        train_csv = input_dir / 'train.csv'\n        if train_zip.exists() and test_zip.exists() and train_csv.exists():\n            extracted_dir = Path('/kaggle/working/sartorius_data')\n            extracted_dir.mkdir(parents=True, exist_ok=True)\n            with ZipFile(train_zip) as archive:\n                archive.extractall(extracted_dir)\n            with ZipFile(test_zip) as archive:\n                archive.extractall(extracted_dir)\n            (extracted_dir / 'train.csv').write_bytes(train_csv.read_bytes())\n            KAGGLE_DATA_CANDIDATES = [extracted_dir]\n            break\nDATA_DIR = KAGGLE_DATA_CANDIDATES[0] if KAGGLE_DATA_CANDIDATES else LOCAL_DATA_DIR\nTRAIN_CSV = DATA_DIR / 'train.csv'\nTRAIN_IMAGE_DIR = DATA_DIR / 'train'\nOUTPUT_DIR = (\n    Path('/kaggle/working/outputs_dino_comparison')\n    if Path('/kaggle').exists()\n    else LOCAL_DATA_DIR / 'outputs_dino_comparison'\n)\nOUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n\nDEPENDENCIES = {\n    'detectron2': importlib.util.find_spec('detectron2') is not None,\n    'maskdino': importlib.util.find_spec('maskdino') is not None,\n    'pycocotools': importlib.util.find_spec('pycocotools') is not None,\n    'scipy': importlib.util.find_spec('scipy') is not None,\n}\n\nprint({\n    'python': platform.python_version(),\n    'torch': torch.__version__,\n    'torchvision': torchvision.__version__,\n    'cuda_available': torch.cuda.is_available(),\n    'cuda': torch.version.cuda,\n    'torch_file': torch.__file__,\n    'gpu': torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,\n    'data_dir': str(DATA_DIR),\n    'output_dir': str(OUTPUT_DIR),\n    'train_csv_exists': TRAIN_CSV.exists(),\n    'train_images_exist': TRAIN_IMAGE_DIR.exists(),\n    **DEPENDENCIES,\n})\n\nassert TRAIN_CSV.exists(), f'Missing: {TRAIN_CSV}'\nassert TRAIN_IMAGE_DIR.exists(), f'Missing: {TRAIN_IMAGE_DIR}'"},{"cell_type":"code","execution_count":5,"id":"a4e535ab","metadata":{},"outputs":[],"source":"import os\nimport sys\n\nprint({\n    'sys.executable': sys.executable,\n    'sys.prefix': sys.prefix,\n    'CONDA_PREFIX': os.environ.get('CONDA_PREFIX'),\n    'CONDA_DEFAULT_ENV': os.environ.get('CONDA_DEFAULT_ENV'),\n    'expected_scrna_python': '/home/Data_Drive_8TB/kykim/.conda/envs/scrna/bin/python',\n    'is_expected_scrna': sys.executable == '/home/Data_Drive_8TB/kykim/.conda/envs/scrna/bin/python',\n})"},{"cell_type":"code","execution_count":null,"id":"5df94cbc","metadata":{},"outputs":[],"source":"# Kernel restart helper for local dependency changes only.\n# Kaggle executes notebooks in a fresh kernel and must continue past this cell.\nif not Path('/kaggle').exists():\n    import os\n    os._exit(0)"},{"cell_type":"code","execution_count":null,"id":"1d1850c4","metadata":{},"outputs":[],"source":"# Run once only when the dependency check reports False.\n# Keep the Kaggle runtime's CUDA PyTorch and patch MaskDINO's legacy CUDA API before building.\nimport subprocess\nimport sys\n\nMASKDINO_REPO = OUTPUT_DIR / 'third_party' / 'MaskDINO'\nOPS_DIR = MASKDINO_REPO / 'maskdino/modeling/pixel_decoder/ops'\noperator_built = any(OPS_DIR.glob('MultiScaleDeformableAttention*.so'))\n\nif not Path('/kaggle').exists() and (torch.__version__ != '2.6.0+cu124' or torchvision.__version__ != '0.21.0+cu124'):\n    subprocess.run([\n        sys.executable, '-m', 'pip', 'install', '--force-reinstall', '--no-cache-dir',\n        'torch==2.6.0+cu124', 'torchvision==0.21.0+cu124',\n        '--index-url', 'https://download.pytorch.org/whl/cu124',\n    ], check=True)\n    raise RuntimeError('Restart the kernel, then run this cell again after restoring CUDA PyTorch.')\n\nif importlib.util.find_spec('cv2') is None:\n    subprocess.run([sys.executable, '-m', 'pip', 'install', '--no-cache-dir', '--no-deps', 'opencv-python-headless'], check=True)\n    if not Path('/kaggle').exists():\n        raise RuntimeError('Restart the kernel after installing OpenCV, then run this cell again.')\nif importlib.util.find_spec('detectron2') is None:\n    subprocess.run(\n        [sys.executable, '-m', 'pip', 'install', '--no-cache-dir', '--no-build-isolation',\n         'git+https://github.com/facebookresearch/detectron2.git'],\n        check=True,\n    )\nif importlib.util.find_spec('timm') is None:\n    subprocess.run([sys.executable, '-m', 'pip', 'install', '--no-deps', 'timm'], check=True)\nif not MASKDINO_REPO.exists():\n    MASKDINO_REPO.parent.mkdir(parents=True, exist_ok=True)\n    subprocess.run(['git', 'clone', '--depth', '1', 'https://github.com/IDEA-Research/MaskDINO.git', str(MASKDINO_REPO)], check=True)\n\ncuda_source = OPS_DIR / 'src/cuda/ms_deform_attn_cuda.cu'\nif cuda_source.exists():\n    source = cuda_source.read_text()\n    source = source.replace('.type().is_cuda()', '.is_cuda()').replace('.data<', '.data_ptr<')\n    cuda_source.write_text(source)\n\nif not operator_built:\n    subprocess.run([sys.executable, 'setup.py', 'build_ext', '--inplace'], cwd=OPS_DIR, check=True)\nif str(MASKDINO_REPO) not in sys.path:\n    sys.path.insert(0, str(MASKDINO_REPO))\nif str(OPS_DIR) not in sys.path:\n    sys.path.insert(0, str(OPS_DIR))\nimport detectron2\nimport maskdino\nimport MultiScaleDeformableAttention\nprint('Detectron2 and MaskDINO source are ready:', MASKDINO_REPO)"},{"cell_type":"markdown","id":"eee1c3d6","metadata":{},"source":"## 1. 동일 데이터 분할\n\n원본 노트북처럼 이미지 단위로 분할하되 `cell_type`별 이미지 수를 최대한 보존한다. 분할 파일을 저장하여 Mask R-CNN, DINO, MaskDINO가 완전히 같은 validation 이미지를 사용하게 한다."},{"cell_type":"code","execution_count":4,"id":"4ca868d7","metadata":{},"outputs":[],"source":"import json\nimport random\n\nimport numpy as np\nimport pandas as pd\nfrom sklearn.model_selection import train_test_split\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\ntrain_df = pd.read_csv(TRAIN_CSV)\nimage_table = (\n    train_df.groupby('id', as_index=False)\n    .agg(cell_type=('cell_type', 'first'), height=('height', 'first'), width=('width', 'first'))\n)\n\ntry:\n    train_ids, val_ids = train_test_split(\n        image_table['id'].to_numpy(),\n        test_size=0.20,\n        random_state=SEED,\n        stratify=image_table['cell_type'],\n    )\nexcept ValueError:\n    train_ids, val_ids = train_test_split(\n        image_table['id'].to_numpy(), test_size=0.20, random_state=SEED\n    )\n\nsplit_path = OUTPUT_DIR / 'dino_split.json'\nsplit_path.write_text(json.dumps({'seed': SEED, 'train_ids': train_ids.tolist(), 'val_ids': val_ids.tolist()}, indent=2))\nprint(f'annotations={len(train_df):,}, images={len(image_table):,}, train={len(train_ids)}, val={len(val_ids)}')\ndisplay(image_table.assign(split=np.where(image_table.id.isin(val_ids), 'val', 'train')).groupby(['split', 'cell_type']).size())\n\n\ndef rle_decode(mask_rle: str, shape: tuple[int, int]) -> np.ndarray:\n    values = np.asarray(mask_rle.split(), dtype=np.int64)\n    starts, lengths = values[0::2] - 1, values[1::2]\n    flat = np.zeros(shape[0] * shape[1], dtype=np.uint8)\n    for start, length in zip(starts, lengths):\n        flat[start:start + length] = 1\n    return flat.reshape(shape)\n\n\ndef rle_encode(mask: np.ndarray) -> str:\n    pixels = np.concatenate([[0], mask.astype(np.uint8).reshape(-1), [0]])\n    runs = np.flatnonzero(pixels[1:] != pixels[:-1]) + 1\n    runs[1::2] -= runs[::2]\n    return ' '.join(map(str, runs))\n\nrow = train_df.iloc[0]\nroundtrip_mask = rle_decode(row.annotation, (int(row.height), int(row.width)))\nassert rle_encode(roundtrip_mask) == ' '.join(row.annotation.split())\nprint('Kaggle row-major RLE round trip: OK')"},{"cell_type":"markdown","id":"9a821330","metadata":{},"source":"## 2. MaskDINO용 COCO instance annotation\n\nMaskDINO/Detectron2는 COCO instance segmentation 형식을 읽는다. Kaggle의 row-major RLE을 binary mask로 복원한 뒤 polygon, bounding box, 면적으로 변환한다. 이 변환은 `opencv-python` 및 `pycocotools`가 설치된 뒤 실행한다."},{"cell_type":"code","execution_count":6,"id":"008b3081","metadata":{},"outputs":[],"source":"# Run after installing pycocotools in this kernel.\ndef masks_to_coco(images: pd.DataFrame, annotations: pd.DataFrame, output_json: Path) -> None:\n    \"\"\"Export exact instance masks as COCO RLE; avoids polygon rasterization loss.\"\"\"\n    coco_mask = importlib.import_module('pycocotools.mask')\n    payload = {'images': [], 'annotations': [], 'categories': [{'id': 1, 'name': 'cell'}]}\n    annotation_id = 1\n\n    for image_index, image_row in enumerate(images.itertuples(index=False), start=1):\n        image_id = image_row.id\n        height, width = int(image_row.height), int(image_row.width)\n        payload['images'].append({\n            'id': image_index,\n            'file_name': f'{image_id}.png',\n            'height': height,\n            'width': width,\n        })\n        for encoded in annotations.loc[annotations.id.eq(image_id), 'annotation']:\n            binary_mask = rle_decode(encoded, (height, width))\n            encoded_coco = coco_mask.encode(np.asfortranarray(binary_mask))\n            encoded_coco['counts'] = encoded_coco['counts'].decode('ascii')\n            ys, xs = np.where(binary_mask)\n            payload['annotations'].append({\n                'id': annotation_id,\n                'image_id': image_index,\n                'category_id': 1,\n                'segmentation': encoded_coco,\n                'bbox': [int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)],\n                'area': int(binary_mask.sum()),\n                'iscrowd': 0,\n            })\n            annotation_id += 1\n\n    output_json.write_text(json.dumps(payload))\n    print(f'Wrote {len(payload[\"images\"])} images and {len(payload[\"annotations\"])} exact masks to {output_json}')\n\n\ndef validate_coco_mask_export(coco_json: Path, expected_images: pd.DataFrame, annotations: pd.DataFrame) -> None:\n    \"\"\"Prove validation image membership and every exported mask match the source RLE exactly.\"\"\"\n    coco_mask = importlib.import_module('pycocotools.mask')\n    payload = json.loads(coco_json.read_text())\n    coco_images = {item['id']: item for item in payload['images']}\n    expected_ids = set(expected_images.id)\n    exported_ids = {Path(item['file_name']).stem for item in payload['images']}\n    assert exported_ids == expected_ids, 'COCO validation image IDs differ from the fixed validation split.'\n\n    checked = 0\n    for annotation in payload['annotations']:\n        image = coco_images[annotation['image_id']]\n        image_id = Path(image['file_name']).stem\n        decoded = coco_mask.decode(annotation['segmentation']).astype(np.uint8)\n        source_masks = [\n            rle_decode(value, (image['height'], image['width']))\n            for value in annotations.loc[annotations.id.eq(image_id), 'annotation']\n        ]\n        assert any(np.array_equal(decoded, source_mask) for source_mask in source_masks), (\n            f'Exported mask does not exactly match an input RLE: {image_id}'\n        )\n        checked += 1\n    print(f'COCO validation passed: {len(exported_ids)} identical images, {checked} exact instance masks.')\n\n\ntrain_images = image_table[image_table.id.isin(train_ids)]\nval_images = image_table[image_table.id.isin(val_ids)]\nCOCO_TRAIN_JSON = OUTPUT_DIR / 'train_maskdino_coco.json'\nCOCO_VAL_JSON = OUTPUT_DIR / 'val_maskdino_coco.json'\nmasks_to_coco(train_images, train_df, COCO_TRAIN_JSON)\nmasks_to_coco(val_images, train_df, COCO_VAL_JSON)\nvalidate_coco_mask_export(COCO_VAL_JSON, val_images, train_df)"},{"cell_type":"markdown","id":"e2f04852","metadata":{},"source":"## 3. Sartorius mask AP@[0.50:0.95]\n\n기존 노트북의 semantic Dice는 threshold 조정에는 쓸 수 있지만 Kaggle 순위 지표가 아니다. 아래는 예측/정답 instance mask를 일대일 매칭해 IoU 0.50부터 0.95까지의 이미지별 precision을 평균하는 비교 지표다."},{"cell_type":"code","execution_count":7,"id":"c46b5ea8","metadata":{},"outputs":[],"source":"from scipy.optimize import linear_sum_assignment\n\nIOU_THRESHOLDS = np.arange(0.50, 1.00, 0.05)\n\n\ndef mask_iou_matrix(pred_masks: list[np.ndarray], true_masks: list[np.ndarray]) -> np.ndarray:\n    if not pred_masks or not true_masks:\n        return np.zeros((len(pred_masks), len(true_masks)), dtype=np.float32)\n    predictions = np.asarray(pred_masks, dtype=np.uint8).reshape(len(pred_masks), -1)\n    targets = np.asarray(true_masks, dtype=np.uint8).reshape(len(true_masks), -1)\n    intersections = predictions @ targets.T\n    unions = predictions.sum(axis=1, keepdims=True) + targets.sum(axis=1) - intersections\n    return intersections / np.maximum(unions, 1)\n\n\ndef precision_at_iou(iou: np.ndarray, threshold: float) -> float:\n    n_pred, n_true = iou.shape\n    if n_pred == 0 and n_true == 0:\n        return 1.0\n    if n_pred == 0 or n_true == 0:\n        return 0.0\n    row_indices, col_indices = linear_sum_assignment(-iou)\n    true_positives = int((iou[row_indices, col_indices] >= threshold).sum())\n    false_positives = n_pred - true_positives\n    false_negatives = n_true - true_positives\n    return true_positives / (true_positives + false_positives + false_negatives)\n\n\ndef sartorius_mask_ap(predictions: dict[str, list[np.ndarray]], truth: dict[str, list[np.ndarray]]) -> float:\n    image_scores = []\n    for image_id, true_masks in truth.items():\n        iou = mask_iou_matrix(predictions.get(image_id, []), true_masks)\n        image_scores.append(np.mean([precision_at_iou(iou, threshold) for threshold in IOU_THRESHOLDS]))\n    return float(np.mean(image_scores))\n\n\ndef ground_truth_masks(ids: np.ndarray) -> dict[str, list[np.ndarray]]:\n    masks_by_image = {}\n    for image_row in image_table[image_table.id.isin(ids)].itertuples(index=False):\n        annotations = train_df.loc[train_df.id.eq(image_row.id), 'annotation']\n        masks_by_image[image_row.id] = [\n            rle_decode(annotation, (int(image_row.height), int(image_row.width)))\n            for annotation in annotations\n        ]\n    return masks_by_image\n\nsanity_mask = np.array([[1, 0], [0, 0]], dtype=np.uint8)\nassert abs(sartorius_mask_ap({'image': [sanity_mask]}, {'image': [sanity_mask]}) - 1.0) < 1e-12\nassert sartorius_mask_ap({}, {'image': [sanity_mask]}) == 0.0\nVAL_TRUTH = ground_truth_masks(val_ids)\nprint(f'Sartorius mask AP implementation sanity checks: OK; validation images={len(VAL_TRUTH)}')"},{"cell_type":"markdown","id":"8e575e02","metadata":{},"source":"## 4. DINO 적용성: box-only와 MaskDINO의 출력 계약\n\nplain DINO의 `pred_boxes`만으로는 객체 경계를 표현할 수 없다. 아래 변환은 **공정한 성능 후보가 아니라 구조적 하한선**을 얻기 위한 pseudo-mask 실험이다. MaskDINO는 `pred_masks`를 통해 실제 instance mask를 제공하므로 이 대회에 적합하다."},{"cell_type":"code","execution_count":10,"id":"f00fee6c","metadata":{},"outputs":[],"source":"def boxes_to_rectangle_masks(boxes_xyxy: np.ndarray, image_shape: tuple[int, int]) -> list[np.ndarray]:\n    \"\"\"DINO box output's deliberately weak pseudo-mask baseline; not a valid segmentation head.\"\"\"\n    height, width = image_shape\n    masks = []\n    for x0, y0, x1, y1 in np.asarray(boxes_xyxy):\n        left, right = np.clip(np.rint([x0, x1]).astype(int), 0, width)\n        top, bottom = np.clip(np.rint([y0, y1]).astype(int), 0, height)\n        if right > left and bottom > top:\n            mask = np.zeros((height, width), dtype=np.uint8)\n            mask[top:bottom, left:right] = 1\n            masks.append(mask)\n    return masks\n\n\ndef normalize_dino_output(output: dict, image_shape: tuple[int, int], score_threshold: float) -> list[np.ndarray]:\n    \"\"\"For a detection-only DINO implementation returning absolute xyxy boxes and scores.\"\"\"\n    keep = np.asarray(output['scores']) >= score_threshold\n    return boxes_to_rectangle_masks(np.asarray(output['boxes_xyxy'])[keep], image_shape)\n\n\ndef normalize_maskdino_output(\n    output: dict,\n    image_shape: tuple[int, int],\n    score_threshold: float,\n    mask_threshold: float = 0.5,\n) -> list[np.ndarray]:\n    \"\"\"For MaskDINO Instances exported as masks=[N,H,W] and scores=[N].\"\"\"\n    masks = np.asarray(output['masks'])\n    scores = np.asarray(output['scores'])\n    if masks.ndim != 3 or len(masks) != len(scores):\n        raise ValueError('MaskDINO adapter expects masks=[N,H,W] and scores=[N].')\n    if tuple(masks.shape[-2:]) != tuple(image_shape):\n        raise ValueError(f'MaskDINO output shape {masks.shape[-2:]} differs from image shape {image_shape}.')\n    return [(mask >= mask_threshold).astype(np.uint8) for mask, score in zip(masks, scores) if score >= score_threshold]\n\nprint('DINO is submission-eligible:', False)\nprint('MaskDINO is submission-eligible:', True)"},{"cell_type":"markdown","id":"a64bf3bc","metadata":{},"source":"## 5. 공통 검증, score sweep, 결과 등록\n\n각 모델 predictor는 `image_id -> {masks, scores}`를 반환하도록 연결한다. 동일한 validation images에서 score threshold를 고르고, 최고 threshold와 mask AP를 registry에 저장한다. 원본 Mask R-CNN도 이 셀의 형식으로 재평가해야 비교가 성립한다."},{"cell_type":"code","execution_count":11,"id":"0e43b479","metadata":{},"outputs":[],"source":"SCORE_GRID = np.arange(0.20, 0.81, 0.05)\nREGISTRY_PATH = OUTPUT_DIR / 'experiment_registry.csv'\n\n\ndef evaluate_cached_outputs(\n    cached_outputs: dict[str, dict],\n    output_adapter,\n    score_thresholds: np.ndarray = SCORE_GRID,\n) -> tuple[float, float, dict[str, list[np.ndarray]]]:\n    candidates = {}\n    scores = {}\n    for threshold in score_thresholds:\n        predicted = {}\n        for image_id, output in cached_outputs.items():\n            row = image_table.loc[image_table.id.eq(image_id)].iloc[0]\n            predicted[image_id] = output_adapter(output, (int(row.height), int(row.width)), float(threshold))\n        candidates[float(threshold)] = predicted\n        scores[float(threshold)] = sartorius_mask_ap(predicted, VAL_TRUTH)\n    best_threshold = max(scores, key=scores.get)\n    return best_threshold, scores[best_threshold], candidates[best_threshold]\n\n\ndef record_experiment(model_name: str, eligible: bool, score: float | None, threshold: float | None, notes: str) -> pd.DataFrame:\n    row = pd.DataFrame([{\n        'model': model_name,\n        'submission_eligible': eligible,\n        'mask_ap_50_95': score,\n        'score_threshold': threshold,\n        'seed': SEED,\n        'n_train_images': len(train_ids),\n        'n_val_images': len(val_ids),\n        'notes': notes,\n    }])\n    old = pd.read_csv(REGISTRY_PATH) if REGISTRY_PATH.exists() else pd.DataFrame()\n    result = pd.concat([old, row], ignore_index=True)\n    result.to_csv(REGISTRY_PATH, index=False)\n    return result\n\n# Example once inference is connected:\n# threshold, mask_ap, masks = evaluate_cached_outputs(maskdino_outputs, normalize_maskdino_output)\n# record_experiment('MaskDINO', True, mask_ap, threshold, 'same split/augmentations/epochs')\n# record_experiment('DINO rectangle pseudo-mask', False, dino_mask_ap, dino_threshold, 'box-only structural lower bound')\n# record_experiment('Mask R-CNN', True, maskrcnn_mask_ap, maskrcnn_threshold, 'recomputed on this split')\nprint('Registry path:', REGISTRY_PATH)"},{"cell_type":"markdown","id":"1cb971fa","metadata":{},"source":"## 6. MaskDINO 학습 연결\n\n현재 커널(`torch 2.6.0+cu124`)에는 MaskDINO와 Detectron2가 없다. 두 패키지는 PyTorch/CUDA ABI 호환 wheel 또는 소스 빌드가 필요하므로 자동 설치하지 않는다. 호환 가능한 Detectron2를 먼저 설치한 뒤 MaskDINO 공식 저장소를 설치하고, 아래 registry cell로 등록한다.\n\n권장 공통 recipe: 동일 split, 입력 long-edge, epoch, augmentation, seed, optimizer/scheduler를 고정한다. DINO는 box AP 용으로만 학습하고, MaskDINO는 같은 recipe의 mask AP로 최종 평가한다."},{"cell_type":"code","execution_count":6,"id":"f7f6abef","metadata":{},"outputs":[],"source":"COMMON_RECIPE = {\n    'seed': SEED,\n    'train_ids_file': str(split_path),\n    'train_images': len(train_ids),\n    'val_images': len(val_ids),\n    'epochs': 20,\n    'image_long_edge': 512,\n    'augmentations': ['horizontal_flip', 'vertical_flip', 'random_rotation', 'scale'],\n    'metric': 'Sartorius mask AP@[0.50:0.95]',\n}\n\nMODEL_PROTOCOL = pd.DataFrame([\n    {'model': 'Mask R-CNN', 'output': 'masks + scores', 'eligible': True, 'metric': 'mask AP'},\n    {'model': 'DINO', 'output': 'boxes + scores', 'eligible': False, 'metric': 'box AP; rectangle-mask lower bound'},\n    {'model': 'MaskDINO', 'output': 'masks + scores', 'eligible': True, 'metric': 'mask AP'},\n])\ndisplay(MODEL_PROTOCOL)\nprint(json.dumps(COMMON_RECIPE, indent=2))\n\n# MaskDINO integration checklist after installation:\n# 1. Register COCO_TRAIN_JSON and COCO_VAL_JSON with detectron2.data.datasets.register_coco_instances.\n# 2. Start from MaskDINO's instance-segmentation config and set DATASETS.TRAIN/TEST to those names.\n# 3. Align epochs, image size, augmentation, and seed with COMMON_RECIPE.\n# 4. Cache validation output as {image_id: {'masks': [N,H,W], 'scores': [N]}}.\n# 5. Run evaluate_cached_outputs(..., normalize_maskdino_output), then record_experiment(...)."},{"cell_type":"markdown","id":"09ce6fd4","metadata":{},"source":"## 6A. MaskDINO RLE mask 학습 검증\n\nMaskDINO 기본 COCO mapper는 polygon 변환을 전제한다. Sartorius annotation은 RLE이므로 아래 mapper가 COCO-RLE를 Detectron2 `BitMasks`로 직접 복원한다. smoke test는 동일 train split의 mini-batch에서 `loss_mask`와 `loss_dice`가 유한값으로 계산되는지 확인한다."},{"cell_type":"code","execution_count":8,"id":"ddb9e7dc","metadata":{},"outputs":[],"source":"import copy\nimport math\n\nfrom detectron2.config import get_cfg\nfrom detectron2.data import DatasetCatalog, MetadataCatalog, build_detection_train_loader\nfrom detectron2.data import detection_utils as d2_utils\nfrom detectron2.data import transforms as T\nfrom detectron2.data.datasets import register_coco_instances\nfrom detectron2.projects.deeplab import add_deeplab_config\nfrom maskdino import add_maskdino_config\nfrom train_net import Trainer as MaskDINOTrainer\n\n\nclass SartoriusRLEMapper:\n    \"\"\"Decode COCO-RLE annotations directly to the tensor masks MaskDINO trains on.\"\"\"\n\n    def __init__(self, cfg, is_train: bool):\n        self.is_train = is_train\n        self.image_format = cfg.INPUT.FORMAT\n        self.augmentations = []\n        if is_train:\n            self.augmentations = [\n                T.RandomFlip(horizontal=True, vertical=False),\n                T.RandomFlip(horizontal=False, vertical=True),\n                T.ResizeScale(\n                    min_scale=cfg.INPUT.MIN_SCALE,\n                    max_scale=cfg.INPUT.MAX_SCALE,\n                    target_height=cfg.INPUT.IMAGE_SIZE,\n                    target_width=cfg.INPUT.IMAGE_SIZE,\n                ),\n                T.FixedSizeCrop(crop_size=(cfg.INPUT.IMAGE_SIZE, cfg.INPUT.IMAGE_SIZE)),\n            ]\n\n    def __call__(self, dataset_dict: dict) -> dict:\n        dataset_dict = copy.deepcopy(dataset_dict)\n        image = d2_utils.read_image(dataset_dict['file_name'], format=self.image_format)\n        d2_utils.check_image_size(dataset_dict, image)\n        image, transforms = T.apply_transform_gens(self.augmentations, image)\n        image_shape = image.shape[:2]\n        dataset_dict['image'] = torch.as_tensor(np.ascontiguousarray(image.transpose(2, 0, 1)))\n        annotations = dataset_dict.pop('annotations', None)\n        if annotations is not None:\n            transformed = [\n                d2_utils.transform_instance_annotations(annotation, transforms, image_shape)\n                for annotation in annotations\n                if annotation.get('iscrowd', 0) == 0\n            ]\n            instances = d2_utils.annotations_to_instances(transformed, image_shape, mask_format='bitmask')\n            instances = d2_utils.filter_empty_instances(instances)\n            instances.gt_masks = instances.gt_masks.tensor\n            dataset_dict['instances'] = instances\n        return dataset_dict\n\n\nclass SartoriusMaskDINOTrainer(MaskDINOTrainer):\n    @classmethod\n    def build_train_loader(cls, cfg):\n        return build_detection_train_loader(cfg, mapper=SartoriusRLEMapper(cfg, is_train=True))\n\n\ndef register_sartorius_maskdino_sets() -> tuple[str, str]:\n    train_name, val_name = 'sartorius_maskdino_train', 'sartorius_maskdino_val'\n    for name, annotation_json in ((train_name, COCO_TRAIN_JSON), (val_name, COCO_VAL_JSON)):\n        if name in DatasetCatalog.list():\n            DatasetCatalog.remove(name)\n            MetadataCatalog.remove(name)\n        register_coco_instances(name, {'thing_classes': ['cell']}, str(annotation_json), str(TRAIN_IMAGE_DIR))\n    return train_name, val_name\n\n\nFAST_DEV_RUN = False\nTRAIN_MAX_ITER = 5000\nTRAIN_IMAGE_SIZE = 512\nTRAIN_NUM_QUERIES = 600\nMASKDINO_CONFIG = MASKDINO_REPO / 'configs/coco/instance-segmentation/maskdino_R50_bs16_50ep_3s_dowsample1_2048_bitmask.yaml'\nMASKDINO_OUTPUT = OUTPUT_DIR / 'maskdino_r50_corrected'\nMASKDINO_PRETRAINED = 'https://github.com/IDEA-Research/detrex-storage/releases/download/maskdino-v0.1.0/maskdino_r50_50ep_300q_hid1024_3sd1_instance_maskenhanced_mask46.1ap_box51.5ap.pth'\nTRAIN_SET_NAME, VAL_SET_NAME = register_sartorius_maskdino_sets()\n\ncfg = get_cfg()\nadd_deeplab_config(cfg)\nadd_maskdino_config(cfg)\ncfg.merge_from_file(str(MASKDINO_CONFIG))\ncfg.DATASETS.TRAIN = (TRAIN_SET_NAME,)\ncfg.DATASETS.TEST = (VAL_SET_NAME,)\ncfg.DATALOADER.NUM_WORKERS = 2\ncfg.INPUT.IMAGE_SIZE = TRAIN_IMAGE_SIZE\ncfg.INPUT.MIN_SIZE_TEST = TRAIN_IMAGE_SIZE\ncfg.INPUT.MAX_SIZE_TEST = TRAIN_IMAGE_SIZE\ncfg.INPUT.MIN_SCALE = 0.85\ncfg.INPUT.MAX_SCALE = 1.15\ncfg.MODEL.WEIGHTS = MASKDINO_PRETRAINED\ncfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES = 1\ncfg.MODEL.MaskDINO.NUM_OBJECT_QUERIES = TRAIN_NUM_QUERIES\ncfg.MODEL.MaskDINO.TEST.INSTANCE_ON = True\ncfg.MODEL.MaskDINO.TEST.SEMANTIC_ON = False\ncfg.MODEL.MaskDINO.TEST.PANOPTIC_ON = False\ncfg.TEST.DETECTIONS_PER_IMAGE = TRAIN_NUM_QUERIES\ncfg.SOLVER.IMS_PER_BATCH = 2\ncfg.SOLVER.BASE_LR = 2.5e-5\ncfg.SOLVER.MAX_ITER = TRAIN_MAX_ITER\ncfg.SOLVER.WARMUP_ITERS = 300\ncfg.SOLVER.CHECKPOINT_PERIOD = 1000\ncfg.TEST.EVAL_PERIOD = 0\ncfg.SEED = SEED\ncfg.OUTPUT_DIR = str(MASKDINO_OUTPUT)\ncfg.freeze()\n\ntrain_records = DatasetCatalog.get(TRAIN_SET_NAME)\nval_records = DatasetCatalog.get(VAL_SET_NAME)\nassert {Path(item['file_name']).stem for item in train_records} == set(train_ids)\nassert {Path(item['file_name']).stem for item in val_records} == set(val_ids)\nprint({\n    'pretrained': cfg.MODEL.WEIGHTS,\n    'train_images': len(train_records),\n    'validation_images': len(val_records),\n    'max_iter': cfg.SOLVER.MAX_ITER,\n    'train_image_size': cfg.INPUT.IMAGE_SIZE,\n    'test_image_size': cfg.INPUT.MIN_SIZE_TEST,\n    'queries': cfg.MODEL.MaskDINO.NUM_OBJECT_QUERIES,\n})"},{"cell_type":"markdown","id":"340f16c8","metadata":{},"source":"## 6B. MaskDINO 빠른 학습\n\n동일한 484개 train image와 동일한 122개 validation image를 유지한다. 먼저 mask 학습 경로와 validation pipeline을 확인하기 위해 600 iteration, 384px, 300 query의 빠른 검증 모드를 사용한다. 이 checkpoint는 최종 Mask R-CNN 대 MaskDINO 성능 비교용이 아니며, 최종 비교 전에는 512px, 600 query, 20 epoch 설정으로 재학습해야 한다."},{"cell_type":"code","execution_count":9,"id":"a9c2ec9f","metadata":{},"outputs":[],"source":"from detectron2.engine import default_setup\n\nMASKDINO_OUTPUT.mkdir(parents=True, exist_ok=True)\ntrainer = SartoriusMaskDINOTrainer(cfg)\ntrainer.resume_or_load(resume=False)\ntraining_result = trainer.train()\n\nMASKDINO_CHECKPOINT = MASKDINO_OUTPUT / 'model_final.pth'\nassert MASKDINO_CHECKPOINT.exists(), f'Missing trained checkpoint: {MASKDINO_CHECKPOINT}'\nprint('MaskDINO training complete:', MASKDINO_CHECKPOINT)"},{"cell_type":"code","execution_count":12,"id":"a84736f5","metadata":{},"outputs":[],"source":"# Fast-run validation: export actual MaskDINO instance masks at original image resolution.\nimport cv2\nfrom detectron2.engine import DefaultPredictor\n\npredict_cfg = cfg.clone()\npredict_cfg.defrost()\npredict_cfg.MODEL.WEIGHTS = str(MASKDINO_CHECKPOINT)\npredict_cfg.MODEL.MaskDINO.TEST.INSTANCE_ON = True\npredict_cfg.MODEL.MaskDINO.TEST.SEMANTIC_ON = False\npredict_cfg.MODEL.MaskDINO.TEST.PANOPTIC_ON = False\npredict_cfg.freeze()\npredictor = DefaultPredictor(predict_cfg)\n\nmaskdino_outputs = {}\nfor image_id in val_ids:\n    image = cv2.imread(str(TRAIN_IMAGE_DIR / f'{image_id}.png'))\n    prediction = predictor(image)['instances'].to('cpu')\n    maskdino_outputs[image_id] = {\n        'masks': prediction.pred_masks.numpy().astype(np.uint8),\n        'scores': prediction.scores.numpy(),\n    }\n\nMASKDINO_FAST_THRESHOLD = 0.05\n_, fast_mask_ap, _ = evaluate_cached_outputs(\n    maskdino_outputs,\n    normalize_maskdino_output,\n    score_thresholds=np.asarray([MASKDINO_FAST_THRESHOLD]),\n)\nmean_predictions = float(np.mean([len(item['scores']) for item in maskdino_outputs.values()]))\nprint({\n    'validation_images': len(maskdino_outputs),\n    'mean_instances_before_threshold': mean_predictions,\n    'score_threshold': MASKDINO_FAST_THRESHOLD,\n    'sartorius_mask_ap_50_95': fast_mask_ap,\n})\nrecord_experiment(\n    'MaskDINO fast (600 iter, 384px, 300 queries)',\n    True,\n    fast_mask_ap,\n    MASKDINO_FAST_THRESHOLD,\n    'Pipeline smoke test only; not comparable to the 20-epoch 512px Mask R-CNN baseline.',\n)"},{"cell_type":"markdown","id":"4c03b87c","metadata":{},"source":""},{"cell_type":"markdown","id":"9d03dc16","metadata":{},"source":"## 7. MaskDINO 제출 및 최종 선택\n\nMaskDINO의 실제 mask output만 아래 함수에 전달한다. 점수가 높은 instance를 먼저 배치해 overlap을 제거하고 Kaggle 제출 RLE을 생성한다. 마지막 셀은 registry의 실측 mask AP와 속도/복잡도를 바탕으로 선택한다."},{"cell_type":"code","execution_count":null,"id":"80b59e20","metadata":{},"outputs":[],"source":"import cv2\nfrom detectron2.engine import DefaultPredictor\n\n\ndef make_submission_from_maskdino(\n    test_outputs: dict[str, dict],\n    score_threshold: float,\n    output_path: Path,\n    min_area: int = 30,\n) -> pd.DataFrame:\n    rows = []\n    for image_id, output in test_outputs.items():\n        masks = np.asarray(output['masks'])\n        scores = np.asarray(output['scores'])\n        order = np.argsort(scores)[::-1]\n        used = np.zeros(masks.shape[-2:], dtype=bool)\n        count = 0\n        for index in order:\n            if scores[index] < score_threshold:\n                continue\n            instance = (masks[index] >= 0.5) & ~used\n            if instance.sum() < min_area:\n                continue\n            used |= instance\n            rows.append({'id': image_id, 'predicted': rle_encode(instance)})\n            count += 1\n        if count == 0:\n            rows.append({'id': image_id, 'predicted': '1 1'})\n    submission = pd.DataFrame(rows).sort_values('id').reset_index(drop=True)\n    submission.to_csv(output_path, index=False)\n    return submission\n\n\n# The registry is optional at Kaggle runtime; submission always uses the fixed validation threshold.\nREGISTRY_PATH = OUTPUT_DIR / 'experiment_registry.csv'\nregistry = pd.read_csv(REGISTRY_PATH) if REGISTRY_PATH.exists() else pd.DataFrame(\n    columns=['model', 'submission_eligible', 'mask_ap_50_95']\n)\n\n# Run test inference with the corrected 5,000-iteration checkpoint.\ntest_outputs = {}\ntest_image_dir = DATA_DIR / 'test'\ntest_submission_path = (\n    Path('/kaggle/working/submission.csv')\n    if Path('/kaggle').exists()\n    else OUTPUT_DIR / 'submission_maskdino_r50_corrected.csv'\n)\nsubmission_predict_cfg = cfg.clone()\nsubmission_predict_cfg.defrost()\nsubmission_predict_cfg.MODEL.WEIGHTS = str(MASKDINO_CHECKPOINT)\nsubmission_predict_cfg.MODEL.MaskDINO.TEST.INSTANCE_ON = True\nsubmission_predict_cfg.MODEL.MaskDINO.TEST.SEMANTIC_ON = False\nsubmission_predict_cfg.MODEL.MaskDINO.TEST.PANOPTIC_ON = False\nsubmission_predict_cfg.freeze()\nsubmission_predictor = DefaultPredictor(submission_predict_cfg)\n\nfor image_path in sorted(test_image_dir.glob('*.png')):\n    image = cv2.imread(str(image_path))\n    instances = submission_predictor(image)['instances'].to('cpu')\n    test_outputs[image_path.stem] = {\n        'masks': instances.pred_masks.numpy().astype(np.uint8),\n        'scores': instances.scores.numpy(),\n    }\n\nsubmission = make_submission_from_maskdino(\n    test_outputs,\n    score_threshold=0.05,\n    output_path=test_submission_path,\n)\nprint({\n    'checkpoint': str(MASKDINO_CHECKPOINT),\n    'test_images': len(test_outputs),\n    'submission_rows': len(submission),\n    'submission_path': str(test_submission_path),\n    'instances_per_image': {image_id: len(output['scores']) for image_id, output in test_outputs.items()},\n})"}],"metadata":{"kernelspec":{"display_name":"scrna","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.11.15"}},"nbformat":4,"nbformat_minor":5}