multimodalart HF Staff commited on
Commit
79c541e
·
verified ·
1 Parent(s): cb6df4f

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())