Spaces:
Running on Zero
Running on Zero
Delete folder code/scripts with huggingface_hub
Browse files
code/scripts/medseg/infer_med_seg_qwen3vl.py
DELETED
|
@@ -1,1300 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
# -*- coding: utf-8 -*-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Text-grounding inference for BiomedParseData_unzip using the Qwen3-VL +
|
| 6 |
-
SAM3 (med_seg) training stack defined in this repo.
|
| 7 |
-
|
| 8 |
-
Architecture (matches qwenvl/modalities/med_seg/decoder.py):
|
| 9 |
-
|
| 10 |
-
image+text → Qwen3-VL → user-turn JOINT hidden (image_pad block + text)
|
| 11 |
-
image → SAM3 backbone → multi-scale dense features
|
| 12 |
-
joint hidden ─proj─► text_embeds ─cross-attn (inside SAM3)──► cls/box/mask
|
| 13 |
-
|
| 14 |
-
What this script does:
|
| 15 |
-
- Recursively find ``**/test.json`` under --data_root
|
| 16 |
-
- For each unit (dir containing test.json):
|
| 17 |
-
- Run text-grounding segmentation per (image, ann) instance
|
| 18 |
-
- Save predicted mask PNGs (1:1 mappable to GT mask_file paths)
|
| 19 |
-
- Optional: save red-overlay visualization PNGs
|
| 20 |
-
- Write a metrics JSON identical in schema to the BiomedParse reference:
|
| 21 |
-
{
|
| 22 |
-
"biomed_<tag>/grounding": {
|
| 23 |
-
"grounding": {
|
| 24 |
-
"scores": {...},
|
| 25 |
-
"instance_results": [...]
|
| 26 |
-
}
|
| 27 |
-
}
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
How weights are loaded:
|
| 31 |
-
1. ``Qwen3VLForConditionalGeneration.from_pretrained(--ckpt_path)`` — loads
|
| 32 |
-
Qwen3-VL backbone + modality_router.decoders.med_seg.proj from the trained
|
| 33 |
-
checkpoint. The MedSegDecoder skeleton is registered automatically via
|
| 34 |
-
_register_all_modalities() because the saved config carries med_seg_config.
|
| 35 |
-
2. ``Sam3Model.from_pretrained(--sam3_model_path)`` — fresh facebook/sam3
|
| 36 |
-
topology, then ``decoder.set_sam3(sam3)``.
|
| 37 |
-
3. Overlay ``model.modality_router.decoders.med_seg.sam3.*`` tensors from
|
| 38 |
-
--ckpt_path onto the live SAM3 instance — restores the medical-domain
|
| 39 |
-
training.
|
| 40 |
-
|
| 41 |
-
Distributed: torchrun launches N processes; rank 0 enumerates units and
|
| 42 |
-
broadcasts the list. Within each unit, samples are sharded across ranks via
|
| 43 |
-
DistributedSampler. Per-rank instance results are gathered to rank 0 which
|
| 44 |
-
writes the per-unit metrics JSON.
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
from __future__ import annotations
|
| 48 |
-
import argparse
|
| 49 |
-
import json
|
| 50 |
-
import logging
|
| 51 |
-
import math
|
| 52 |
-
import os
|
| 53 |
-
import sys
|
| 54 |
-
from dataclasses import dataclass
|
| 55 |
-
from pathlib import Path
|
| 56 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 57 |
-
|
| 58 |
-
import numpy as np
|
| 59 |
-
import torch
|
| 60 |
-
import torch.distributed as dist
|
| 61 |
-
import torch.nn.functional as F
|
| 62 |
-
from PIL import Image
|
| 63 |
-
from torch.utils.data import DataLoader, Dataset
|
| 64 |
-
from torch.utils.data.distributed import DistributedSampler
|
| 65 |
-
from transformers import AutoProcessor, Sam3Model, Sam3Processor
|
| 66 |
-
|
| 67 |
-
# Make the repo root importable when launched via torchrun from anywhere.
|
| 68 |
-
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 69 |
-
if str(_REPO_ROOT) not in sys.path:
|
| 70 |
-
sys.path.insert(0, str(_REPO_ROOT))
|
| 71 |
-
|
| 72 |
-
from qwenvl.models.modeling_bio_qwen3_vl import Qwen3VLForConditionalGeneration # noqa: E402
|
| 73 |
-
from qwenvl.models.configuration_bio_qwen3_vl import Qwen3VLConfig # noqa: E402
|
| 74 |
-
from qwenvl.modalities.med_seg.processor import DEFAULT_MED_SEG_SYSTEM_PROMPT # noqa: E402
|
| 75 |
-
from qwenvl.modalities.med_seg.sam3_resize import ( # noqa: E402
|
| 76 |
-
patch_sam3_for_image_size,
|
| 77 |
-
patch_sam3_processor_for_image_size,
|
| 78 |
-
)
|
| 79 |
-
from qwenvl.utils.checkpoint_io import load_state_dict_from_ckpt_dir # noqa: E402
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 83 |
-
# Distributed helpers
|
| 84 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 85 |
-
def dist_is_ready() -> bool:
|
| 86 |
-
return dist.is_available() and dist.is_initialized()
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def get_rank_env() -> int:
|
| 90 |
-
return int(os.environ.get("RANK", os.environ.get("LOCAL_RANK", "0")))
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def get_local_rank_env() -> int:
|
| 94 |
-
return int(os.environ.get("LOCAL_RANK", "0"))
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def is_rank0() -> bool:
|
| 98 |
-
return (dist.get_rank() == 0) if dist_is_ready() else (get_rank_env() == 0)
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def dist_barrier():
|
| 102 |
-
if dist_is_ready():
|
| 103 |
-
dist.barrier()
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def broadcast_object_list_py(obj_list: List[Any]) -> List[Any]:
|
| 107 |
-
if not dist_is_ready():
|
| 108 |
-
return obj_list
|
| 109 |
-
holder = [obj_list] if is_rank0() else [None]
|
| 110 |
-
dist.broadcast_object_list(holder, src=0)
|
| 111 |
-
if holder[0] is None:
|
| 112 |
-
raise RuntimeError("broadcast_object_list failed")
|
| 113 |
-
return holder[0]
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def setup_logger(output_dir: str) -> logging.Logger:
|
| 117 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 118 |
-
rank = get_rank_env()
|
| 119 |
-
log_path = os.path.join(output_dir, f"infer_rank{rank}.log")
|
| 120 |
-
|
| 121 |
-
logger = logging.getLogger("infer_med_seg_qwen3vl")
|
| 122 |
-
logger.setLevel(logging.INFO)
|
| 123 |
-
logger.handlers.clear()
|
| 124 |
-
|
| 125 |
-
fmt = logging.Formatter(
|
| 126 |
-
"%(asctime)s | %(levelname)s | rank=%(rank)s | %(message)s",
|
| 127 |
-
"%Y-%m-%d %H:%M:%S",
|
| 128 |
-
)
|
| 129 |
-
|
| 130 |
-
sh = logging.StreamHandler()
|
| 131 |
-
sh.setFormatter(fmt)
|
| 132 |
-
logger.addHandler(sh)
|
| 133 |
-
|
| 134 |
-
fh = logging.FileHandler(log_path, mode="a", encoding="utf-8")
|
| 135 |
-
fh.setFormatter(fmt)
|
| 136 |
-
logger.addHandler(fh)
|
| 137 |
-
|
| 138 |
-
old_factory = logging.getLogRecordFactory()
|
| 139 |
-
|
| 140 |
-
def record_factory(*args, **kwargs):
|
| 141 |
-
record = old_factory(*args, **kwargs)
|
| 142 |
-
record.rank = rank
|
| 143 |
-
return record
|
| 144 |
-
|
| 145 |
-
logging.setLogRecordFactory(record_factory)
|
| 146 |
-
logger.info(f"Logging to: {log_path}")
|
| 147 |
-
return logger
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 151 |
-
# IO utils
|
| 152 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
-
def load_json(path: str) -> Any:
|
| 154 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 155 |
-
return json.load(f)
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def save_json(path: str, obj: Any):
|
| 159 |
-
tmp = path + ".tmp"
|
| 160 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 161 |
-
json.dump(obj, f, ensure_ascii=False, indent=2)
|
| 162 |
-
os.replace(tmp, path)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def load_image_rgb(path: str) -> Image.Image:
|
| 166 |
-
return Image.open(path).convert("RGB")
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def load_mask_bool(path: str) -> np.ndarray:
|
| 170 |
-
m = Image.open(path).convert("L")
|
| 171 |
-
arr = np.array(m)
|
| 172 |
-
mx = int(arr.max()) if arr.size > 0 else 0
|
| 173 |
-
if mx <= 10:
|
| 174 |
-
return arr > 0
|
| 175 |
-
return arr > 127
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 179 |
-
# Metric utilities (BiomedParse reference schema)
|
| 180 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 181 |
-
def calc_iou_dice(pred: np.ndarray, gt: np.ndarray) -> Tuple[float, float, int, int, int, int]:
|
| 182 |
-
pred = pred.astype(bool)
|
| 183 |
-
gt = gt.astype(bool)
|
| 184 |
-
I = int((pred & gt).sum())
|
| 185 |
-
U = int((pred | gt).sum())
|
| 186 |
-
pA = int(pred.sum())
|
| 187 |
-
gA = int(gt.sum())
|
| 188 |
-
iou = float(I / (U + 1e-6)) if U > 0 else (1.0 if I == 0 else 0.0)
|
| 189 |
-
denom = pA + gA
|
| 190 |
-
dice = float((2 * I) / (denom + 1e-6)) if denom > 0 else (1.0 if I == 0 else 0.0)
|
| 191 |
-
return iou, dice, I, U, pA, gA
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def summarize_scores(instance_results: List[Dict[str, Any]]) -> Dict[str, float]:
|
| 195 |
-
if len(instance_results) == 0:
|
| 196 |
-
return {
|
| 197 |
-
"precision@0.5": 0.0, "precision@0.6": 0.0, "precision@0.7": 0.0,
|
| 198 |
-
"precision@0.8": 0.0, "precision@0.9": 0.0,
|
| 199 |
-
"cIoU": 0.0, "mIoU": 0.0, "cDice": 0.0, "mDice": 0.0,
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
ious = np.array([float(x["IoU"][0]) for x in instance_results], dtype=np.float64)
|
| 203 |
-
dices = np.array([float(x["Dice"][0]) for x in instance_results], dtype=np.float64)
|
| 204 |
-
Is = np.array([float(x["I"][0]) for x in instance_results], dtype=np.float64)
|
| 205 |
-
Us = np.array([float(x["U"][0]) for x in instance_results], dtype=np.float64)
|
| 206 |
-
pA = np.array([float(x["pred_area"][0]) for x in instance_results], dtype=np.float64)
|
| 207 |
-
|
| 208 |
-
# gt_area reconstructed: dice = 2I/(P+G) → G = 2I/dice − P
|
| 209 |
-
gtA = (2.0 * Is / np.maximum(dices, 1e-12)) - pA
|
| 210 |
-
gtA = np.maximum(gtA, 0.0)
|
| 211 |
-
|
| 212 |
-
scores: Dict[str, float] = {}
|
| 213 |
-
for t in [0.5, 0.6, 0.7, 0.8, 0.9]:
|
| 214 |
-
scores[f"precision@{t}"] = float((ious >= t).mean() * 100.0)
|
| 215 |
-
scores["cIoU"] = float(Is.sum() / (Us.sum() + 1e-12) * 100.0)
|
| 216 |
-
scores["mIoU"] = float(ious.mean() * 100.0)
|
| 217 |
-
scores["cDice"] = float((2.0 * Is.sum()) / (pA.sum() + gtA.sum() + 1e-12) * 100.0)
|
| 218 |
-
scores["mDice"] = float(dices.mean() * 100.0)
|
| 219 |
-
return scores
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 223 |
-
# Dataset parsing
|
| 224 |
-
# (BiomedParseData layout: each unit dir has test.json + test/ + test_mask/)
|
| 225 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 226 |
-
@dataclass
|
| 227 |
-
class GroundingSample:
|
| 228 |
-
image_path: str
|
| 229 |
-
mask_path: str
|
| 230 |
-
prompt_text: str
|
| 231 |
-
metadata: Dict[str, Any]
|
| 232 |
-
save_rel: str # relative path under dest_dir/pred_masks/
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def _finite_list(xs) -> bool:
|
| 236 |
-
try:
|
| 237 |
-
for v in xs:
|
| 238 |
-
if not math.isfinite(float(v)):
|
| 239 |
-
return False
|
| 240 |
-
return True
|
| 241 |
-
except Exception:
|
| 242 |
-
return False
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _get_text_from_ann(ann: Dict[str, Any], cat_name_by_id: Dict[int, str]) -> str:
|
| 246 |
-
for k in ["text", "phrase", "caption", "sentence", "sent", "query"]:
|
| 247 |
-
if k in ann and isinstance(ann[k], str) and ann[k].strip():
|
| 248 |
-
return ann[k].strip()
|
| 249 |
-
if "sentences" in ann and isinstance(ann["sentences"], list) and ann["sentences"]:
|
| 250 |
-
v0 = ann["sentences"][0]
|
| 251 |
-
if isinstance(v0, str) and v0.strip():
|
| 252 |
-
return v0.strip()
|
| 253 |
-
if isinstance(v0, dict):
|
| 254 |
-
for kk in ["sent", "raw", "text"]:
|
| 255 |
-
if kk in v0 and isinstance(v0[kk], str) and v0[kk].strip():
|
| 256 |
-
return v0[kk].strip()
|
| 257 |
-
cid = ann.get("category_id", None)
|
| 258 |
-
if cid is not None:
|
| 259 |
-
try:
|
| 260 |
-
cname = cat_name_by_id.get(int(cid), None)
|
| 261 |
-
if cname:
|
| 262 |
-
return str(cname)
|
| 263 |
-
except Exception:
|
| 264 |
-
pass
|
| 265 |
-
return "visual"
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def _resolve_existing_path(rel_or_abs: str, bases: List[str]) -> Optional[str]:
|
| 269 |
-
if not rel_or_abs:
|
| 270 |
-
return None
|
| 271 |
-
if os.path.isabs(rel_or_abs) and os.path.exists(rel_or_abs):
|
| 272 |
-
return rel_or_abs
|
| 273 |
-
for b in bases:
|
| 274 |
-
p = os.path.join(b, rel_or_abs)
|
| 275 |
-
if os.path.exists(p):
|
| 276 |
-
return p
|
| 277 |
-
return None
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def _safe_rel_from_json_path(p: str) -> str:
|
| 281 |
-
import re
|
| 282 |
-
p = (p or "").replace("\\", "/").strip()
|
| 283 |
-
p = re.sub(r"^[A-Za-z]:", "", p)
|
| 284 |
-
p = p.lstrip("/")
|
| 285 |
-
parts = [x for x in p.split("/") if x not in ("", ".", "..")]
|
| 286 |
-
return "/".join(parts)
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
def parse_test_json_to_samples(
|
| 290 |
-
test_json_path: str, data_root: str, logger: logging.Logger,
|
| 291 |
-
) -> List[GroundingSample]:
|
| 292 |
-
test_json_path = os.path.abspath(test_json_path)
|
| 293 |
-
unit_dir = os.path.dirname(test_json_path)
|
| 294 |
-
data_root = os.path.abspath(data_root)
|
| 295 |
-
j = load_json(test_json_path)
|
| 296 |
-
|
| 297 |
-
img_bases = [
|
| 298 |
-
unit_dir,
|
| 299 |
-
os.path.join(unit_dir, "test"),
|
| 300 |
-
os.path.join(unit_dir, "Test"),
|
| 301 |
-
os.path.join(unit_dir, "images"),
|
| 302 |
-
os.path.join(unit_dir, "images", "test"),
|
| 303 |
-
os.path.join(unit_dir, "imgs"),
|
| 304 |
-
data_root,
|
| 305 |
-
]
|
| 306 |
-
mask_bases = [
|
| 307 |
-
unit_dir,
|
| 308 |
-
os.path.join(unit_dir, "test_mask"),
|
| 309 |
-
os.path.join(unit_dir, "test_masks"),
|
| 310 |
-
os.path.join(unit_dir, "Test_mask"),
|
| 311 |
-
os.path.join(unit_dir, "masks"),
|
| 312 |
-
os.path.join(unit_dir, "masks", "test"),
|
| 313 |
-
data_root,
|
| 314 |
-
]
|
| 315 |
-
|
| 316 |
-
cat_name_by_id: Dict[int, str] = {}
|
| 317 |
-
if isinstance(j, dict) and "categories" in j and isinstance(j["categories"], list):
|
| 318 |
-
for c in j["categories"]:
|
| 319 |
-
try:
|
| 320 |
-
cid = int(c.get("id"))
|
| 321 |
-
cat_name_by_id[cid] = str(c.get("name"))
|
| 322 |
-
except Exception:
|
| 323 |
-
pass
|
| 324 |
-
|
| 325 |
-
samples: List[GroundingSample] = []
|
| 326 |
-
|
| 327 |
-
def pick_mask_file(ann: Dict[str, Any]) -> Optional[str]:
|
| 328 |
-
for k in ["mask_file", "mask_path", "mask", "segmentation_mask", "seg_mask"]:
|
| 329 |
-
if k in ann and isinstance(ann[k], str) and ann[k]:
|
| 330 |
-
return ann[k]
|
| 331 |
-
return None
|
| 332 |
-
|
| 333 |
-
if isinstance(j, dict) and "images" in j and "annotations" in j:
|
| 334 |
-
images = {im["id"]: im for im in j["images"] if isinstance(im, dict) and "id" in im}
|
| 335 |
-
for ann in j["annotations"]:
|
| 336 |
-
if not isinstance(ann, dict):
|
| 337 |
-
continue
|
| 338 |
-
mfile = pick_mask_file(ann)
|
| 339 |
-
if not mfile:
|
| 340 |
-
continue
|
| 341 |
-
img_id = ann.get("image_id", None)
|
| 342 |
-
im = images.get(img_id, None) if img_id is not None else None
|
| 343 |
-
if im is None:
|
| 344 |
-
continue
|
| 345 |
-
file_name = im.get("file_name") or im.get("path") or im.get("image_file")
|
| 346 |
-
if not isinstance(file_name, str) or not file_name:
|
| 347 |
-
continue
|
| 348 |
-
img_path = _resolve_existing_path(file_name, img_bases)
|
| 349 |
-
mask_path = _resolve_existing_path(mfile, mask_bases)
|
| 350 |
-
if img_path is None or mask_path is None:
|
| 351 |
-
continue
|
| 352 |
-
prompt_text = _get_text_from_ann(ann, cat_name_by_id)
|
| 353 |
-
|
| 354 |
-
gi = {
|
| 355 |
-
"area": int(ann.get("area", 0) or 0),
|
| 356 |
-
"mask_file": mfile,
|
| 357 |
-
"iscrowd": int(ann.get("iscrowd", 0) or 0),
|
| 358 |
-
"image_id": int(img_id) if img_id is not None else 0,
|
| 359 |
-
"category_id": ann.get("category_id", None),
|
| 360 |
-
"id": ann.get("id", ann.get("ann_id", 0)),
|
| 361 |
-
"file_name": Path(file_name).name,
|
| 362 |
-
"split": ann.get("split", "test"),
|
| 363 |
-
"ann_id": ann.get("ann_id", ann.get("id", 0)),
|
| 364 |
-
"ref_id": ann.get("ref_id", ann.get("id", 0)),
|
| 365 |
-
}
|
| 366 |
-
meta = {
|
| 367 |
-
"file_name": file_name,
|
| 368 |
-
"image_id": int(img_id) if img_id is not None else 0,
|
| 369 |
-
"grounding_info": [gi],
|
| 370 |
-
}
|
| 371 |
-
mask_rel = _safe_rel_from_json_path(mfile)
|
| 372 |
-
save_rel = str(Path(mask_rel).with_suffix(".png"))
|
| 373 |
-
samples.append(GroundingSample(img_path, mask_path, prompt_text, meta, save_rel))
|
| 374 |
-
|
| 375 |
-
elif isinstance(j, list):
|
| 376 |
-
for item in j:
|
| 377 |
-
if not isinstance(item, dict):
|
| 378 |
-
continue
|
| 379 |
-
meta = item.get("metadata", None)
|
| 380 |
-
if not isinstance(meta, dict):
|
| 381 |
-
continue
|
| 382 |
-
file_name = meta.get("file_name", None)
|
| 383 |
-
image_id = meta.get("image_id", 0)
|
| 384 |
-
gi_list = meta.get("grounding_info", [])
|
| 385 |
-
if not file_name or not isinstance(gi_list, list):
|
| 386 |
-
continue
|
| 387 |
-
for gi in gi_list:
|
| 388 |
-
if not isinstance(gi, dict):
|
| 389 |
-
continue
|
| 390 |
-
mfile = gi.get("mask_file", None)
|
| 391 |
-
if not mfile:
|
| 392 |
-
continue
|
| 393 |
-
img_path = _resolve_existing_path(file_name, img_bases)
|
| 394 |
-
mask_path = _resolve_existing_path(mfile, mask_bases)
|
| 395 |
-
if img_path is None or mask_path is None:
|
| 396 |
-
continue
|
| 397 |
-
prompt_text = item.get("text") or item.get("prompt") or "visual"
|
| 398 |
-
meta2 = {
|
| 399 |
-
"file_name": file_name,
|
| 400 |
-
"image_id": int(image_id),
|
| 401 |
-
"grounding_info": [gi],
|
| 402 |
-
}
|
| 403 |
-
mask_rel = _safe_rel_from_json_path(mfile)
|
| 404 |
-
save_rel = str(Path(mask_rel).with_suffix(".png"))
|
| 405 |
-
samples.append(GroundingSample(img_path, mask_path, str(prompt_text), meta2, save_rel))
|
| 406 |
-
|
| 407 |
-
logger.info(f"[Parse] {test_json_path} -> {len(samples)} grounding instances")
|
| 408 |
-
return samples
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
class GroundingInferDataset(Dataset):
|
| 412 |
-
def __init__(self, samples: List[GroundingSample]):
|
| 413 |
-
self.samples = samples
|
| 414 |
-
|
| 415 |
-
def __len__(self):
|
| 416 |
-
return len(self.samples)
|
| 417 |
-
|
| 418 |
-
def __getitem__(self, idx: int) -> GroundingSample:
|
| 419 |
-
return self.samples[idx]
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
def collate_samples(batch: List[GroundingSample]) -> List[GroundingSample]:
|
| 423 |
-
return batch
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
def discover_all_test_jsons(data_root: str) -> List[str]:
|
| 427 |
-
out = []
|
| 428 |
-
for dirpath, _, filenames in os.walk(data_root):
|
| 429 |
-
if "test.json" in filenames:
|
| 430 |
-
out.append(os.path.join(dirpath, "test.json"))
|
| 431 |
-
out.sort()
|
| 432 |
-
return out
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 436 |
-
# user_text_mask — bit-identical to MedSegCollator._build_user_text_mask
|
| 437 |
-
#
|
| 438 |
-
# Covers the full Qwen3-VL native image block (<|vision_start|> ... <|vision_end|>)
|
| 439 |
-
# plus the trailing text query. SAM3 must see the same span at inference as it
|
| 440 |
-
# did at training time.
|
| 441 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 442 |
-
def build_user_text_mask(
|
| 443 |
-
input_ids_1d: torch.Tensor,
|
| 444 |
-
attention_mask_1d: torch.Tensor,
|
| 445 |
-
qwen_image_pad_id: Optional[int],
|
| 446 |
-
qwen_vision_start_id: Optional[int],
|
| 447 |
-
pad_token_id: int = 0,
|
| 448 |
-
) -> torch.Tensor:
|
| 449 |
-
m = torch.zeros_like(input_ids_1d, dtype=torch.bool)
|
| 450 |
-
if attention_mask_1d is not None:
|
| 451 |
-
valid = attention_mask_1d.to(dtype=torch.bool)
|
| 452 |
-
else:
|
| 453 |
-
valid = input_ids_1d.ne(int(pad_token_id))
|
| 454 |
-
valid_pos = torch.nonzero(valid, as_tuple=False).flatten()
|
| 455 |
-
if valid_pos.numel() == 0:
|
| 456 |
-
return m
|
| 457 |
-
start = int(valid_pos[0].item())
|
| 458 |
-
end = int(valid_pos[-1].item())
|
| 459 |
-
|
| 460 |
-
image_pad_pos = None
|
| 461 |
-
if qwen_image_pad_id is not None:
|
| 462 |
-
matches = torch.nonzero(
|
| 463 |
-
input_ids_1d.eq(int(qwen_image_pad_id)) & valid, as_tuple=False
|
| 464 |
-
).flatten()
|
| 465 |
-
if matches.numel() > 0:
|
| 466 |
-
image_pad_pos = int(matches[0].item())
|
| 467 |
-
|
| 468 |
-
if image_pad_pos is not None:
|
| 469 |
-
start = image_pad_pos
|
| 470 |
-
if qwen_vision_start_id is not None:
|
| 471 |
-
prefix = input_ids_1d[: image_pad_pos + 1].eq(int(qwen_vision_start_id))
|
| 472 |
-
prefix = prefix & valid[: image_pad_pos + 1]
|
| 473 |
-
starts = torch.nonzero(prefix, as_tuple=False).flatten()
|
| 474 |
-
if starts.numel() > 0:
|
| 475 |
-
start = int(starts[-1].item())
|
| 476 |
-
elif qwen_vision_start_id is not None:
|
| 477 |
-
matches = torch.nonzero(
|
| 478 |
-
input_ids_1d.eq(int(qwen_vision_start_id)) & valid, as_tuple=False
|
| 479 |
-
).flatten()
|
| 480 |
-
if matches.numel() > 0:
|
| 481 |
-
start = int(matches[0].item())
|
| 482 |
-
|
| 483 |
-
m[start:end + 1] = True
|
| 484 |
-
return m
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 488 |
-
# Single-batch inference
|
| 489 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 490 |
-
@torch.inference_mode()
|
| 491 |
-
def infer_pil_batch_probs(
|
| 492 |
-
model: Qwen3VLForConditionalGeneration,
|
| 493 |
-
qwen_processor: Any,
|
| 494 |
-
sam3_processor: Sam3Processor,
|
| 495 |
-
system_prompt: str,
|
| 496 |
-
images: List[Image.Image],
|
| 497 |
-
prompts: List[str],
|
| 498 |
-
device: torch.device,
|
| 499 |
-
model_dtype: torch.dtype,
|
| 500 |
-
qwen_image_pad_id: Optional[int],
|
| 501 |
-
qwen_vision_start_id: Optional[int],
|
| 502 |
-
pad_token_id: int,
|
| 503 |
-
) -> List[np.ndarray]:
|
| 504 |
-
"""Run one image batch and return probability masks at each image's size."""
|
| 505 |
-
orig_sizes: List[Tuple[int, int]] = [(im.size[1], im.size[0]) for im in images] # (H, W)
|
| 506 |
-
|
| 507 |
-
# ── Qwen3-VL inputs (chat template matches MedSegCollator) ──
|
| 508 |
-
chat_texts: List[str] = []
|
| 509 |
-
for ut in prompts:
|
| 510 |
-
messages = [
|
| 511 |
-
{"role": "system", "content": [{"type": "text", "text": system_prompt}]},
|
| 512 |
-
{"role": "user", "content": [
|
| 513 |
-
{"type": "image"},
|
| 514 |
-
{"type": "text", "text": "\n" + str(ut)},
|
| 515 |
-
]},
|
| 516 |
-
]
|
| 517 |
-
chat_texts.append(qwen_processor.apply_chat_template(
|
| 518 |
-
messages, tokenize=False, add_generation_prompt=False,
|
| 519 |
-
))
|
| 520 |
-
qwen_full = qwen_processor(
|
| 521 |
-
text=chat_texts, images=images, return_tensors="pt", padding=True,
|
| 522 |
-
)
|
| 523 |
-
|
| 524 |
-
# user_text_mask per sample
|
| 525 |
-
user_masks = []
|
| 526 |
-
for i in range(len(images)):
|
| 527 |
-
user_masks.append(build_user_text_mask(
|
| 528 |
-
qwen_full["input_ids"][i],
|
| 529 |
-
qwen_full["attention_mask"][i],
|
| 530 |
-
qwen_image_pad_id=qwen_image_pad_id,
|
| 531 |
-
qwen_vision_start_id=qwen_vision_start_id,
|
| 532 |
-
pad_token_id=pad_token_id,
|
| 533 |
-
))
|
| 534 |
-
user_text_mask = torch.stack(user_masks, dim=0).to(device)
|
| 535 |
-
|
| 536 |
-
# ── SAM3 inputs ──
|
| 537 |
-
# Sentinel box per sample (label=-10) so SAM3's geometry_encoder branch is
|
| 538 |
-
# always exercised — same trick as MedSegCollator. text_grounding has no
|
| 539 |
-
# real prompt boxes; SAM3 ignores -10-labeled boxes (modeling_sam3.py).
|
| 540 |
-
sentinel_boxes = [[[0.0, 0.0, 1.0, 1.0]] for _ in images]
|
| 541 |
-
sentinel_labels = [[-10] for _ in images]
|
| 542 |
-
sam3_enc = sam3_processor(
|
| 543 |
-
images=images,
|
| 544 |
-
input_boxes=sentinel_boxes,
|
| 545 |
-
input_boxes_labels=sentinel_labels,
|
| 546 |
-
return_tensors="pt",
|
| 547 |
-
)
|
| 548 |
-
|
| 549 |
-
# Move tensors to device. Float tensors must match the model's dtype
|
| 550 |
-
# (Qwen3-VL + SAM3 are loaded in bf16/fp16 here; the processors return
|
| 551 |
-
# fp32 by default and will dtype-mismatch the first matmul without
|
| 552 |
-
# this cast — we have no autocast wrapper at inference time).
|
| 553 |
-
qwen_inputs = {
|
| 554 |
-
"input_ids": qwen_full["input_ids"].to(device), # int, no dtype cast
|
| 555 |
-
"attention_mask": qwen_full["attention_mask"].to(device),
|
| 556 |
-
}
|
| 557 |
-
if "pixel_values" in qwen_full:
|
| 558 |
-
qwen_inputs["pixel_values"] = qwen_full["pixel_values"].to(device=device, dtype=model_dtype)
|
| 559 |
-
if "image_grid_thw" in qwen_full:
|
| 560 |
-
qwen_inputs["image_grid_thw"] = qwen_full["image_grid_thw"].to(device) # int
|
| 561 |
-
|
| 562 |
-
pixel_values_sam3 = sam3_enc["pixel_values"].to(device=device, dtype=model_dtype)
|
| 563 |
-
input_boxes = sam3_enc.get("input_boxes")
|
| 564 |
-
input_boxes_labels = sam3_enc.get("input_boxes_labels")
|
| 565 |
-
if input_boxes is not None:
|
| 566 |
-
input_boxes = input_boxes.to(device=device, dtype=model_dtype)
|
| 567 |
-
if input_boxes_labels is not None:
|
| 568 |
-
input_boxes_labels = input_boxes_labels.to(device) # int
|
| 569 |
-
|
| 570 |
-
# ── Qwen3-VL forward → last_hidden_state ──
|
| 571 |
-
# Use the inner Qwen3VLModel (skips lm_head matmul, mirrors training's
|
| 572 |
-
# frozen-Qwen path under med_seg_freeze_qwen=True).
|
| 573 |
-
inner_outputs = model.model(
|
| 574 |
-
input_ids=qwen_inputs["input_ids"],
|
| 575 |
-
attention_mask=qwen_inputs["attention_mask"],
|
| 576 |
-
pixel_values=qwen_inputs.get("pixel_values"),
|
| 577 |
-
image_grid_thw=qwen_inputs.get("image_grid_thw"),
|
| 578 |
-
)
|
| 579 |
-
hidden_states = inner_outputs.last_hidden_state # [B, L, D]
|
| 580 |
-
|
| 581 |
-
# ── MedSegDecoder.decode (same proj + slice + SAM3 forward as training) ──
|
| 582 |
-
decoder = model.model.modality_router.decoders["med_seg"]
|
| 583 |
-
out = decoder.decode(
|
| 584 |
-
hidden_states=hidden_states,
|
| 585 |
-
pixel_values_sam3=pixel_values_sam3,
|
| 586 |
-
input_boxes=input_boxes,
|
| 587 |
-
input_boxes_labels=input_boxes_labels,
|
| 588 |
-
user_text_mask=user_text_mask,
|
| 589 |
-
targets=None, # only needed by compute_loss, not by decode/forward
|
| 590 |
-
)
|
| 591 |
-
|
| 592 |
-
if getattr(out, "pred_masks", None) is None:
|
| 593 |
-
return [np.zeros(orig_sizes[i], dtype=np.float32) for i in range(len(images))]
|
| 594 |
-
|
| 595 |
-
pred_masks = out.pred_masks # [B, Q, h', w']
|
| 596 |
-
pred_logits = out.pred_logits
|
| 597 |
-
if pred_logits.ndim == 2:
|
| 598 |
-
pred_logits = pred_logits.unsqueeze(-1) # [B, Q, 1]
|
| 599 |
-
|
| 600 |
-
probs: List[np.ndarray] = []
|
| 601 |
-
for i in range(len(images)):
|
| 602 |
-
q = int(pred_logits[i, :, 0].argmax().item())
|
| 603 |
-
pm = pred_masks[i, q:q + 1] # [1, h', w']
|
| 604 |
-
H, W = orig_sizes[i]
|
| 605 |
-
pm_up = F.interpolate(
|
| 606 |
-
pm.unsqueeze(0).float(), size=(H, W),
|
| 607 |
-
mode="bilinear", align_corners=False,
|
| 608 |
-
)[0, 0]
|
| 609 |
-
probs.append(pm_up.sigmoid().detach().cpu().numpy().astype(np.float32))
|
| 610 |
-
|
| 611 |
-
return probs
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
@torch.inference_mode()
|
| 615 |
-
def infer_batch(
|
| 616 |
-
model: Qwen3VLForConditionalGeneration,
|
| 617 |
-
qwen_processor: Any,
|
| 618 |
-
sam3_processor: Sam3Processor,
|
| 619 |
-
system_prompt: str,
|
| 620 |
-
batch: List[GroundingSample],
|
| 621 |
-
device: torch.device,
|
| 622 |
-
model_dtype: torch.dtype,
|
| 623 |
-
qwen_image_pad_id: Optional[int],
|
| 624 |
-
qwen_vision_start_id: Optional[int],
|
| 625 |
-
pad_token_id: int,
|
| 626 |
-
mask_threshold: float = 0.5,
|
| 627 |
-
) -> Tuple[List[np.ndarray], List[Image.Image]]:
|
| 628 |
-
"""Run one batch end-to-end and return (pred_bool_masks_at_orig_size, pil_images)."""
|
| 629 |
-
images: List[Image.Image] = [load_image_rgb(s.image_path) for s in batch]
|
| 630 |
-
prompts: List[str] = [s.prompt_text or "visual" for s in batch]
|
| 631 |
-
probs = infer_pil_batch_probs(
|
| 632 |
-
model=model,
|
| 633 |
-
qwen_processor=qwen_processor,
|
| 634 |
-
sam3_processor=sam3_processor,
|
| 635 |
-
system_prompt=system_prompt,
|
| 636 |
-
images=images,
|
| 637 |
-
prompts=prompts,
|
| 638 |
-
device=device,
|
| 639 |
-
model_dtype=model_dtype,
|
| 640 |
-
qwen_image_pad_id=qwen_image_pad_id,
|
| 641 |
-
qwen_vision_start_id=qwen_vision_start_id,
|
| 642 |
-
pad_token_id=pad_token_id,
|
| 643 |
-
)
|
| 644 |
-
preds = [(p > float(mask_threshold)).astype(bool) for p in probs]
|
| 645 |
-
return preds, images
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
def make_overlapping_tiles(
|
| 649 |
-
width: int,
|
| 650 |
-
height: int,
|
| 651 |
-
tile_size: int,
|
| 652 |
-
overlap: float,
|
| 653 |
-
) -> List[Tuple[int, int, int, int]]:
|
| 654 |
-
tile_size = max(1, int(tile_size))
|
| 655 |
-
overlap = min(0.9, max(0.0, float(overlap)))
|
| 656 |
-
tw = min(tile_size, int(width))
|
| 657 |
-
th = min(tile_size, int(height))
|
| 658 |
-
if tw >= width and th >= height:
|
| 659 |
-
return [(0, 0, int(width), int(height))]
|
| 660 |
-
|
| 661 |
-
stride_x = max(1, int(round(tw * (1.0 - overlap))))
|
| 662 |
-
stride_y = max(1, int(round(th * (1.0 - overlap))))
|
| 663 |
-
|
| 664 |
-
xs = list(range(0, max(width - tw, 0) + 1, stride_x))
|
| 665 |
-
ys = list(range(0, max(height - th, 0) + 1, stride_y))
|
| 666 |
-
if not xs or xs[-1] != width - tw:
|
| 667 |
-
xs.append(max(width - tw, 0))
|
| 668 |
-
if not ys or ys[-1] != height - th:
|
| 669 |
-
ys.append(max(height - th, 0))
|
| 670 |
-
|
| 671 |
-
seen = set()
|
| 672 |
-
tiles: List[Tuple[int, int, int, int]] = []
|
| 673 |
-
for y in ys:
|
| 674 |
-
for x in xs:
|
| 675 |
-
box = (int(x), int(y), int(x + tw), int(y + th))
|
| 676 |
-
if box in seen:
|
| 677 |
-
continue
|
| 678 |
-
seen.add(box)
|
| 679 |
-
tiles.append(box)
|
| 680 |
-
return tiles
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
@torch.inference_mode()
|
| 684 |
-
def infer_batch_tiled(
|
| 685 |
-
model: Qwen3VLForConditionalGeneration,
|
| 686 |
-
qwen_processor: Any,
|
| 687 |
-
sam3_processor: Sam3Processor,
|
| 688 |
-
system_prompt: str,
|
| 689 |
-
batch: List[GroundingSample],
|
| 690 |
-
device: torch.device,
|
| 691 |
-
model_dtype: torch.dtype,
|
| 692 |
-
qwen_image_pad_id: Optional[int],
|
| 693 |
-
qwen_vision_start_id: Optional[int],
|
| 694 |
-
pad_token_id: int,
|
| 695 |
-
tile_size: int,
|
| 696 |
-
tile_overlap: float,
|
| 697 |
-
tile_batch_size: int,
|
| 698 |
-
mask_threshold: float = 0.5,
|
| 699 |
-
) -> Tuple[List[np.ndarray], List[Image.Image]]:
|
| 700 |
-
"""Sliding-window inference: run prompt on every tile and max-merge probs."""
|
| 701 |
-
final_preds: List[np.ndarray] = []
|
| 702 |
-
pil_images: List[Image.Image] = []
|
| 703 |
-
tile_batch_size = max(1, int(tile_batch_size))
|
| 704 |
-
|
| 705 |
-
for sample in batch:
|
| 706 |
-
image = load_image_rgb(sample.image_path)
|
| 707 |
-
pil_images.append(image)
|
| 708 |
-
W, H = image.size
|
| 709 |
-
merged = np.zeros((H, W), dtype=np.float32)
|
| 710 |
-
tiles = make_overlapping_tiles(W, H, int(tile_size), float(tile_overlap))
|
| 711 |
-
for start in range(0, len(tiles), tile_batch_size):
|
| 712 |
-
tile_boxes = tiles[start:start + tile_batch_size]
|
| 713 |
-
tile_images = [image.crop(box) for box in tile_boxes]
|
| 714 |
-
tile_prompts = [sample.prompt_text or "visual"] * len(tile_images)
|
| 715 |
-
tile_probs = infer_pil_batch_probs(
|
| 716 |
-
model=model,
|
| 717 |
-
qwen_processor=qwen_processor,
|
| 718 |
-
sam3_processor=sam3_processor,
|
| 719 |
-
system_prompt=system_prompt,
|
| 720 |
-
images=tile_images,
|
| 721 |
-
prompts=tile_prompts,
|
| 722 |
-
device=device,
|
| 723 |
-
model_dtype=model_dtype,
|
| 724 |
-
qwen_image_pad_id=qwen_image_pad_id,
|
| 725 |
-
qwen_vision_start_id=qwen_vision_start_id,
|
| 726 |
-
pad_token_id=pad_token_id,
|
| 727 |
-
)
|
| 728 |
-
for (x1, y1, x2, y2), prob in zip(tile_boxes, tile_probs):
|
| 729 |
-
merged[y1:y2, x1:x2] = np.maximum(merged[y1:y2, x1:x2], prob)
|
| 730 |
-
final_preds.append((merged > float(mask_threshold)).astype(bool))
|
| 731 |
-
|
| 732 |
-
return final_preds, pil_images
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 736 |
-
# Visualization helpers (no scipy / cv2 dependency)
|
| 737 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 738 |
-
def save_pred_mask_png(path: str, mask_bool: np.ndarray):
|
| 739 |
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 740 |
-
arr = (mask_bool.astype(np.uint8) * 255)
|
| 741 |
-
Image.fromarray(arr, mode="L").save(path)
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
def _mask_boundary(mask: np.ndarray, width: int = 2) -> np.ndarray:
|
| 745 |
-
m = (mask.astype(np.uint8) > 0).astype(np.uint8)
|
| 746 |
-
H, W = m.shape
|
| 747 |
-
pad = np.pad(m, 1, constant_values=0)
|
| 748 |
-
er = np.ones((H, W), dtype=np.uint8)
|
| 749 |
-
for dy in (-1, 0, 1):
|
| 750 |
-
for dx in (-1, 0, 1):
|
| 751 |
-
if dy == 0 and dx == 0:
|
| 752 |
-
continue
|
| 753 |
-
er &= pad[1 + dy:1 + dy + H, 1 + dx:1 + dx + W]
|
| 754 |
-
boundary = (m & (1 - er)).astype(bool)
|
| 755 |
-
if width > 1:
|
| 756 |
-
b = boundary.astype(np.uint8)
|
| 757 |
-
for _ in range(width - 1):
|
| 758 |
-
padb = np.pad(b, 1, constant_values=0)
|
| 759 |
-
nb = np.zeros_like(b)
|
| 760 |
-
for dy in (-1, 0, 1):
|
| 761 |
-
for dx in (-1, 0, 1):
|
| 762 |
-
nb |= padb[1 + dy:1 + dy + H, 1 + dx:1 + dx + W]
|
| 763 |
-
b = nb
|
| 764 |
-
boundary = b.astype(bool)
|
| 765 |
-
return boundary
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
def save_vis_overlay_png(
|
| 769 |
-
path: str,
|
| 770 |
-
image_rgb: Image.Image,
|
| 771 |
-
mask_bool: np.ndarray,
|
| 772 |
-
alpha: float = 0.45,
|
| 773 |
-
color: Tuple[int, int, int] = (255, 0, 0),
|
| 774 |
-
draw_contour: bool = True,
|
| 775 |
-
contour_width: int = 2,
|
| 776 |
-
):
|
| 777 |
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 778 |
-
img = image_rgb.convert("RGBA")
|
| 779 |
-
rgba = np.array(img, dtype=np.float32)
|
| 780 |
-
H, W = rgba.shape[:2]
|
| 781 |
-
m = mask_bool.astype(bool)
|
| 782 |
-
if m.shape != (H, W):
|
| 783 |
-
m = np.array(
|
| 784 |
-
Image.fromarray(m.astype(np.uint8) * 255).resize((W, H), resample=Image.NEAREST)
|
| 785 |
-
) > 0
|
| 786 |
-
if m.any():
|
| 787 |
-
c = np.array([color[0], color[1], color[2], 255.0], dtype=np.float32)
|
| 788 |
-
rgba[m] = rgba[m] * (1.0 - alpha) + c * alpha
|
| 789 |
-
if draw_contour:
|
| 790 |
-
b = _mask_boundary(m, width=int(contour_width))
|
| 791 |
-
rgba[b] = c
|
| 792 |
-
out = Image.fromarray(np.clip(rgba, 0, 255).astype(np.uint8), mode="RGBA")
|
| 793 |
-
out.save(path)
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 797 |
-
# Model construction (SAM3 attachment)
|
| 798 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 799 |
-
def build_model(
|
| 800 |
-
ckpt_path: str,
|
| 801 |
-
sam3_model_path: str,
|
| 802 |
-
dtype: torch.dtype,
|
| 803 |
-
device: torch.device,
|
| 804 |
-
logger: logging.Logger,
|
| 805 |
-
sam3_image_size: Optional[int] = None,
|
| 806 |
-
) -> Tuple[Qwen3VLForConditionalGeneration, Any]:
|
| 807 |
-
"""Load Qwen3-VL+proj from ckpt, attach SAM3 (fresh topology + ckpt overlay).
|
| 808 |
-
|
| 809 |
-
Returns (model, qwen_processor). The med_seg decoder's sam3 sub-module is
|
| 810 |
-
attached and weight-overlaid; ``decoder.decode(...)`` is callable.
|
| 811 |
-
"""
|
| 812 |
-
# Load OUR Qwen3VLConfig subclass directly (bypassing AutoConfig, which
|
| 813 |
-
# may be hijacked by transformers' own qwen3_vl registration and silently
|
| 814 |
-
# drop the med_seg_config field).
|
| 815 |
-
config = Qwen3VLConfig.from_pretrained(ckpt_path)
|
| 816 |
-
if getattr(config, "med_seg_config", None) is None:
|
| 817 |
-
raise RuntimeError(
|
| 818 |
-
f"[load] config.med_seg_config is None after loading {ckpt_path}/config.json. "
|
| 819 |
-
f"Run scripts/segmentation/train/fix_resume_ckpt_config.py on the ckpt first."
|
| 820 |
-
)
|
| 821 |
-
if is_rank0():
|
| 822 |
-
logger.info(
|
| 823 |
-
f"[load] config.med_seg_config OK: "
|
| 824 |
-
f"sam3_text_dim={config.med_seg_config.sam3_text_dim}, "
|
| 825 |
-
f"mask_hw=({config.med_seg_config.mask_h},{config.med_seg_config.mask_w})"
|
| 826 |
-
)
|
| 827 |
-
|
| 828 |
-
# ── Load main model via from_pretrained ──
|
| 829 |
-
# The sam3 sub-module is NOT yet attached at this point, so the ckpt's
|
| 830 |
-
# ``model.modality_router.decoders.med_seg.sam3.*`` keys would be
|
| 831 |
-
# reported as UNEXPECTED by transformers' loader. We silence that
|
| 832 |
-
# report (it's misleading — those tensors will be loaded a few lines
|
| 833 |
-
# below into the freshly-attached sam3 sub-module).
|
| 834 |
-
if is_rank0():
|
| 835 |
-
logger.info(f"[load] Qwen3VLForConditionalGeneration.from_pretrained({ckpt_path})")
|
| 836 |
-
|
| 837 |
-
import transformers.utils.logging as _hf_logging
|
| 838 |
-
_saved_verbosity = _hf_logging.get_verbosity()
|
| 839 |
-
_hf_logging.set_verbosity_error()
|
| 840 |
-
try:
|
| 841 |
-
model = Qwen3VLForConditionalGeneration.from_pretrained(
|
| 842 |
-
ckpt_path, config=config, dtype=dtype,
|
| 843 |
-
)
|
| 844 |
-
finally:
|
| 845 |
-
_hf_logging.set_verbosity(_saved_verbosity)
|
| 846 |
-
model.to(device=device)
|
| 847 |
-
model.eval()
|
| 848 |
-
|
| 849 |
-
qwen_processor = AutoProcessor.from_pretrained(ckpt_path)
|
| 850 |
-
|
| 851 |
-
# ── Ensure modalities are registered ──
|
| 852 |
-
# ``from_pretrained`` may have copied/rebuilt the config internally and
|
| 853 |
-
# dropped ``med_seg_config`` along the way; even if model.__init__ ran
|
| 854 |
-
# ``_register_all_modalities``, the saved-state-restoration step can
|
| 855 |
-
# replace ``model.config`` with a config that lost the dataclass field.
|
| 856 |
-
# We restore the field on the live model.config and re-run registration.
|
| 857 |
-
# The registration call is idempotent (skips already-registered modalities).
|
| 858 |
-
if getattr(model.config, "med_seg_config", None) is None:
|
| 859 |
-
if is_rank0():
|
| 860 |
-
logger.info(
|
| 861 |
-
"[load] model.config.med_seg_config is None after from_pretrained — "
|
| 862 |
-
"restoring from explicitly-loaded config and re-registering modalities."
|
| 863 |
-
)
|
| 864 |
-
model.config.med_seg_config = config.med_seg_config
|
| 865 |
-
model.model._register_all_modalities(model.config)
|
| 866 |
-
|
| 867 |
-
# Move any newly-created modality components to correct device/dtype
|
| 868 |
-
# (decoder skeleton was just instantiated on CPU/fp32 by register_modality).
|
| 869 |
-
router = model.model.modality_router
|
| 870 |
-
for mod_dict in (router.encoders, router.projectors, router.decoders):
|
| 871 |
-
for name in mod_dict:
|
| 872 |
-
mod_dict[name] = mod_dict[name].to(device=device, dtype=dtype)
|
| 873 |
-
|
| 874 |
-
# nn.ModuleDict has no .get() — use __contains__ + __getitem__.
|
| 875 |
-
if "med_seg" not in router.decoders:
|
| 876 |
-
raise RuntimeError(
|
| 877 |
-
"med_seg decoder still not registered after manual "
|
| 878 |
-
"_register_all_modalities() call. "
|
| 879 |
-
"Check qwenvl/modalities/med_seg/__init__.py exports "
|
| 880 |
-
"MODALITY_CONFIG_KEY and register_modality."
|
| 881 |
-
)
|
| 882 |
-
decoder = router.decoders["med_seg"]
|
| 883 |
-
|
| 884 |
-
# ── Attach SAM3 backbone ──
|
| 885 |
-
if is_rank0():
|
| 886 |
-
logger.info(f"[load] Sam3Model.from_pretrained({sam3_model_path}, dtype={dtype})")
|
| 887 |
-
# Mirror the training-time image_size override. ckpt-aware: if the
|
| 888 |
-
# checkpoint was trained at a non-default image_size, the user MUST
|
| 889 |
-
# pass --sam3_image_size with the same value, otherwise SAM3 weights
|
| 890 |
-
# won't fit (FPN feature sizes were baked into the trained tensors).
|
| 891 |
-
sam3_load_kwargs: Dict[str, Any] = {"dtype": dtype}
|
| 892 |
-
if sam3_image_size:
|
| 893 |
-
from transformers import Sam3Config
|
| 894 |
-
sam3_cfg = Sam3Config.from_pretrained(sam3_model_path)
|
| 895 |
-
new_mask_size = patch_sam3_for_image_size(sam3_cfg, int(sam3_image_size))
|
| 896 |
-
sam3_load_kwargs["config"] = sam3_cfg
|
| 897 |
-
if is_rank0():
|
| 898 |
-
logger.info(
|
| 899 |
-
f"[load] SAM3 image_size override: {sam3_image_size} "
|
| 900 |
-
f"(mask {new_mask_size}×{new_mask_size})"
|
| 901 |
-
)
|
| 902 |
-
sam3 = Sam3Model.from_pretrained(sam3_model_path, **sam3_load_kwargs)
|
| 903 |
-
sam3.to(device=device)
|
| 904 |
-
sam3.eval()
|
| 905 |
-
|
| 906 |
-
# Heal corrupted cells in the freshly downloaded facebook/sam3 (same as
|
| 907 |
-
# Trained-ckpt overlay below will write over these
|
| 908 |
-
# for any cell that was actually trained.
|
| 909 |
-
healed_cells = 0
|
| 910 |
-
healed_tensors = 0
|
| 911 |
-
for n, p in sam3.named_parameters():
|
| 912 |
-
if torch.isnan(p).any() or torch.isinf(p).any() or p.abs().max() > 100.0:
|
| 913 |
-
with torch.no_grad():
|
| 914 |
-
bad_mask = torch.isnan(p) | torch.isinf(p) | (p.abs() > 100.0)
|
| 915 |
-
n_bad = int(bad_mask.sum().item())
|
| 916 |
-
n_total = p.numel()
|
| 917 |
-
if n_bad / n_total < 0.01:
|
| 918 |
-
healthy = p[~bad_mask]
|
| 919 |
-
fill_val = healthy.float().median().to(p.dtype) if healthy.numel() > 0 \
|
| 920 |
-
else torch.zeros((), dtype=p.dtype, device=p.device)
|
| 921 |
-
p.data[bad_mask] = fill_val
|
| 922 |
-
else:
|
| 923 |
-
if p.dim() >= 2:
|
| 924 |
-
fan_in = p.shape[1] * (p.shape[2:].numel() if p.dim() > 2 else 1)
|
| 925 |
-
std = (2.0 / fan_in) ** 0.5
|
| 926 |
-
p.data = torch.randn_like(p) * std
|
| 927 |
-
else:
|
| 928 |
-
p.data.zero_()
|
| 929 |
-
healed_cells += n_bad
|
| 930 |
-
healed_tensors += 1
|
| 931 |
-
if healed_cells > 0 and is_rank0():
|
| 932 |
-
logger.warning(
|
| 933 |
-
f"[sam3] healed {healed_cells} corrupted cells across "
|
| 934 |
-
f"{healed_tensors} tensors in fresh SAM3 weights."
|
| 935 |
-
)
|
| 936 |
-
|
| 937 |
-
decoder.set_sam3(sam3)
|
| 938 |
-
|
| 939 |
-
# ── Overlay trained sam3 weights from the bio_qwen3vl ckpt ──
|
| 940 |
-
# The sam3 sub-module was not present during from_pretrained, so its
|
| 941 |
-
# tensors landed as UNEXPECTED (silenced above). We recover them here.
|
| 942 |
-
src_state = load_state_dict_from_ckpt_dir(ckpt_path)
|
| 943 |
-
sam3_prefix = "model.modality_router.decoders.med_seg.sam3."
|
| 944 |
-
ckpt_sam3 = {
|
| 945 |
-
k[len(sam3_prefix):]: v
|
| 946 |
-
for k, v in src_state.items()
|
| 947 |
-
if k.startswith(sam3_prefix)
|
| 948 |
-
}
|
| 949 |
-
if ckpt_sam3:
|
| 950 |
-
ckpt_sam3 = {
|
| 951 |
-
k: v.to(device=device, dtype=dtype) if v.is_floating_point() else v.to(device=device)
|
| 952 |
-
for k, v in ckpt_sam3.items()
|
| 953 |
-
}
|
| 954 |
-
res = sam3.load_state_dict(ckpt_sam3, strict=False)
|
| 955 |
-
if is_rank0():
|
| 956 |
-
logger.info(
|
| 957 |
-
f"[sam3] Overlaid trained SAM3 weights from {ckpt_path}: "
|
| 958 |
-
f"loaded={len(ckpt_sam3)}, missing={len(res.missing_keys)}, "
|
| 959 |
-
f"unexpected={len(res.unexpected_keys)}"
|
| 960 |
-
)
|
| 961 |
-
if res.missing_keys:
|
| 962 |
-
logger.info(f" missing sample: {res.missing_keys[:3]}")
|
| 963 |
-
if res.unexpected_keys:
|
| 964 |
-
logger.info(f" unexpected sample: {res.unexpected_keys[:3]}")
|
| 965 |
-
else:
|
| 966 |
-
if is_rank0():
|
| 967 |
-
logger.warning(
|
| 968 |
-
f"[sam3] No trained SAM3 weights in ckpt ({ckpt_path}); "
|
| 969 |
-
f"using fresh facebook/sam3."
|
| 970 |
-
)
|
| 971 |
-
del src_state, ckpt_sam3
|
| 972 |
-
|
| 973 |
-
return model, qwen_processor
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 977 |
-
# Main
|
| 978 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 979 |
-
def main():
|
| 980 |
-
ap = argparse.ArgumentParser()
|
| 981 |
-
ap.add_argument("--data_root", required=True)
|
| 982 |
-
ap.add_argument("--results_root", required=True)
|
| 983 |
-
ap.add_argument("--method", required=True)
|
| 984 |
-
ap.add_argument("--ckpt_path", required=True,
|
| 985 |
-
help="Trained Qwen3-VL+SAM3 checkpoint dir (e.g. .../checkpoint-23500)")
|
| 986 |
-
ap.add_argument("--sam3_model_path", required=False, default=None,
|
| 987 |
-
help="Original facebook/sam3 dir (provides topology + processor). "
|
| 988 |
-
"Optional — if the checkpoint at --ckpt_path bundles a sam3/ "
|
| 989 |
-
"subdir (modern training output), this flag can be omitted.")
|
| 990 |
-
ap.add_argument("--system_prompt", default="")
|
| 991 |
-
ap.add_argument("--system_prompt_file", default="")
|
| 992 |
-
ap.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"])
|
| 993 |
-
|
| 994 |
-
ap.add_argument("--batch_size", type=int, default=1)
|
| 995 |
-
ap.add_argument("--num_workers", type=int, default=0)
|
| 996 |
-
ap.add_argument("--seed", type=int, default=42)
|
| 997 |
-
ap.add_argument("--save_pred_masks", action="store_true")
|
| 998 |
-
ap.add_argument("--skip_existing_masks", action="store_true")
|
| 999 |
-
ap.add_argument("--save_vis", action="store_true")
|
| 1000 |
-
ap.add_argument("--skip_existing_vis", action="store_true")
|
| 1001 |
-
ap.add_argument("--tile_inference", action="store_true",
|
| 1002 |
-
help="Run sliding-window tiled inference and max-merge "
|
| 1003 |
-
"tile probabilities back to the original image.")
|
| 1004 |
-
ap.add_argument("--tile_size", type=int, default=512,
|
| 1005 |
-
help="Tile edge length in original-image pixels.")
|
| 1006 |
-
ap.add_argument("--tile_overlap", type=float, default=0.5,
|
| 1007 |
-
help="Tile overlap ratio in [0, 0.9].")
|
| 1008 |
-
ap.add_argument("--tile_batch_size", type=int, default=1,
|
| 1009 |
-
help="How many tiles from one image to run per model batch.")
|
| 1010 |
-
ap.add_argument("--mask_threshold", type=float, default=0.5,
|
| 1011 |
-
help="Probability threshold for final binary masks.")
|
| 1012 |
-
|
| 1013 |
-
# Must match the training-time --sam3_image_size value exactly. If the
|
| 1014 |
-
# checkpoint was trained at 2016, eval at 1008 will silently load wrong
|
| 1015 |
-
# FPN tensor shapes (or fail loudly, depending on the layer).
|
| 1016 |
-
ap.add_argument("--sam3_image_size", type=int, default=None,
|
| 1017 |
-
help="Override SAM3 vision input size to match training. "
|
| 1018 |
-
"Default None = use SAM3's bundled 1008. Pass the "
|
| 1019 |
-
"same value used in --sam3_image_size during training.")
|
| 1020 |
-
|
| 1021 |
-
args = ap.parse_args()
|
| 1022 |
-
|
| 1023 |
-
# ── Determinism — same ckpt + same DATA_ROOT must produce the same
|
| 1024 |
-
# pred mask and the same metrics, regardless of world_size or run
|
| 1025 |
-
# count. Disable cudnn benchmark/TF32 to keep matmul outputs bitwise
|
| 1026 |
-
# stable across runs on the same GPU.
|
| 1027 |
-
import random as _random
|
| 1028 |
-
_random.seed(int(args.seed))
|
| 1029 |
-
np.random.seed(int(args.seed))
|
| 1030 |
-
torch.manual_seed(int(args.seed))
|
| 1031 |
-
if torch.cuda.is_available():
|
| 1032 |
-
torch.cuda.manual_seed_all(int(args.seed))
|
| 1033 |
-
torch.backends.cudnn.benchmark = False
|
| 1034 |
-
torch.backends.cudnn.deterministic = True
|
| 1035 |
-
try:
|
| 1036 |
-
torch.backends.cuda.matmul.allow_tf32 = False
|
| 1037 |
-
torch.backends.cudnn.allow_tf32 = False
|
| 1038 |
-
except Exception:
|
| 1039 |
-
pass
|
| 1040 |
-
|
| 1041 |
-
# Distributed init
|
| 1042 |
-
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
| 1043 |
-
dist.init_process_group(backend="nccl", init_method="env://")
|
| 1044 |
-
torch.cuda.set_device(get_local_rank_env())
|
| 1045 |
-
device = torch.device("cuda", get_local_rank_env())
|
| 1046 |
-
else:
|
| 1047 |
-
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 1048 |
-
|
| 1049 |
-
log_dir = os.path.join(args.results_root, "_logs", args.method)
|
| 1050 |
-
logger = setup_logger(log_dir)
|
| 1051 |
-
|
| 1052 |
-
# System prompt — use repo default if neither flag is given (training default)
|
| 1053 |
-
if args.system_prompt_file:
|
| 1054 |
-
with open(args.system_prompt_file, "r", encoding="utf-8") as f:
|
| 1055 |
-
system_prompt = f.read()
|
| 1056 |
-
elif args.system_prompt:
|
| 1057 |
-
system_prompt = args.system_prompt
|
| 1058 |
-
else:
|
| 1059 |
-
system_prompt = DEFAULT_MED_SEG_SYSTEM_PROMPT
|
| 1060 |
-
|
| 1061 |
-
# Resolve SAM3 source: prefer bundled <ckpt>/sam3/ subdir, fall back to
|
| 1062 |
-
# the explicit --sam3_model_path. This lets new ckpts be self-contained.
|
| 1063 |
-
bundled_sam3 = os.path.join(args.ckpt_path, "sam3")
|
| 1064 |
-
if os.path.isfile(os.path.join(bundled_sam3, "config.json")):
|
| 1065 |
-
sam3_source = bundled_sam3
|
| 1066 |
-
elif args.sam3_model_path:
|
| 1067 |
-
sam3_source = args.sam3_model_path
|
| 1068 |
-
else:
|
| 1069 |
-
raise SystemExit(
|
| 1070 |
-
f"SAM3 metadata not found. Either pass --sam3_model_path, or use a "
|
| 1071 |
-
f"checkpoint that bundles config.json under {bundled_sam3}."
|
| 1072 |
-
)
|
| 1073 |
-
|
| 1074 |
-
if is_rank0():
|
| 1075 |
-
logger.info(f"data_root={args.data_root}")
|
| 1076 |
-
logger.info(f"results_root={args.results_root}")
|
| 1077 |
-
logger.info(f"method={args.method}")
|
| 1078 |
-
logger.info(f"ckpt={args.ckpt_path}")
|
| 1079 |
-
logger.info(f"sam3={sam3_source}")
|
| 1080 |
-
logger.info(f"device={device}")
|
| 1081 |
-
logger.info(f"dtype={args.dtype}")
|
| 1082 |
-
|
| 1083 |
-
dtype_map = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}
|
| 1084 |
-
dtype = dtype_map[args.dtype]
|
| 1085 |
-
|
| 1086 |
-
model, qwen_processor = build_model(
|
| 1087 |
-
ckpt_path=args.ckpt_path,
|
| 1088 |
-
sam3_model_path=sam3_source,
|
| 1089 |
-
dtype=dtype,
|
| 1090 |
-
device=device,
|
| 1091 |
-
logger=logger,
|
| 1092 |
-
sam3_image_size=args.sam3_image_size,
|
| 1093 |
-
)
|
| 1094 |
-
sam3_processor = Sam3Processor.from_pretrained(sam3_source)
|
| 1095 |
-
if args.sam3_image_size:
|
| 1096 |
-
patch_sam3_processor_for_image_size(sam3_processor, int(args.sam3_image_size))
|
| 1097 |
-
|
| 1098 |
-
# Cache native Qwen3-VL token IDs for user_text_mask construction.
|
| 1099 |
-
tokenizer = qwen_processor.tokenizer
|
| 1100 |
-
|
| 1101 |
-
def _lookup_token(token: str) -> Optional[int]:
|
| 1102 |
-
try:
|
| 1103 |
-
tid = tokenizer.convert_tokens_to_ids(token)
|
| 1104 |
-
tid = int(tid) if tid is not None else None
|
| 1105 |
-
return tid if tid is not None and tid >= 0 else None
|
| 1106 |
-
except Exception:
|
| 1107 |
-
return None
|
| 1108 |
-
|
| 1109 |
-
qwen_image_pad_id = _lookup_token("<|image_pad|>")
|
| 1110 |
-
qwen_vision_start_id = _lookup_token("<|vision_start|>")
|
| 1111 |
-
pad_token_id = int(getattr(tokenizer, "pad_token_id", 0) or 0)
|
| 1112 |
-
|
| 1113 |
-
if is_rank0():
|
| 1114 |
-
logger.info(
|
| 1115 |
-
f"[tok] image_pad_id={qwen_image_pad_id}, "
|
| 1116 |
-
f"vision_start_id={qwen_vision_start_id}, "
|
| 1117 |
-
f"pad_token_id={pad_token_id}"
|
| 1118 |
-
)
|
| 1119 |
-
|
| 1120 |
-
# Discover test.json units (rank0) and broadcast
|
| 1121 |
-
test_jsons = discover_all_test_jsons(args.data_root) if is_rank0() else []
|
| 1122 |
-
test_jsons = broadcast_object_list_py(test_jsons)
|
| 1123 |
-
if is_rank0():
|
| 1124 |
-
logger.info(f"Found {len(test_jsons)} test.json files under {args.data_root}")
|
| 1125 |
-
dist_barrier()
|
| 1126 |
-
|
| 1127 |
-
# ── Per-unit loop ──
|
| 1128 |
-
for test_json_path in test_jsons:
|
| 1129 |
-
unit_dir = os.path.dirname(os.path.abspath(test_json_path))
|
| 1130 |
-
rel_dir = os.path.relpath(unit_dir, os.path.abspath(args.data_root))
|
| 1131 |
-
rel_dir = "." if rel_dir == os.curdir else rel_dir
|
| 1132 |
-
|
| 1133 |
-
dest_dir = os.path.join(args.results_root, rel_dir)
|
| 1134 |
-
os.makedirs(dest_dir, exist_ok=True)
|
| 1135 |
-
|
| 1136 |
-
tag = rel_dir.replace(os.sep, "_") if rel_dir != "." else Path(args.data_root).name
|
| 1137 |
-
metrics_path = os.path.join(dest_dir, f"{args.method}_{tag}_dataset_metrics.json")
|
| 1138 |
-
|
| 1139 |
-
if is_rank0():
|
| 1140 |
-
logger.info("=" * 80)
|
| 1141 |
-
logger.info(f"[Dataset] test_json={test_json_path}")
|
| 1142 |
-
logger.info(f"[Dataset] dest_dir={dest_dir}")
|
| 1143 |
-
logger.info(f"[Dataset] metrics_path={metrics_path}")
|
| 1144 |
-
dist_barrier()
|
| 1145 |
-
|
| 1146 |
-
samples = parse_test_json_to_samples(test_json_path, args.data_root, logger)
|
| 1147 |
-
ds = GroundingInferDataset(samples)
|
| 1148 |
-
|
| 1149 |
-
sampler = DistributedSampler(ds, shuffle=False, drop_last=False) if dist_is_ready() else None
|
| 1150 |
-
dl = DataLoader(
|
| 1151 |
-
ds,
|
| 1152 |
-
batch_size=max(1, int(args.batch_size)),
|
| 1153 |
-
sampler=sampler,
|
| 1154 |
-
shuffle=False,
|
| 1155 |
-
num_workers=int(args.num_workers),
|
| 1156 |
-
pin_memory=True,
|
| 1157 |
-
collate_fn=collate_samples,
|
| 1158 |
-
)
|
| 1159 |
-
|
| 1160 |
-
local_results: List[Dict[str, Any]] = []
|
| 1161 |
-
pred_root = os.path.join(dest_dir, "pred_masks")
|
| 1162 |
-
vis_root = os.path.join(dest_dir, "vis")
|
| 1163 |
-
|
| 1164 |
-
for batch in dl:
|
| 1165 |
-
# Skip-on-existing — only takes effect if every output file for the
|
| 1166 |
-
# batch already exists; otherwise we re-run inference (cheaper than
|
| 1167 |
-
# diffing per-sample status).
|
| 1168 |
-
if args.save_pred_masks and args.skip_existing_masks and not args.save_vis:
|
| 1169 |
-
if all(os.path.exists(os.path.join(pred_root, s.save_rel)) for s in batch):
|
| 1170 |
-
continue
|
| 1171 |
-
if args.save_vis and args.skip_existing_vis and not args.save_pred_masks:
|
| 1172 |
-
if all(os.path.exists(os.path.join(vis_root, s.save_rel)) for s in batch):
|
| 1173 |
-
continue
|
| 1174 |
-
if (args.save_pred_masks and args.skip_existing_masks
|
| 1175 |
-
and args.save_vis and args.skip_existing_vis):
|
| 1176 |
-
if (all(os.path.exists(os.path.join(pred_root, s.save_rel)) for s in batch)
|
| 1177 |
-
and all(os.path.exists(os.path.join(vis_root, s.save_rel)) for s in batch)):
|
| 1178 |
-
continue
|
| 1179 |
-
|
| 1180 |
-
try:
|
| 1181 |
-
infer_kwargs = dict(
|
| 1182 |
-
model=model,
|
| 1183 |
-
qwen_processor=qwen_processor,
|
| 1184 |
-
sam3_processor=sam3_processor,
|
| 1185 |
-
system_prompt=system_prompt,
|
| 1186 |
-
batch=batch,
|
| 1187 |
-
device=device,
|
| 1188 |
-
model_dtype=dtype,
|
| 1189 |
-
qwen_image_pad_id=qwen_image_pad_id,
|
| 1190 |
-
qwen_vision_start_id=qwen_vision_start_id,
|
| 1191 |
-
pad_token_id=pad_token_id,
|
| 1192 |
-
mask_threshold=float(args.mask_threshold),
|
| 1193 |
-
)
|
| 1194 |
-
if args.tile_inference:
|
| 1195 |
-
preds, pil_images = infer_batch_tiled(
|
| 1196 |
-
**infer_kwargs,
|
| 1197 |
-
tile_size=int(args.tile_size),
|
| 1198 |
-
tile_overlap=float(args.tile_overlap),
|
| 1199 |
-
tile_batch_size=int(args.tile_batch_size),
|
| 1200 |
-
)
|
| 1201 |
-
else:
|
| 1202 |
-
preds, pil_images = infer_batch(**infer_kwargs)
|
| 1203 |
-
except Exception as e:
|
| 1204 |
-
logger.warning(f"[infer] batch failed ({len(batch)} samples): {e}")
|
| 1205 |
-
continue
|
| 1206 |
-
|
| 1207 |
-
for s, pred_bool, pil_img in zip(batch, preds, pil_images):
|
| 1208 |
-
try:
|
| 1209 |
-
gt_bool = load_mask_bool(s.mask_path)
|
| 1210 |
-
except Exception:
|
| 1211 |
-
continue
|
| 1212 |
-
|
| 1213 |
-
if args.save_pred_masks:
|
| 1214 |
-
save_pred_mask_png(os.path.join(pred_root, s.save_rel), pred_bool)
|
| 1215 |
-
if args.save_vis:
|
| 1216 |
-
save_vis_overlay_png(os.path.join(vis_root, s.save_rel), pil_img, pred_bool)
|
| 1217 |
-
|
| 1218 |
-
iou, dice, I, U, pA, _gA = calc_iou_dice(pred_bool, gt_bool)
|
| 1219 |
-
local_results.append({
|
| 1220 |
-
"metadata": s.metadata,
|
| 1221 |
-
"IoU": [float(iou)],
|
| 1222 |
-
"Dice": [float(dice)],
|
| 1223 |
-
"I": [int(I)],
|
| 1224 |
-
"U": [int(U)],
|
| 1225 |
-
"IoU_box": "",
|
| 1226 |
-
"pred_area": [int(pA)],
|
| 1227 |
-
})
|
| 1228 |
-
|
| 1229 |
-
# Gather → rank 0 writes JSON
|
| 1230 |
-
if dist_is_ready():
|
| 1231 |
-
gathered = [None for _ in range(dist.get_world_size())] if is_rank0() else None
|
| 1232 |
-
dist.gather_object(local_results, gathered, dst=0)
|
| 1233 |
-
if is_rank0():
|
| 1234 |
-
all_results: List[Dict[str, Any]] = []
|
| 1235 |
-
for part in gathered:
|
| 1236 |
-
if part:
|
| 1237 |
-
all_results.extend(part)
|
| 1238 |
-
else:
|
| 1239 |
-
all_results = []
|
| 1240 |
-
else:
|
| 1241 |
-
all_results = local_results
|
| 1242 |
-
|
| 1243 |
-
if is_rank0():
|
| 1244 |
-
def _stable_key(x):
|
| 1245 |
-
"""Stable per-instance identity for dedup + sort.
|
| 1246 |
-
|
| 1247 |
-
DistributedSampler(drop_last=False) pads the tail by repeating
|
| 1248 |
-
samples so every rank gets the same length. Without dedup the
|
| 1249 |
-
metric would double-count those samples and depend on
|
| 1250 |
-
world_size. Use (image_id, ref_id, mask_file) — the triple is
|
| 1251 |
-
unique across the corpus.
|
| 1252 |
-
"""
|
| 1253 |
-
meta = x.get("metadata", {})
|
| 1254 |
-
iid = int(meta.get("image_id", 0) or 0)
|
| 1255 |
-
gi = (meta.get("grounding_info") or [{}])[0]
|
| 1256 |
-
rid = int(gi.get("ref_id", gi.get("ann_id", gi.get("id", 0))) or 0)
|
| 1257 |
-
mfile = str(gi.get("mask_file", ""))
|
| 1258 |
-
return (iid, rid, mfile)
|
| 1259 |
-
|
| 1260 |
-
seen = set()
|
| 1261 |
-
deduped: List[Dict[str, Any]] = []
|
| 1262 |
-
for r in all_results:
|
| 1263 |
-
k = _stable_key(r)
|
| 1264 |
-
if k in seen:
|
| 1265 |
-
continue
|
| 1266 |
-
seen.add(k)
|
| 1267 |
-
deduped.append(r)
|
| 1268 |
-
n_dropped = len(all_results) - len(deduped)
|
| 1269 |
-
if n_dropped > 0:
|
| 1270 |
-
logger.info(
|
| 1271 |
-
f"[Dedup] dropped {n_dropped} duplicate instance(s) "
|
| 1272 |
-
f"introduced by DistributedSampler tail-padding"
|
| 1273 |
-
)
|
| 1274 |
-
all_results = deduped
|
| 1275 |
-
all_results.sort(key=_stable_key)
|
| 1276 |
-
scores = summarize_scores(all_results)
|
| 1277 |
-
key = f"biomed_{tag}/grounding"
|
| 1278 |
-
out_obj = {
|
| 1279 |
-
key: {
|
| 1280 |
-
"grounding": {
|
| 1281 |
-
"scores": scores,
|
| 1282 |
-
"instance_results": all_results,
|
| 1283 |
-
}
|
| 1284 |
-
}
|
| 1285 |
-
}
|
| 1286 |
-
save_json(metrics_path, out_obj)
|
| 1287 |
-
logger.info(f"[Write] {metrics_path} (instances={len(all_results)})")
|
| 1288 |
-
logger.info(f"[Scores] {scores}")
|
| 1289 |
-
|
| 1290 |
-
dist_barrier()
|
| 1291 |
-
|
| 1292 |
-
if is_rank0():
|
| 1293 |
-
logger.info("All datasets done.")
|
| 1294 |
-
dist_barrier()
|
| 1295 |
-
if dist_is_ready():
|
| 1296 |
-
dist.destroy_process_group()
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
if __name__ == "__main__":
|
| 1300 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/scripts/weather/infer_forecast.py
DELETED
|
@@ -1,408 +0,0 @@
|
|
| 1 |
-
"""Autoregressive forecast rollout aligned with our training engine.
|
| 2 |
-
|
| 3 |
-
Mirrors the per-step state advancement of
|
| 4 |
-
``qwenvl.modalities.weather.rollout.WeatherRolloutEngine``:
|
| 5 |
-
|
| 6 |
-
* ``step_idx`` recurs as the actual rollout step (0, 1, 2, ...). This
|
| 7 |
-
drives both ``hour``/``doy`` (via ``cur_times = times + lead·step_idx``)
|
| 8 |
-
AND ``step_scalar = log1p(step_idx) / log1p(Tmax)`` inside
|
| 9 |
-
``WeatherEncoder.get_condition`` — exactly what the model has been
|
| 10 |
-
trained against.
|
| 11 |
-
* ``times`` (the init time) stays fixed; ``cur_times`` is derived per
|
| 12 |
-
step from ``step_idx``.
|
| 13 |
-
* ``lead_hours`` stays fixed at the training-time per-step interval
|
| 14 |
-
(typically 6 h). ``effective_hour = lead_hours * (step_idx + 1)``.
|
| 15 |
-
* ``cur_values`` slides: drop the oldest historical frame, append the
|
| 16 |
-
model's previous prediction.
|
| 17 |
-
|
| 18 |
-
Important: Polaris's bundled ``infer_forecast.py`` uses ``step_idx=0``
|
| 19 |
-
forever and advances ``times`` instead. That convention is *not* what
|
| 20 |
-
this checkpoint was trained on — using it would feed the model a
|
| 21 |
-
``step_scalar=0`` it never saw at training time. Use this script for
|
| 22 |
-
the weather checkpoint.
|
| 23 |
-
|
| 24 |
-
Inputs
|
| 25 |
-
------
|
| 26 |
-
``--input``: a netCDF or zarr path with a single ``[T_in, C, H, W]`` (or
|
| 27 |
-
``[C, H, W]``) tensor named after channels in the same order the model
|
| 28 |
-
was trained on. ``T_in`` must equal ``ERA5_HIST_FRAMES`` (default 1).
|
| 29 |
-
``--init_time``: ISO timestamp of the input frame; falls back to the
|
| 30 |
-
``time`` attribute on the netCDF.
|
| 31 |
-
|
| 32 |
-
Outputs
|
| 33 |
-
-------
|
| 34 |
-
For every rollout step, saves ``<save_dir>/<effective_hour>03dh.nc`` with
|
| 35 |
-
the unnormalized prediction over all channels. ``--max_lead_hour`` and
|
| 36 |
-
``--lead_step_hours`` together determine how many rollout steps to run
|
| 37 |
-
(``ceil(max_lead_hour / lead_step_hours)``).
|
| 38 |
-
"""
|
| 39 |
-
|
| 40 |
-
from __future__ import annotations
|
| 41 |
-
|
| 42 |
-
import argparse
|
| 43 |
-
import os
|
| 44 |
-
import sys
|
| 45 |
-
import time
|
| 46 |
-
from typing import List, Optional
|
| 47 |
-
|
| 48 |
-
import numpy as np
|
| 49 |
-
import pandas as pd
|
| 50 |
-
import torch
|
| 51 |
-
import xarray as xr
|
| 52 |
-
|
| 53 |
-
# Ensure repo root is on PYTHONPATH so this script is runnable directly.
|
| 54 |
-
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 55 |
-
if _REPO_ROOT not in sys.path:
|
| 56 |
-
sys.path.insert(0, _REPO_ROOT)
|
| 57 |
-
|
| 58 |
-
from transformers import AutoProcessor
|
| 59 |
-
|
| 60 |
-
from qwenvl.models.modeling_bio_qwen3_vl import Qwen3VLForConditionalGeneration
|
| 61 |
-
from qwenvl.modalities.weather.data import load_meteorological_buffers
|
| 62 |
-
from qwenvl.modalities.weather.data.era5_dataset import preprocess_meteo_chat
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
# ---------------------------------------------------------------------------
|
| 66 |
-
# Argument parsing
|
| 67 |
-
# ---------------------------------------------------------------------------
|
| 68 |
-
|
| 69 |
-
def _parse_args() -> argparse.Namespace:
|
| 70 |
-
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 71 |
-
|
| 72 |
-
parser.add_argument("--checkpoint", required=True,
|
| 73 |
-
help="Path to a trained bio_qwen3vl_weather checkpoint (the directory "
|
| 74 |
-
"with config.json + safetensors / deepspeed shards).")
|
| 75 |
-
parser.add_argument("--era5_data_path", required=True,
|
| 76 |
-
help="ERA5 zarr path used during training. Provides mean/std/weight "
|
| 77 |
-
"buffers via load_meteorological_buffers; must match training.")
|
| 78 |
-
parser.add_argument("--input", required=True,
|
| 79 |
-
help="netCDF (.nc) input frame: [T_in, C, H, W] or [C, H, W].")
|
| 80 |
-
parser.add_argument("--save_dir", default="outputs/forecast",
|
| 81 |
-
help="Output directory for <effective_hour>h.nc files.")
|
| 82 |
-
|
| 83 |
-
parser.add_argument("--init_time", default=None,
|
| 84 |
-
help="ISO timestamp of the input frame, e.g. '2023-07-01T00:00'. "
|
| 85 |
-
"If omitted, falls back to input_da.attrs['time'] or the last "
|
| 86 |
-
"time coord on input_da.")
|
| 87 |
-
parser.add_argument("--max_lead_hour", type=int, default=240,
|
| 88 |
-
help="Maximum forecast lead time (hours).")
|
| 89 |
-
parser.add_argument("--lead_step_hours", type=int, default=6,
|
| 90 |
-
help="Hours per rollout step. Must match training (default 6).")
|
| 91 |
-
|
| 92 |
-
parser.add_argument("--era5_image_size", type=int, nargs=2, default=[721, 1440])
|
| 93 |
-
parser.add_argument("--era5_latlon_range", type=float, nargs=4, default=None,
|
| 94 |
-
metavar=("LAT_MIN", "LAT_MAX", "LON_MIN", "LON_MAX"))
|
| 95 |
-
parser.add_argument("--remove_channels", type=str, nargs="*", default=None,
|
| 96 |
-
help="Channels removed at training time. MUST match training: "
|
| 97 |
-
"the encoder mean/std buffers depend on this.")
|
| 98 |
-
|
| 99 |
-
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 100 |
-
parser.add_argument("--dtype", choices=["bf16", "fp16", "fp32"], default="bf16")
|
| 101 |
-
parser.add_argument("--attn_implementation", default="flash_attention_2",
|
| 102 |
-
choices=["flash_attention_2", "sdpa", "eager"])
|
| 103 |
-
parser.add_argument("--save_channels", type=str, nargs="*", default=None,
|
| 104 |
-
help="If set, only save these channels in each output .nc. "
|
| 105 |
-
"Default: save all channels.")
|
| 106 |
-
|
| 107 |
-
parser.add_argument("--load_ema", action="store_true",
|
| 108 |
-
help="If set, load weather_ema_weights.pt from the checkpoint dir "
|
| 109 |
-
"(written by WeatherEMACallback) and overlay onto the model "
|
| 110 |
-
"BEFORE rollout. Improves stability/quality of the prediction; "
|
| 111 |
-
"no effect if the file is missing.")
|
| 112 |
-
return parser.parse_args()
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# ---------------------------------------------------------------------------
|
| 116 |
-
# Helpers
|
| 117 |
-
# ---------------------------------------------------------------------------
|
| 118 |
-
|
| 119 |
-
def _resolve_dtype(name: str) -> torch.dtype:
|
| 120 |
-
return {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[name]
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def _load_input(input_path: str, init_time_arg: Optional[str]):
|
| 124 |
-
"""Read a netCDF and return (init_field np.float32, init_time, channel_names, lats, lons).
|
| 125 |
-
|
| 126 |
-
init_field shape: ``[T_in, C, H, W]`` (T_in inferred from the file).
|
| 127 |
-
"""
|
| 128 |
-
if input_path.endswith(".zarr") or os.path.isdir(input_path):
|
| 129 |
-
da = xr.open_zarr(input_path)
|
| 130 |
-
else:
|
| 131 |
-
da = xr.open_dataarray(input_path)
|
| 132 |
-
|
| 133 |
-
# When opened as a Dataset (zarr), pull the first variable.
|
| 134 |
-
if hasattr(da, "data_vars"):
|
| 135 |
-
var = list(da.data_vars)[0]
|
| 136 |
-
da = da[var]
|
| 137 |
-
|
| 138 |
-
if "time" in da.dims:
|
| 139 |
-
if init_time_arg is not None:
|
| 140 |
-
init_time = pd.to_datetime(init_time_arg)
|
| 141 |
-
da = da.sel(time=init_time, method="nearest")
|
| 142 |
-
init_field = da.values
|
| 143 |
-
else:
|
| 144 |
-
init_time = pd.to_datetime(da.time.values[-1])
|
| 145 |
-
init_field = da.isel(time=-1).values
|
| 146 |
-
else:
|
| 147 |
-
init_time = pd.to_datetime(init_time_arg or da.attrs.get("time", "2023-07-01"))
|
| 148 |
-
init_field = da.values
|
| 149 |
-
|
| 150 |
-
init_field = init_field.astype(np.float32)
|
| 151 |
-
if init_field.ndim == 3:
|
| 152 |
-
init_field = init_field[None] # → [T_in=1, C, H, W]
|
| 153 |
-
|
| 154 |
-
channels = [str(c) for c in da.channel.values] if "channel" in da.coords else None
|
| 155 |
-
lats = da.lat.values if "lat" in da.coords else None
|
| 156 |
-
lons = da.lon.values if "lon" in da.coords else None
|
| 157 |
-
|
| 158 |
-
return init_field, init_time, channels, lats, lons
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def _compute_meteo_num_tokens(weather_config) -> int:
|
| 162 |
-
in_h, in_w = weather_config.image_size if isinstance(weather_config.image_size, (list, tuple)) \
|
| 163 |
-
else (weather_config.image_size, weather_config.image_size)
|
| 164 |
-
ps = weather_config.patch_size
|
| 165 |
-
swin_h = in_h // 2 * 2 if ps == 1 else in_h // ps
|
| 166 |
-
swin_w = in_w if ps == 1 else in_w // ps
|
| 167 |
-
return swin_h * swin_w
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def _build_chat_inputs(processor, lead_hours: int, num_tokens: int, weather_token_ids, device):
|
| 171 |
-
"""Construct ``input_ids`` / ``attention_mask`` matching the dataset's
|
| 172 |
-
chat template so the LLM sees the same prompt structure used at training."""
|
| 173 |
-
pad_str = processor.tokenizer.convert_ids_to_tokens(int(weather_token_ids["pad"]))
|
| 174 |
-
start_str = processor.tokenizer.convert_ids_to_tokens(int(weather_token_ids["start"]))
|
| 175 |
-
end_str = processor.tokenizer.convert_ids_to_tokens(int(weather_token_ids["end"]))
|
| 176 |
-
|
| 177 |
-
input_text = f"Predict global weather state {int(lead_hours)} hours ahead at a 0.25° resolution"
|
| 178 |
-
full = preprocess_meteo_chat(
|
| 179 |
-
input_text=input_text,
|
| 180 |
-
processor=processor,
|
| 181 |
-
meteo_pad_token=pad_str,
|
| 182 |
-
weather_start_token=start_str,
|
| 183 |
-
weather_end_token=end_str,
|
| 184 |
-
meteo_num_tokens=num_tokens,
|
| 185 |
-
add_assistant_prompt=True,
|
| 186 |
-
)
|
| 187 |
-
input_ids = full["input_ids"].to(device)
|
| 188 |
-
attention_mask = input_ids.ne(processor.tokenizer.pad_token_id).to(device)
|
| 189 |
-
return input_ids, attention_mask
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def _save_step(output_unnorm: np.ndarray,
|
| 193 |
-
init_time, effective_hour: int,
|
| 194 |
-
channels, lats, lons,
|
| 195 |
-
save_dir: str,
|
| 196 |
-
keep_channels: Optional[List[str]]):
|
| 197 |
-
if channels is not None and keep_channels is not None:
|
| 198 |
-
idx = [channels.index(c) for c in keep_channels if c in channels]
|
| 199 |
-
if idx:
|
| 200 |
-
output_unnorm = output_unnorm[idx]
|
| 201 |
-
channels = [channels[i] for i in idx]
|
| 202 |
-
|
| 203 |
-
fcst_time = pd.to_datetime(init_time) + pd.Timedelta(hours=int(effective_hour))
|
| 204 |
-
da = xr.DataArray(
|
| 205 |
-
output_unnorm,
|
| 206 |
-
dims=["channel", "lat", "lon"],
|
| 207 |
-
coords=dict(
|
| 208 |
-
channel=channels if channels is not None else np.arange(output_unnorm.shape[0]),
|
| 209 |
-
lat=lats if lats is not None else np.arange(output_unnorm.shape[1]),
|
| 210 |
-
lon=lons if lons is not None else np.arange(output_unnorm.shape[2]),
|
| 211 |
-
),
|
| 212 |
-
attrs=dict(
|
| 213 |
-
init_time=str(init_time),
|
| 214 |
-
fcst_time=str(fcst_time),
|
| 215 |
-
lead_hour=int(effective_hour),
|
| 216 |
-
),
|
| 217 |
-
)
|
| 218 |
-
fname = os.path.join(save_dir, f"{int(effective_hour):03d}h.nc")
|
| 219 |
-
da.to_netcdf(fname)
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
# ---------------------------------------------------------------------------
|
| 223 |
-
# Main rollout
|
| 224 |
-
# ---------------------------------------------------------------------------
|
| 225 |
-
|
| 226 |
-
@torch.no_grad()
|
| 227 |
-
def run_inference(args: argparse.Namespace) -> None:
|
| 228 |
-
device = torch.device(args.device)
|
| 229 |
-
dtype = _resolve_dtype(args.dtype)
|
| 230 |
-
|
| 231 |
-
# ── Load model + processor ────────────────────────────────────────
|
| 232 |
-
print(f"[load] checkpoint = {args.checkpoint}", flush=True)
|
| 233 |
-
model = Qwen3VLForConditionalGeneration.from_pretrained(
|
| 234 |
-
args.checkpoint,
|
| 235 |
-
dtype=dtype,
|
| 236 |
-
attn_implementation=args.attn_implementation,
|
| 237 |
-
).to(device)
|
| 238 |
-
model.eval()
|
| 239 |
-
processor = AutoProcessor.from_pretrained(args.checkpoint, use_fast=False)
|
| 240 |
-
|
| 241 |
-
inner = model.model
|
| 242 |
-
router = inner.modality_router
|
| 243 |
-
if "weather" not in router.encoders:
|
| 244 |
-
raise RuntimeError("checkpoint has no weather modality registered")
|
| 245 |
-
encoder = router.encoders["weather"]
|
| 246 |
-
decoder = router.decoders["weather"]
|
| 247 |
-
|
| 248 |
-
# Optional EMA overlay: WeatherEMACallback drops a sidecar
|
| 249 |
-
# ``weather_ema_weights.pt`` next to each checkpoint with fp32 EMA
|
| 250 |
-
# tensors keyed by the live model namespace. Apply *after*
|
| 251 |
-
# from_pretrained finished so the EMA weights win.
|
| 252 |
-
if args.load_ema:
|
| 253 |
-
ema_path = os.path.join(args.checkpoint, "weather_ema_weights.pt")
|
| 254 |
-
if not os.path.isfile(ema_path):
|
| 255 |
-
print(f"[load] --load_ema set but {ema_path} not found, "
|
| 256 |
-
f"falling back to main weights", flush=True)
|
| 257 |
-
else:
|
| 258 |
-
ema_state = torch.load(ema_path, map_location="cpu")
|
| 259 |
-
ema_overlay = {}
|
| 260 |
-
for k, v in ema_state.items():
|
| 261 |
-
if not (
|
| 262 |
-
k.startswith("model.modality_router.encoders.weather.")
|
| 263 |
-
or k.startswith("model.modality_router.decoders.weather.")
|
| 264 |
-
):
|
| 265 |
-
continue
|
| 266 |
-
ema_overlay[k] = (
|
| 267 |
-
v.to(device=device, dtype=dtype)
|
| 268 |
-
if v.is_floating_point()
|
| 269 |
-
else v.to(device=device)
|
| 270 |
-
)
|
| 271 |
-
result = model.load_state_dict(ema_overlay, strict=False)
|
| 272 |
-
# Note: ``missing_keys`` here is huge — it lists every model
|
| 273 |
-
# parameter that wasn't in the EMA overlay (i.e. Qwen3 LLM
|
| 274 |
-
# weights, vision tower, etc.). That's expected; the EMA file
|
| 275 |
-
# only stores weather params. We only flag *unexpected* keys
|
| 276 |
-
# (EMA tensors that didn't match a real model parameter) since
|
| 277 |
-
# those signal a real namespace drift.
|
| 278 |
-
print(
|
| 279 |
-
f"[load] EMA overlay: {len(ema_overlay)} weather tensors applied; "
|
| 280 |
-
f"unexpected_keys={len(result.unexpected_keys)}",
|
| 281 |
-
flush=True,
|
| 282 |
-
)
|
| 283 |
-
if result.unexpected_keys:
|
| 284 |
-
print(
|
| 285 |
-
f"[load] WARNING — EMA had keys the model didn't accept: "
|
| 286 |
-
f"{result.unexpected_keys[:5]}...",
|
| 287 |
-
flush=True,
|
| 288 |
-
)
|
| 289 |
-
|
| 290 |
-
weather_token_ids = (model.config.bio_token_ids or {}).get("weather")
|
| 291 |
-
if weather_token_ids is None or "pad" not in weather_token_ids:
|
| 292 |
-
raise RuntimeError("config.bio_token_ids['weather'] missing — check checkpoint config")
|
| 293 |
-
|
| 294 |
-
# ── Inject ERA5 statistics (must match training) ──────────────────
|
| 295 |
-
print(f"[load] ERA5 buffers from {args.era5_data_path}", flush=True)
|
| 296 |
-
ch, idx, coords, buffers = load_meteorological_buffers(
|
| 297 |
-
data_path=args.era5_data_path,
|
| 298 |
-
image_size=tuple(args.era5_image_size),
|
| 299 |
-
latlon_range=tuple(args.era5_latlon_range) if args.era5_latlon_range else None,
|
| 300 |
-
remove_channels=args.remove_channels,
|
| 301 |
-
)
|
| 302 |
-
encoder.inject_meteorological_context(ch, idx, coords, buffers)
|
| 303 |
-
|
| 304 |
-
# ── Read initial field ────────────────────────────────────────────
|
| 305 |
-
init_field, init_time, in_channels, in_lats, in_lons = _load_input(args.input, args.init_time)
|
| 306 |
-
print(f"[load] input field shape={init_field.shape}, init_time={init_time}", flush=True)
|
| 307 |
-
|
| 308 |
-
# Use coords from the input file when present; otherwise fall back to
|
| 309 |
-
# the buffer coords (which always cover the global 0.25° grid).
|
| 310 |
-
out_channels = in_channels if in_channels is not None else ch
|
| 311 |
-
out_lats = in_lats if in_lats is not None else np.array(coords["lat"])
|
| 312 |
-
out_lons = in_lons if in_lons is not None else np.array(coords["lon"])
|
| 313 |
-
|
| 314 |
-
# Sanity check: input channel ordering must match the ERA5 buffers,
|
| 315 |
-
# otherwise mean/std normalisation pairs the wrong stats with the
|
| 316 |
-
# wrong variable. The pipeline trusts positional ordering — there
|
| 317 |
-
# is no automatic reorder.
|
| 318 |
-
if in_channels is not None:
|
| 319 |
-
if list(in_channels) != list(ch):
|
| 320 |
-
print(
|
| 321 |
-
f"[load] WARNING — input channel ordering differs from "
|
| 322 |
-
f"ERA5 buffer ordering. Predictions will be garbage.\n"
|
| 323 |
-
f" input first 5: {list(in_channels)[:5]}\n"
|
| 324 |
-
f" buffer first 5: {list(ch)[:5]}\n"
|
| 325 |
-
f" Reorder your input nc to match, or strip channel coord "
|
| 326 |
-
f"from the input.",
|
| 327 |
-
flush=True,
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
# ── Set up cur_values: same normalisation path as encoder.forward ──
|
| 331 |
-
cur_values = torch.from_numpy(init_field).to(device)
|
| 332 |
-
cur_values = torch.nan_to_num(cur_values).unsqueeze(0) # add batch dim → [1, T_in, C, H, W]
|
| 333 |
-
cur_values = cur_values.to(torch.float32)
|
| 334 |
-
cur_values = encoder._reset_input(cur_values)
|
| 335 |
-
|
| 336 |
-
# ── Chat tokens (built once: structure doesn't change per step) ───
|
| 337 |
-
weather_config = model.config.weather_config
|
| 338 |
-
num_tokens = _compute_meteo_num_tokens(weather_config)
|
| 339 |
-
input_ids, attention_mask = _build_chat_inputs(
|
| 340 |
-
processor=processor,
|
| 341 |
-
lead_hours=int(args.lead_step_hours),
|
| 342 |
-
num_tokens=num_tokens,
|
| 343 |
-
weather_token_ids=weather_token_ids,
|
| 344 |
-
device=device,
|
| 345 |
-
)
|
| 346 |
-
weather_input_ids = torch.ones(1, num_tokens, dtype=torch.long, device=device)
|
| 347 |
-
weather_attention_mask = torch.ones(1, num_tokens, dtype=torch.long, device=device)
|
| 348 |
-
weather_grid_thw = torch.tensor([[1, 1, num_tokens]], dtype=torch.long, device=device)
|
| 349 |
-
|
| 350 |
-
# ── Rollout ───────────────────────────────────────────────────────
|
| 351 |
-
n_steps = (args.max_lead_hour + args.lead_step_hours - 1) // args.lead_step_hours
|
| 352 |
-
print(f"[rollout] steps={n_steps}, lead_step={args.lead_step_hours}h, "
|
| 353 |
-
f"max_lead={args.max_lead_hour}h", flush=True)
|
| 354 |
-
|
| 355 |
-
os.makedirs(args.save_dir, exist_ok=True)
|
| 356 |
-
times_idx = pd.DatetimeIndex([init_time])
|
| 357 |
-
lead_hours_t = torch.tensor([float(args.lead_step_hours)], device=device, dtype=torch.float32)
|
| 358 |
-
|
| 359 |
-
t_start = time.perf_counter()
|
| 360 |
-
for t in range(n_steps):
|
| 361 |
-
effective_hour = int((t + 1) * args.lead_step_hours)
|
| 362 |
-
|
| 363 |
-
# Each forward call refreshes the encoder's internal cache.
|
| 364 |
-
outputs = inner(
|
| 365 |
-
input_ids=input_ids,
|
| 366 |
-
attention_mask=attention_mask,
|
| 367 |
-
weather_input_ids=weather_input_ids,
|
| 368 |
-
weather_attention_mask=weather_attention_mask,
|
| 369 |
-
weather_grid_thw=weather_grid_thw,
|
| 370 |
-
weather_meteo_values=cur_values,
|
| 371 |
-
weather_lead_hours=lead_hours_t,
|
| 372 |
-
weather_times=times_idx,
|
| 373 |
-
weather_step_idx=t, # ← critical: aligns with training
|
| 374 |
-
)
|
| 375 |
-
hidden = outputs[0]
|
| 376 |
-
|
| 377 |
-
next_frame = decoder.predict_from_hidden(
|
| 378 |
-
hidden,
|
| 379 |
-
input_ids=input_ids,
|
| 380 |
-
__weather_pad_id__=int(weather_token_ids["pad"]),
|
| 381 |
-
)
|
| 382 |
-
if next_frame is None:
|
| 383 |
-
raise RuntimeError(f"step {t}: predict_from_hidden returned None — check pad tokens.")
|
| 384 |
-
|
| 385 |
-
# next_frame is [B=1, C, H, W] in normalised space.
|
| 386 |
-
pred_unnorm = encoder.unnormalize(next_frame.unsqueeze(1)) # → [1, 1, C, H, W]
|
| 387 |
-
pred_np = pred_unnorm[0, 0].float().cpu().numpy()
|
| 388 |
-
|
| 389 |
-
_save_step(
|
| 390 |
-
pred_np, init_time, effective_hour,
|
| 391 |
-
channels=out_channels, lats=out_lats, lons=out_lons,
|
| 392 |
-
save_dir=args.save_dir, keep_channels=args.save_channels,
|
| 393 |
-
)
|
| 394 |
-
|
| 395 |
-
print(f" step {t + 1:>2}/{n_steps} effective {effective_hour:>3d}h "
|
| 396 |
-
f"range [{pred_np.min():.2f}, {pred_np.max():.2f}]", flush=True)
|
| 397 |
-
|
| 398 |
-
# Slide the input window: drop oldest historical frame, append
|
| 399 |
-
# this step's prediction (in normalised space, fp32). Mirrors
|
| 400 |
-
# WeatherRolloutEngine._slide_window.
|
| 401 |
-
next_norm = next_frame.to(torch.float32).unsqueeze(1) # [1, 1, C, H, W]
|
| 402 |
-
cur_values = torch.cat([cur_values[:, 1:], next_norm], dim=1)
|
| 403 |
-
|
| 404 |
-
print(f"[done] {time.perf_counter() - t_start:.1f}s, saved to {args.save_dir}", flush=True)
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
if __name__ == "__main__":
|
| 408 |
-
run_inference(_parse_args())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|