Ashish Mehta commited on
Commit
4687353
·
1 Parent(s): 8e53dc6

Add data pipeline scripts and configuration files for Visual Genome processing

Browse files

- Introduced .gitignore to exclude unnecessary files and directories.
- Added requirements.txt for project dependencies.
- Implemented dataset.yaml with download, extraction, and processing configurations.
- Created multiple scripts for data handling: download, extract, process, and build splits.
- Developed run_baselines.py and eval_baseline.py for baseline model evaluation.
- Enhanced README.md with data pipeline instructions and expected outputs.
- Established src/data module for data utilities and processing functions.

.gitignore ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python cache and bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Packaging / build
10
+ .Python
11
+ build/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # Virtual environments
27
+ .venv/
28
+ venv/
29
+ env/
30
+ ENV/
31
+
32
+ # Test / coverage / tooling cache
33
+ .pytest_cache/
34
+ .mypy_cache/
35
+ .ruff_cache/
36
+ .tox/
37
+ .nox/
38
+ .coverage
39
+ .coverage.*
40
+ htmlcov/
41
+ .hypothesis/
42
+
43
+ # Notebook checkpoints
44
+ .ipynb_checkpoints/
45
+
46
+ # Logs and runtime files
47
+ *.log
48
+ *.out
49
+ *.err
50
+
51
+ # IDE / editor files
52
+ .vscode/
53
+ .idea/
54
+ *.swp
55
+ *.swo
56
+
57
+ # OS files
58
+ .DS_Store
59
+ Thumbs.db
60
+
61
+ # Local environment files
62
+ .env
63
+ .env.*
64
+
65
+ # ML data and artifacts
66
+ data/raw/
67
+ data/processed/
68
+ checkpoints/
69
+ runs/
70
+ outputs/
71
+ artifacts/
72
+
73
+ .jpeg
74
+ .jpg
75
+ .png
76
+ .gif
77
+ .bmp
78
+ .tiff
79
+ .ico
80
+ .webp
81
+ .svg
82
+ .heic
83
+ .heif
84
+ .heif-srgb
85
+ .heif-srgb-alpha
86
+ .heif-srgb-alpha-alpha
README.md CHANGED
@@ -16,6 +16,8 @@ Current scope starts with `chair` and multi-label attributes like `broken`, `blu
16
  - `configs/train.yaml` - optimization, staging, losses, checkpointing
17
  - `configs/eval.yaml` - metrics, visualization, error analysis
18
  - `configs/experiment.yaml` - baseline/main/ablation run definitions
 
 
19
 
20
  ## Config Overview
21
 
@@ -79,7 +81,31 @@ Use `plan/plan.md` as the source of truth for quality gates and deliverables.
79
 
80
  ## How To Run
81
 
82
- This repo currently provides config skeletons. Connect them to your training entrypoint (for example `scripts/train.py`) and evaluation entrypoint (for example `scripts/eval.py`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  Typical flow:
85
 
@@ -90,6 +116,19 @@ Typical flow:
90
 
91
  ## Expected Outputs
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  - model checkpoints
94
  - evaluation reports (mAP/F1/per-attribute)
95
  - embedding analysis artifacts (UMAP/retrieval results)
 
16
  - `configs/train.yaml` - optimization, staging, losses, checkpointing
17
  - `configs/eval.yaml` - metrics, visualization, error analysis
18
  - `configs/experiment.yaml` - baseline/main/ablation run definitions
19
+ - `scripts/data/` - data download, extraction, processing, split pipeline
20
+ - `src/data/` - reusable normalization and IO utilities for data pipeline
21
 
22
  ## Config Overview
23
 
 
81
 
82
  ## How To Run
83
 
84
+ ### 1) Run Chair-Only Data Pipeline (Visual Genome)
85
+
86
+ Default full pipeline:
87
+
88
+ `python scripts/data/run_data_pipeline.py --config configs/dataset.yaml`
89
+
90
+ Run individual stages:
91
+
92
+ - `python scripts/data/run_data_pipeline.py --download-only --config configs/dataset.yaml`
93
+ - `python scripts/data/run_data_pipeline.py --extract-only --config configs/dataset.yaml`
94
+ - `python scripts/data/run_data_pipeline.py --process-only --config configs/dataset.yaml`
95
+ - `python scripts/data/run_data_pipeline.py --split-only --config configs/dataset.yaml`
96
+
97
+ Direct scripts (if needed):
98
+
99
+ - `python scripts/data/download_visual_genome.py --config configs/dataset.yaml`
100
+ - `python scripts/data/extract_visual_genome.py --config configs/dataset.yaml`
101
+ - `python scripts/data/process_visual_genome.py --config configs/dataset.yaml`
102
+ - `python scripts/data/build_splits.py --config configs/dataset.yaml`
103
+
104
+ Important: the processing pipeline is strict chair-only (`primary_object_class: chair`).
105
+
106
+ ### 2) Train/Evaluate Models
107
+
108
+ This repo provides dataset + experiment configs. Connect model training to your entrypoints (for example `scripts/train.py` and `scripts/eval.py`).
109
 
110
  Typical flow:
111
 
 
116
 
117
  ## Expected Outputs
118
 
119
+ Data artifacts:
120
+
121
+ - `data/processed/visual_genome/samples.parquet` (or `samples.csv` fallback)
122
+ - `data/processed/visual_genome/label_vocab.json`
123
+ - `data/processed/visual_genome/label_frequencies.json`
124
+ - `data/processed/visual_genome/processing_report.json`
125
+ - `data/processed/visual_genome/splits/train.json`
126
+ - `data/processed/visual_genome/splits/val.json`
127
+ - `data/processed/visual_genome/splits/test.json`
128
+ - `data/processed/visual_genome/splits/split_report.json`
129
+
130
+ Training/eval artifacts:
131
+
132
  - model checkpoints
133
  - evaluation reports (mAP/F1/per-attribute)
134
  - embedding analysis artifacts (UMAP/retrieval results)
configs/dataset.yaml CHANGED
@@ -11,12 +11,58 @@ dataset:
11
  image_size: 224
12
  num_workers: 8
13
  pin_memory: true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  splits:
16
  train: 0.8
17
  val: 0.1
18
  test: 0.1
19
  stratify_by_attribute_groups: true
 
 
20
  split_seed: 42
21
 
22
  attributes:
 
11
  image_size: 224
12
  num_workers: 8
13
  pin_memory: true
14
+ expected_primary_object_class: chair
15
+
16
+ download:
17
+ enabled: true
18
+ force_download: false
19
+ timeout_seconds: 120
20
+ retries: 3
21
+ chunk_size_bytes: 1048576
22
+ artifacts:
23
+ - name: images_metadata
24
+ url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/image_data.json.zip
25
+ filename: image_data.json.zip
26
+ sha256: null
27
+ - name: objects
28
+ url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/objects.json.zip
29
+ filename: objects.json.zip
30
+ sha256: null
31
+ - name: attributes
32
+ url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/attributes.json.zip
33
+ filename: attributes.json.zip
34
+ sha256: null
35
+
36
+ extraction:
37
+ enabled: true
38
+ force_extract: false
39
+ safe_extract: true
40
+ marker_file: .extract_complete
41
+ archives:
42
+ - image_data.json.zip
43
+ - objects.json.zip
44
+ - attributes.json.zip
45
+
46
+ processing:
47
+ output_format: parquet
48
+ csv_fallback: true
49
+ processing_report_file: processing_report.json
50
+ label_vocab_file: label_vocab.json
51
+ label_frequency_file: label_frequencies.json
52
+ samples_file_parquet: samples.parquet
53
+ samples_file_csv: samples.csv
54
+ split_dir: splits
55
+ unknown_label_token: __unknown__
56
+ keep_unmapped_attributes: false
57
+ strict_chair_only: true
58
 
59
  splits:
60
  train: 0.8
61
  val: 0.1
62
  test: 0.1
63
  stratify_by_attribute_groups: true
64
+ multilabel_stratify: true
65
+ stratify_fallback: iterative_bucket
66
  split_seed: 42
67
 
68
  attributes:
configs/model.yaml CHANGED
@@ -6,13 +6,13 @@ model:
6
  encoders:
7
  clip:
8
  enabled: true
9
- model_name: ViT-B-32
10
  pretrained: true
11
  output_dim: 512
12
  freeze_stage1: true
13
  dino:
14
  enabled: true
15
- model_name: vit_base_patch14_dinov2
16
  pretrained: true
17
  output_dim: 768
18
  freeze_stage1: true
 
6
  encoders:
7
  clip:
8
  enabled: true
9
+ model_name: vit_base_patch16_clip_224.openai
10
  pretrained: true
11
  output_dim: 512
12
  freeze_stage1: true
13
  dino:
14
  enabled: true
15
+ model_name: vit_base_patch14_dinov2.lvd142m
16
  pretrained: true
17
  output_dim: 768
18
  freeze_stage1: true
configs/train.yaml CHANGED
@@ -4,6 +4,10 @@ run:
4
  experiment_name: clip_dino_gnn_bipartite
5
  output_dir: outputs
6
  save_every_n_epochs: 1
 
 
 
 
7
 
8
  training:
9
  stage1:
 
4
  experiment_name: clip_dino_gnn_bipartite
5
  output_dir: outputs
6
  save_every_n_epochs: 1
7
+ smoke_profile:
8
+ epochs: 2
9
+ batch_size: 8
10
+ eval_batch_size: 8
11
 
12
  training:
13
  stage1:
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu121
2
+
3
+ PyYAML
4
+ pandas
5
+ pyarrow
6
+ torch==2.5.1+cu121
7
+ torchvision==0.20.1+cu121
8
+ torchaudio==2.5.1+cu121
9
+ timm
10
+ scikit-learn
11
+ Pillow
12
+ tqdm
scripts/data/build_splits.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import hashlib
6
+ import json
7
+ import random
8
+ from collections import defaultdict
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Iterable, List, Tuple
11
+
12
+ import sys
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[2]
15
+ SRC_DIR = REPO_ROOT / "src"
16
+ if str(SRC_DIR) not in sys.path:
17
+ sys.path.insert(0, str(SRC_DIR))
18
+
19
+ from data.io_utils import ensure_dir, load_yaml, write_json # noqa: E402
20
+
21
+
22
+ def to_json_safe(value: Any) -> Any:
23
+ if isinstance(value, dict):
24
+ return {str(k): to_json_safe(v) for k, v in value.items()}
25
+ if isinstance(value, (list, tuple, set)):
26
+ return [to_json_safe(v) for v in value]
27
+ if hasattr(value, "tolist"):
28
+ return to_json_safe(value.tolist())
29
+ if hasattr(value, "item"):
30
+ try:
31
+ return value.item()
32
+ except Exception:
33
+ pass
34
+ return value
35
+
36
+
37
+ def parse_args() -> argparse.Namespace:
38
+ parser = argparse.ArgumentParser(
39
+ description="Build deterministic train/val/test split manifests for chair samples."
40
+ )
41
+ parser.add_argument(
42
+ "--config",
43
+ type=Path,
44
+ default=REPO_ROOT / "configs" / "dataset.yaml",
45
+ help="Path to dataset config yaml.",
46
+ )
47
+ return parser.parse_args()
48
+
49
+
50
+ def _load_samples_csv(path: Path) -> List[Dict[str, Any]]:
51
+ rows: List[Dict[str, Any]] = []
52
+ with path.open("r", encoding="utf-8", newline="") as handle:
53
+ reader = csv.DictReader(handle)
54
+ for row in reader:
55
+ rows.append(
56
+ {
57
+ "image_id": int(row["image_id"]),
58
+ "object_id": int(row["object_id"]),
59
+ "object_name": row.get("object_name", "chair"),
60
+ "image_url": row.get("image_url", ""),
61
+ "image_path": row.get("image_path", ""),
62
+ "attributes_raw": json.loads(row.get("attributes_raw", "[]")),
63
+ "attributes_norm": json.loads(row.get("attributes_norm", "[]")),
64
+ }
65
+ )
66
+ return rows
67
+
68
+
69
+ def load_samples(processed_root: Path, processing_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
70
+ parquet_path = processed_root / processing_cfg.get("samples_file_parquet", "samples.parquet")
71
+ csv_path = processed_root / processing_cfg.get("samples_file_csv", "samples.csv")
72
+ if parquet_path.exists():
73
+ try:
74
+ import pandas as pd # type: ignore
75
+
76
+ df = pd.read_parquet(parquet_path)
77
+ rows = df.to_dict(orient="records")
78
+ for row in rows:
79
+ row = to_json_safe(row)
80
+ if isinstance(row.get("attributes_raw"), str):
81
+ row["attributes_raw"] = json.loads(row["attributes_raw"])
82
+ if isinstance(row.get("attributes_norm"), str):
83
+ row["attributes_norm"] = json.loads(row["attributes_norm"])
84
+ row["image_id"] = int(row["image_id"])
85
+ row["object_id"] = int(row["object_id"])
86
+ row["attributes_raw"] = to_json_safe(row.get("attributes_raw", []))
87
+ row["attributes_norm"] = to_json_safe(row.get("attributes_norm", []))
88
+ return rows
89
+ except Exception:
90
+ pass
91
+ if csv_path.exists():
92
+ return _load_samples_csv(csv_path)
93
+ raise RuntimeError("No processed samples file found (parquet/csv). Run processing first.")
94
+
95
+
96
+ def allocate_counts(total: int, train_ratio: float, val_ratio: float, test_ratio: float) -> Tuple[int, int, int]:
97
+ ratios = [train_ratio, val_ratio, test_ratio]
98
+ names = ["train", "val", "test"]
99
+ base = [int(total * r) for r in ratios]
100
+ remaining = total - sum(base)
101
+ frac = sorted(
102
+ [(i, (total * ratios[i]) - base[i]) for i in range(3)],
103
+ key=lambda x: x[1],
104
+ reverse=True,
105
+ )
106
+ for i in range(remaining):
107
+ base[frac[i % len(frac)][0]] += 1
108
+ counts = dict(zip(names, base))
109
+ return counts["train"], counts["val"], counts["test"]
110
+
111
+
112
+ def signature(labels: Iterable[str]) -> str:
113
+ uniq = sorted(set(labels))
114
+ return "|".join(uniq) if uniq else "__no_label__"
115
+
116
+
117
+ def digest_ids(values: Iterable[int]) -> str:
118
+ joined = ",".join(str(v) for v in sorted(values))
119
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
120
+
121
+
122
+ def main() -> None:
123
+ args = parse_args()
124
+ cfg = load_yaml(args.config)
125
+ dataset_cfg = cfg["dataset"]
126
+ split_cfg = cfg.get("splits", {})
127
+ processing_cfg = cfg.get("processing", {})
128
+
129
+ processed_root = REPO_ROOT / dataset_cfg["processed_dir"]
130
+ ensure_dir(processed_root)
131
+ samples = load_samples(processed_root=processed_root, processing_cfg=processing_cfg)
132
+ if not samples:
133
+ raise RuntimeError("No samples available for split generation.")
134
+
135
+ seed = int(split_cfg.get("split_seed", 42))
136
+ random.seed(seed)
137
+
138
+ by_image: Dict[int, Dict[str, Any]] = {}
139
+ for row in samples:
140
+ image_id = int(row["image_id"])
141
+ if image_id not in by_image:
142
+ by_image[image_id] = {"labels": set(), "rows": []}
143
+ by_image[image_id]["rows"].append(row)
144
+ by_image[image_id]["labels"].update(row.get("attributes_norm", []))
145
+
146
+ buckets: Dict[str, List[int]] = defaultdict(list)
147
+ for image_id, payload in by_image.items():
148
+ buckets[signature(payload["labels"])].append(image_id)
149
+
150
+ train_ratio = float(split_cfg.get("train", 0.8))
151
+ val_ratio = float(split_cfg.get("val", 0.1))
152
+ test_ratio = float(split_cfg.get("test", 0.1))
153
+ ratio_sum = train_ratio + val_ratio + test_ratio
154
+ if abs(ratio_sum - 1.0) > 1e-6:
155
+ raise RuntimeError("Split ratios must sum to 1.0")
156
+
157
+ train_ids: List[int] = []
158
+ val_ids: List[int] = []
159
+ test_ids: List[int] = []
160
+
161
+ for _, image_ids in sorted(buckets.items(), key=lambda item: item[0]):
162
+ random.shuffle(image_ids)
163
+ n_train, n_val, n_test = allocate_counts(
164
+ total=len(image_ids),
165
+ train_ratio=train_ratio,
166
+ val_ratio=val_ratio,
167
+ test_ratio=test_ratio,
168
+ )
169
+ train_ids.extend(image_ids[:n_train])
170
+ val_ids.extend(image_ids[n_train : n_train + n_val])
171
+ test_ids.extend(image_ids[n_train + n_val : n_train + n_val + n_test])
172
+
173
+ train_set = set(train_ids)
174
+ val_set = set(val_ids)
175
+ test_set = set(test_ids)
176
+ if train_set & val_set or train_set & test_set or val_set & test_set:
177
+ raise RuntimeError("Leakage detected across train/val/test image IDs.")
178
+
179
+ split_rows = {"train": [], "val": [], "test": []}
180
+ for row in samples:
181
+ row = to_json_safe(row)
182
+ image_id = int(row["image_id"])
183
+ if image_id in train_set:
184
+ split_rows["train"].append(row)
185
+ elif image_id in val_set:
186
+ split_rows["val"].append(row)
187
+ elif image_id in test_set:
188
+ split_rows["test"].append(row)
189
+
190
+ split_dir = processed_root / processing_cfg.get("split_dir", "splits")
191
+ ensure_dir(split_dir)
192
+ for split_name in ("train", "val", "test"):
193
+ write_json(split_dir / f"{split_name}.json", {"samples": split_rows[split_name]})
194
+
195
+ summary = {
196
+ "seed": seed,
197
+ "total_rows": len(samples),
198
+ "total_unique_images": len(by_image),
199
+ "train_rows": len(split_rows["train"]),
200
+ "val_rows": len(split_rows["val"]),
201
+ "test_rows": len(split_rows["test"]),
202
+ "train_unique_images": len(train_set),
203
+ "val_unique_images": len(val_set),
204
+ "test_unique_images": len(test_set),
205
+ "train_image_digest": digest_ids(train_set),
206
+ "val_image_digest": digest_ids(val_set),
207
+ "test_image_digest": digest_ids(test_set),
208
+ "stratify_mode": "multilabel_signature_bucket",
209
+ "fallback_used": str(split_cfg.get("stratify_fallback", "iterative_bucket")),
210
+ "leakage_check_passed": True,
211
+ }
212
+ write_json(split_dir / "split_report.json", summary)
213
+ print("[ok] Split manifests created with leakage check passed.")
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
scripts/data/download_visual_genome.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import sys
7
+
8
+ REPO_ROOT = Path(__file__).resolve().parents[2]
9
+ SRC_DIR = REPO_ROOT / "src"
10
+ if str(SRC_DIR) not in sys.path:
11
+ sys.path.insert(0, str(SRC_DIR))
12
+
13
+ from data.io_utils import download_file, ensure_dir, load_yaml, verify_checksum # noqa: E402
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Download Visual Genome raw artifacts.")
18
+ parser.add_argument(
19
+ "--config",
20
+ type=Path,
21
+ default=REPO_ROOT / "configs" / "dataset.yaml",
22
+ help="Path to dataset config yaml.",
23
+ )
24
+ parser.add_argument(
25
+ "--force-download",
26
+ action="store_true",
27
+ help="Force re-download even if files already exist.",
28
+ )
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ cfg = load_yaml(args.config)
35
+ dataset_cfg = cfg["dataset"]
36
+ download_cfg = cfg.get("download", {})
37
+
38
+ if not download_cfg.get("enabled", True):
39
+ print("Download disabled in config. Nothing to do.")
40
+ return
41
+
42
+ raw_root = REPO_ROOT / dataset_cfg["root_dir"]
43
+ ensure_dir(raw_root)
44
+
45
+ timeout_seconds = int(download_cfg.get("timeout_seconds", 120))
46
+ retries = int(download_cfg.get("retries", 3))
47
+ chunk_size = int(download_cfg.get("chunk_size_bytes", 1024 * 1024))
48
+ force_download = args.force_download or bool(download_cfg.get("force_download", False))
49
+
50
+ artifacts = download_cfg.get("artifacts", [])
51
+ if not artifacts:
52
+ raise RuntimeError("No download artifacts configured.")
53
+
54
+ for artifact in artifacts:
55
+ filename = artifact["filename"]
56
+ url = artifact["url"]
57
+ expected_sha256 = artifact.get("sha256")
58
+ target = raw_root / filename
59
+
60
+ if target.exists() and not force_download:
61
+ if verify_checksum(target, expected_sha256):
62
+ print(f"[skip] {filename} already exists and checksum passed.")
63
+ continue
64
+ print(f"[warn] {filename} exists but checksum mismatch. Re-downloading.")
65
+
66
+ print(f"[download] {filename} <- {url}")
67
+ download_file(
68
+ url=url,
69
+ destination=target,
70
+ timeout_seconds=timeout_seconds,
71
+ retries=retries,
72
+ chunk_size=chunk_size,
73
+ )
74
+
75
+ if not verify_checksum(target, expected_sha256):
76
+ raise RuntimeError(f"Checksum validation failed for {target}")
77
+ print(f"[ok] {filename}")
78
+
79
+ print("All configured Visual Genome artifacts are downloaded.")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
scripts/data/extract_visual_genome.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import sys
7
+
8
+ REPO_ROOT = Path(__file__).resolve().parents[2]
9
+ SRC_DIR = REPO_ROOT / "src"
10
+ if str(SRC_DIR) not in sys.path:
11
+ sys.path.insert(0, str(SRC_DIR))
12
+
13
+ from data.io_utils import ensure_dir, extract_archive, load_yaml # noqa: E402
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Extract Visual Genome raw archives.")
18
+ parser.add_argument(
19
+ "--config",
20
+ type=Path,
21
+ default=REPO_ROOT / "configs" / "dataset.yaml",
22
+ help="Path to dataset config yaml.",
23
+ )
24
+ parser.add_argument(
25
+ "--force-extract",
26
+ action="store_true",
27
+ help="Force extraction even if marker exists.",
28
+ )
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ cfg = load_yaml(args.config)
35
+ dataset_cfg = cfg["dataset"]
36
+ extraction_cfg = cfg.get("extraction", {})
37
+
38
+ if not extraction_cfg.get("enabled", True):
39
+ print("Extraction disabled in config. Nothing to do.")
40
+ return
41
+
42
+ raw_root = REPO_ROOT / dataset_cfg["root_dir"]
43
+ ensure_dir(raw_root)
44
+
45
+ marker_file = raw_root / extraction_cfg.get("marker_file", ".extract_complete")
46
+ force_extract = args.force_extract or bool(extraction_cfg.get("force_extract", False))
47
+ safe_extract = bool(extraction_cfg.get("safe_extract", True))
48
+
49
+ if marker_file.exists() and not force_extract:
50
+ print(f"[skip] Extraction marker found: {marker_file}")
51
+ return
52
+
53
+ archives = extraction_cfg.get("archives", [])
54
+ if not archives:
55
+ raise RuntimeError("No extraction archives configured.")
56
+
57
+ for archive_name in archives:
58
+ archive_path = raw_root / archive_name
59
+ if not archive_path.exists():
60
+ raise RuntimeError(f"Archive missing: {archive_path}")
61
+ print(f"[extract] {archive_name}")
62
+ extract_archive(archive_path=archive_path, output_dir=raw_root, safe_extract=safe_extract)
63
+
64
+ marker_file.write_text("ok\n", encoding="utf-8")
65
+ print(f"[ok] Extraction completed. Marker written at {marker_file}")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
scripts/data/process_visual_genome.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Tuple
8
+
9
+ import sys
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[2]
12
+ SRC_DIR = REPO_ROOT / "src"
13
+ if str(SRC_DIR) not in sys.path:
14
+ sys.path.insert(0, str(SRC_DIR))
15
+
16
+ from data.io_utils import ensure_dir, load_yaml, write_json # noqa: E402
17
+ from data.normalization import ( # noqa: E402
18
+ build_label_vocab,
19
+ filter_by_min_support,
20
+ normalize_labels,
21
+ normalize_text,
22
+ )
23
+ from data.schemas import SampleRecord # noqa: E402
24
+
25
+
26
+ def parse_args() -> argparse.Namespace:
27
+ parser = argparse.ArgumentParser(
28
+ description="Process Visual Genome attributes to chair-only normalized samples."
29
+ )
30
+ parser.add_argument(
31
+ "--config",
32
+ type=Path,
33
+ default=REPO_ROOT / "configs" / "dataset.yaml",
34
+ help="Path to dataset config yaml.",
35
+ )
36
+ return parser.parse_args()
37
+
38
+
39
+ def flatten_attribute_groups(groups_cfg: Dict[str, Iterable[str]]) -> List[str]:
40
+ labels: List[str] = []
41
+ for values in groups_cfg.values():
42
+ labels.extend(values)
43
+ return labels
44
+
45
+
46
+ def find_json_file(raw_root: Path, target_name: str) -> Path:
47
+ direct = raw_root / target_name
48
+ if direct.exists():
49
+ return direct
50
+ matches = list(raw_root.rglob(target_name))
51
+ if not matches:
52
+ raise RuntimeError(f"Required json file not found: {target_name}")
53
+ return matches[0]
54
+
55
+
56
+ def load_json(path: Path) -> Any:
57
+ with path.open("r", encoding="utf-8") as handle:
58
+ return json.load(handle)
59
+
60
+
61
+ def build_image_metadata_map(image_data: List[Dict[str, Any]]) -> Dict[int, Dict[str, str]]:
62
+ out: Dict[int, Dict[str, str]] = {}
63
+ for row in image_data:
64
+ image_id = int(row.get("image_id"))
65
+ image_url = str(row.get("url") or "")
66
+ file_name = Path(image_url).name if image_url else ""
67
+ image_path = str(Path("data/raw/visual_genome/images") / file_name) if file_name else ""
68
+ out[image_id] = {"image_url": image_url, "image_path": image_path}
69
+ return out
70
+
71
+
72
+ def as_list(value: Any) -> List[str]:
73
+ if value is None:
74
+ return []
75
+ if isinstance(value, list):
76
+ return [str(v) for v in value]
77
+ return [str(value)]
78
+
79
+
80
+ def object_is_chair(names: List[str], lowercase: bool, lemmatize: bool) -> bool:
81
+ normalized = [
82
+ normalize_text(name, lowercase=lowercase, lemmatize=lemmatize) for name in names
83
+ ]
84
+ return "chair" in set(normalized)
85
+
86
+
87
+ def parse_chair_records(
88
+ attributes_data: List[Dict[str, Any]],
89
+ image_meta: Dict[int, Dict[str, str]],
90
+ lowercase: bool,
91
+ lemmatize: bool,
92
+ synonym_map: Dict[str, str],
93
+ keep_unmapped_attributes: bool,
94
+ allowed_labels: List[str],
95
+ ) -> Tuple[List[SampleRecord], Dict[str, int]]:
96
+ allowed_set = set(
97
+ normalize_text(label, lowercase=lowercase, lemmatize=lemmatize) for label in allowed_labels
98
+ )
99
+ row_stats = {
100
+ "total_attribute_objects": 0,
101
+ "non_chair_objects_dropped": 0,
102
+ "chair_objects_kept_pre_filter": 0,
103
+ }
104
+ output: List[SampleRecord] = []
105
+
106
+ for image_item in attributes_data:
107
+ image_id = int(image_item.get("image_id"))
108
+ image_attributes = image_item.get("attributes", [])
109
+ metadata = image_meta.get(image_id, {})
110
+ for obj in image_attributes:
111
+ row_stats["total_attribute_objects"] += 1
112
+ names = as_list(obj.get("names"))
113
+ if not object_is_chair(names, lowercase=lowercase, lemmatize=lemmatize):
114
+ row_stats["non_chair_objects_dropped"] += 1
115
+ continue
116
+
117
+ object_id = int(obj.get("object_id") or 0)
118
+ raw_attributes = as_list(obj.get("attributes"))
119
+ normalized = normalize_labels(
120
+ labels=raw_attributes,
121
+ lowercase=lowercase,
122
+ lemmatize=lemmatize,
123
+ synonym_map=synonym_map,
124
+ keep_unmapped=keep_unmapped_attributes,
125
+ allowed_labels=allowed_set,
126
+ )
127
+ record = SampleRecord(
128
+ image_id=image_id,
129
+ object_id=object_id,
130
+ object_name="chair",
131
+ image_url=metadata.get("image_url", ""),
132
+ image_path=metadata.get("image_path", ""),
133
+ attributes_raw=raw_attributes,
134
+ attributes_norm=normalized,
135
+ )
136
+ output.append(record)
137
+ row_stats["chair_objects_kept_pre_filter"] += 1
138
+
139
+ return output, row_stats
140
+
141
+
142
+ def deduplicate_records(records: List[SampleRecord]) -> List[SampleRecord]:
143
+ seen = set()
144
+ out: List[SampleRecord] = []
145
+ for record in records:
146
+ key = (record.image_id, record.object_id)
147
+ if key in seen:
148
+ continue
149
+ seen.add(key)
150
+ out.append(record)
151
+ return out
152
+
153
+
154
+ def save_samples(
155
+ rows: List[Dict[str, Any]],
156
+ output_format: str,
157
+ csv_fallback: bool,
158
+ parquet_path: Path,
159
+ csv_path: Path,
160
+ ) -> str:
161
+ if output_format == "parquet":
162
+ try:
163
+ import pandas as pd # type: ignore
164
+
165
+ df = pd.DataFrame(rows)
166
+ df.to_parquet(parquet_path, index=False)
167
+ return str(parquet_path)
168
+ except Exception as exc: # pragma: no cover
169
+ if not csv_fallback:
170
+ raise RuntimeError(f"Failed to write parquet and csv fallback disabled: {exc}") from exc
171
+
172
+ ensure_dir(csv_path.parent)
173
+ fieldnames = [
174
+ "image_id",
175
+ "object_id",
176
+ "object_name",
177
+ "image_url",
178
+ "image_path",
179
+ "attributes_raw",
180
+ "attributes_norm",
181
+ ]
182
+ with csv_path.open("w", encoding="utf-8", newline="") as handle:
183
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
184
+ writer.writeheader()
185
+ for row in rows:
186
+ row_copy = dict(row)
187
+ row_copy["attributes_raw"] = json.dumps(row_copy["attributes_raw"], ensure_ascii=True)
188
+ row_copy["attributes_norm"] = json.dumps(row_copy["attributes_norm"], ensure_ascii=True)
189
+ writer.writerow(row_copy)
190
+ return str(csv_path)
191
+
192
+
193
+ def main() -> None:
194
+ args = parse_args()
195
+ cfg = load_yaml(args.config)
196
+ dataset_cfg = cfg["dataset"]
197
+ processing_cfg = cfg.get("processing", {})
198
+ filtering_cfg = cfg.get("filtering", {})
199
+ attributes_cfg = cfg.get("attributes", {})
200
+
201
+ strict_chair_only = bool(processing_cfg.get("strict_chair_only", True))
202
+ primary_object = str(cfg["project"].get("primary_object_class", "chair")).strip().lower()
203
+ if strict_chair_only and primary_object != "chair":
204
+ raise RuntimeError("This pipeline currently supports strict chair-only processing.")
205
+
206
+ raw_root = REPO_ROOT / dataset_cfg["root_dir"]
207
+ processed_root = REPO_ROOT / dataset_cfg["processed_dir"]
208
+ ensure_dir(processed_root)
209
+
210
+ attributes_path = find_json_file(raw_root, "attributes.json")
211
+ image_data_path = find_json_file(raw_root, "image_data.json")
212
+ attributes_data = load_json(attributes_path)
213
+ image_data = load_json(image_data_path)
214
+
215
+ lowercase = bool(attributes_cfg.get("lowercase", True))
216
+ lemmatize = bool(attributes_cfg.get("lemmatize", True))
217
+ synonym_map = {
218
+ normalize_text(str(k), lowercase=lowercase, lemmatize=lemmatize): normalize_text(
219
+ str(v), lowercase=lowercase, lemmatize=lemmatize
220
+ )
221
+ for k, v in attributes_cfg.get("synonym_map", {}).items()
222
+ }
223
+ allowed_labels = flatten_attribute_groups(attributes_cfg.get("groups", {}))
224
+ keep_unmapped = bool(processing_cfg.get("keep_unmapped_attributes", False))
225
+
226
+ image_meta = build_image_metadata_map(image_data)
227
+ records, row_stats = parse_chair_records(
228
+ attributes_data=attributes_data,
229
+ image_meta=image_meta,
230
+ lowercase=lowercase,
231
+ lemmatize=lemmatize,
232
+ synonym_map=synonym_map,
233
+ keep_unmapped_attributes=keep_unmapped,
234
+ allowed_labels=allowed_labels,
235
+ )
236
+
237
+ if bool(filtering_cfg.get("deduplicate_images", True)):
238
+ records = deduplicate_records(records)
239
+
240
+ if bool(filtering_cfg.get("drop_empty_labels", True)):
241
+ records = [r for r in records if len(r.attributes_norm) > 0]
242
+
243
+ min_support = int(attributes_cfg.get("min_support", 1))
244
+ filtered_label_lists, label_freq = filter_by_min_support(
245
+ [r.attributes_norm for r in records],
246
+ min_support=min_support,
247
+ )
248
+ for idx, labels in enumerate(filtered_label_lists):
249
+ records[idx].attributes_norm = labels
250
+
251
+ if bool(filtering_cfg.get("drop_empty_labels", True)):
252
+ records = [r for r in records if len(r.attributes_norm) > 0]
253
+
254
+ max_samples_per_attribute = filtering_cfg.get("max_samples_per_attribute")
255
+ if max_samples_per_attribute is not None:
256
+ per_attr_limit = int(max_samples_per_attribute)
257
+ attr_counts: Dict[str, int] = {}
258
+ limited: List[SampleRecord] = []
259
+ for rec in records:
260
+ if not rec.attributes_norm:
261
+ continue
262
+ can_take = True
263
+ for label in rec.attributes_norm:
264
+ if attr_counts.get(label, 0) >= per_attr_limit:
265
+ can_take = False
266
+ break
267
+ if can_take:
268
+ limited.append(rec)
269
+ for label in rec.attributes_norm:
270
+ attr_counts[label] = attr_counts.get(label, 0) + 1
271
+ records = limited
272
+
273
+ label_freq = {}
274
+ for rec in records:
275
+ for label in set(rec.attributes_norm):
276
+ label_freq[label] = label_freq.get(label, 0) + 1
277
+ label_freq = dict(sorted(label_freq.items()))
278
+ label_vocab = build_label_vocab(label_freq)
279
+
280
+ rows = [record.to_dict() for record in records]
281
+ samples_parquet = processed_root / processing_cfg.get("samples_file_parquet", "samples.parquet")
282
+ samples_csv = processed_root / processing_cfg.get("samples_file_csv", "samples.csv")
283
+ output_format = str(processing_cfg.get("output_format", "parquet")).lower()
284
+ csv_fallback = bool(processing_cfg.get("csv_fallback", True))
285
+ samples_written = save_samples(
286
+ rows=rows,
287
+ output_format=output_format,
288
+ csv_fallback=csv_fallback,
289
+ parquet_path=samples_parquet,
290
+ csv_path=samples_csv,
291
+ )
292
+
293
+ write_json(
294
+ processed_root / processing_cfg.get("label_vocab_file", "label_vocab.json"),
295
+ label_vocab,
296
+ )
297
+ write_json(
298
+ processed_root / processing_cfg.get("label_frequency_file", "label_frequencies.json"),
299
+ label_freq,
300
+ )
301
+ report = {
302
+ "raw_attributes_file": str(attributes_path),
303
+ "raw_image_data_file": str(image_data_path),
304
+ "samples_output": samples_written,
305
+ "strict_chair_only": strict_chair_only,
306
+ "primary_object_class": primary_object,
307
+ "total_samples_final": len(records),
308
+ "labels_kept": list(label_vocab.keys()),
309
+ "num_labels_kept": len(label_vocab),
310
+ "min_support": min_support,
311
+ **row_stats,
312
+ }
313
+ write_json(
314
+ processed_root / processing_cfg.get("processing_report_file", "processing_report.json"),
315
+ report,
316
+ )
317
+ print(f"[ok] Processed {len(records)} chair samples.")
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
scripts/data/run_data_pipeline.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[2]
11
+
12
+
13
+ def parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description="Run chair-only Visual Genome data pipeline.")
15
+ parser.add_argument(
16
+ "--config",
17
+ type=Path,
18
+ default=REPO_ROOT / "configs" / "dataset.yaml",
19
+ help="Path to dataset config yaml.",
20
+ )
21
+ parser.add_argument(
22
+ "--dataset-name",
23
+ type=str,
24
+ default="visual_genome",
25
+ help="Dataset selector. Only visual_genome is supported in this version.",
26
+ )
27
+ parser.add_argument("--download-only", action="store_true")
28
+ parser.add_argument("--extract-only", action="store_true")
29
+ parser.add_argument("--process-only", action="store_true")
30
+ parser.add_argument("--split-only", action="store_true")
31
+ parser.add_argument("--all", action="store_true", help="Run full pipeline (default behavior).")
32
+ return parser.parse_args()
33
+
34
+
35
+ def run_step(script_path: Path, config_path: Path) -> None:
36
+ cmd: List[str] = [sys.executable, str(script_path), "--config", str(config_path)]
37
+ print(f"[run] {' '.join(cmd)}")
38
+ subprocess.run(cmd, check=True, cwd=REPO_ROOT)
39
+
40
+
41
+ def main() -> None:
42
+ args = parse_args()
43
+ if args.dataset_name != "visual_genome":
44
+ raise RuntimeError("Only --dataset-name visual_genome is supported currently.")
45
+
46
+ selected_flags = [
47
+ args.download_only,
48
+ args.extract_only,
49
+ args.process_only,
50
+ args.split_only,
51
+ args.all,
52
+ ]
53
+ if sum(1 for flag in selected_flags if flag) > 1:
54
+ raise RuntimeError("Use only one stage selector at a time.")
55
+
56
+ scripts_dir = REPO_ROOT / "scripts" / "data"
57
+ steps = {
58
+ "download": scripts_dir / "download_visual_genome.py",
59
+ "extract": scripts_dir / "extract_visual_genome.py",
60
+ "process": scripts_dir / "process_visual_genome.py",
61
+ "split": scripts_dir / "build_splits.py",
62
+ }
63
+
64
+ if args.download_only:
65
+ pipeline = ["download"]
66
+ elif args.extract_only:
67
+ pipeline = ["extract"]
68
+ elif args.process_only:
69
+ pipeline = ["process"]
70
+ elif args.split_only:
71
+ pipeline = ["split"]
72
+ else:
73
+ pipeline = ["download", "extract", "process", "split"]
74
+
75
+ for step in pipeline:
76
+ run_step(script_path=steps[step], config_path=args.config)
77
+ print("[ok] Data pipeline completed.")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
scripts/eval_baseline.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+
8
+ import sys
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ SRC_DIR = REPO_ROOT / "src"
12
+ if str(SRC_DIR) not in sys.path:
13
+ sys.path.insert(0, str(SRC_DIR))
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Show saved metrics for a trained baseline run.")
18
+ parser.add_argument("--run-name", type=str, required=True)
19
+ parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "outputs")
20
+ return parser.parse_args()
21
+
22
+
23
+ def main() -> None:
24
+ args = parse_args()
25
+ metrics_path = args.output_dir / args.run_name / "metrics.json"
26
+ if not metrics_path.exists():
27
+ raise RuntimeError(f"Metrics not found: {metrics_path}")
28
+ with metrics_path.open("r", encoding="utf-8") as handle:
29
+ payload: Dict[str, Any] = json.load(handle)
30
+ report = {
31
+ "run_name": payload.get("run_name"),
32
+ "mode": payload.get("mode"),
33
+ "best_val_map": payload.get("best_val_map"),
34
+ "test_map": payload.get("test_metrics", {}).get("map"),
35
+ "test_macro_f1": payload.get("test_metrics", {}).get("macro_f1"),
36
+ "test_micro_f1": payload.get("test_metrics", {}).get("micro_f1"),
37
+ }
38
+ print(json.dumps(report, indent=2))
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
scripts/run_baselines.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List
11
+
12
+ import yaml
13
+
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parents[1]
16
+
17
+
18
+ def log(message: str) -> None:
19
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
20
+ print(f"[{ts}] [run_baselines] {message}", flush=True)
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser(description="Run Baseline A/B/C experiments.")
25
+ parser.add_argument("--mode", choices=["smoke", "full"], default="smoke")
26
+ parser.add_argument(
27
+ "--experiment-config",
28
+ type=Path,
29
+ default=REPO_ROOT / "configs" / "experiment.yaml",
30
+ )
31
+ parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
32
+ parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
33
+ parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
34
+ parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
35
+ return parser.parse_args()
36
+
37
+
38
+ def load_yaml(path: Path) -> Dict[str, Any]:
39
+ with path.open("r", encoding="utf-8") as handle:
40
+ payload = yaml.safe_load(handle)
41
+ return payload or {}
42
+
43
+
44
+ def run_train(
45
+ mode: str,
46
+ run_name: str,
47
+ overrides: Dict[str, Any],
48
+ dataset_config: Path,
49
+ model_config: Path,
50
+ train_config: Path,
51
+ eval_config: Path,
52
+ ) -> None:
53
+ cmd: List[str] = [
54
+ sys.executable,
55
+ str(REPO_ROOT / "scripts" / "train_baseline.py"),
56
+ "--run-name",
57
+ run_name,
58
+ "--mode",
59
+ mode,
60
+ "--dataset-config",
61
+ str(dataset_config),
62
+ "--model-config",
63
+ str(model_config),
64
+ "--train-config",
65
+ str(train_config),
66
+ "--eval-config",
67
+ str(eval_config),
68
+ "--overrides-json",
69
+ json.dumps(overrides),
70
+ ]
71
+ env = dict(**os.environ)
72
+ env["PYTHONUNBUFFERED"] = "1"
73
+ cmd.insert(1, "-u")
74
+ log(f"Launching run '{run_name}' in mode='{mode}'")
75
+ log(f"Python executable: {sys.executable}")
76
+ log(f"Overrides: {json.dumps(overrides)}")
77
+ log(f"Command: {' '.join(cmd)}")
78
+ started = time.time()
79
+ completed = subprocess.run(cmd, check=False, cwd=REPO_ROOT, env=env)
80
+ elapsed = time.time() - started
81
+ log(f"Run '{run_name}' exited with code={completed.returncode} after {elapsed:.1f}s")
82
+ if completed.returncode != 0:
83
+ raise RuntimeError(f"Baseline run failed: {run_name}")
84
+
85
+
86
+ def main() -> None:
87
+ args = parse_args()
88
+ log("Starting baseline orchestrator")
89
+ log(f"Repo root: {REPO_ROOT}")
90
+ log(f"Mode: {args.mode}")
91
+ log(f"Experiment config: {args.experiment_config}")
92
+ experiment_cfg = load_yaml(args.experiment_config)
93
+ baseline_runs = experiment_cfg.get("baseline_runs", [])
94
+ if not baseline_runs:
95
+ raise RuntimeError("No baseline_runs found in experiment config.")
96
+ log(f"Discovered {len(baseline_runs)} baseline runs")
97
+
98
+ output_dir = REPO_ROOT / "outputs"
99
+ output_dir.mkdir(parents=True, exist_ok=True)
100
+ comparison: Dict[str, Any] = {"mode": args.mode, "runs": []}
101
+
102
+ for row in baseline_runs:
103
+ run_name = f"{row['name']}_{args.mode}"
104
+ overrides = row.get("overrides", {})
105
+ log(f"Preparing run: {run_name}")
106
+ run_train(
107
+ mode=args.mode,
108
+ run_name=run_name,
109
+ overrides=overrides,
110
+ dataset_config=args.dataset_config,
111
+ model_config=args.model_config,
112
+ train_config=args.train_config,
113
+ eval_config=args.eval_config,
114
+ )
115
+ metrics_path = output_dir / run_name / "metrics.json"
116
+ log(f"Reading metrics from: {metrics_path}")
117
+ with metrics_path.open("r", encoding="utf-8") as handle:
118
+ metrics = json.load(handle)
119
+ comparison["runs"].append(
120
+ {
121
+ "run_name": run_name,
122
+ "test_map": metrics.get("test_metrics", {}).get("map"),
123
+ "test_macro_f1": metrics.get("test_metrics", {}).get("macro_f1"),
124
+ "test_micro_f1": metrics.get("test_metrics", {}).get("micro_f1"),
125
+ }
126
+ )
127
+
128
+ comparison_path = output_dir / f"baseline_comparison_{args.mode}.json"
129
+ with comparison_path.open("w", encoding="utf-8") as handle:
130
+ json.dump(comparison, handle, indent=2, ensure_ascii=True)
131
+ log(f"Wrote comparison report: {comparison_path}")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
scripts/train_baseline.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import random
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Tuple
9
+
10
+ import numpy as np
11
+ import torch
12
+ from tqdm import tqdm
13
+
14
+ import sys
15
+
16
+ REPO_ROOT = Path(__file__).resolve().parents[1]
17
+ SRC_DIR = REPO_ROOT / "src"
18
+ if str(SRC_DIR) not in sys.path:
19
+ sys.path.insert(0, str(SRC_DIR))
20
+
21
+ from data.io_utils import write_json # noqa: E402
22
+ from train.config_utils import ( # noqa: E402
23
+ apply_dot_overrides,
24
+ deep_merge,
25
+ load_yaml,
26
+ parse_overrides_json,
27
+ )
28
+ from train.dataset import ( # noqa: E402
29
+ ChairAttributeDataset,
30
+ build_dataloader,
31
+ class_pos_weights,
32
+ load_json,
33
+ split_paths,
34
+ )
35
+ from train.encoders import build_backbones # noqa: E402
36
+ from train.losses import bce_logits_loss # noqa: E402
37
+ from train.metrics import compute_metrics # noqa: E402
38
+ from train.models import BaselineClassifier # noqa: E402
39
+
40
+
41
+ def log(message: str) -> None:
42
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
43
+ print(f"[{ts}] [train_baseline] {message}", flush=True)
44
+
45
+
46
+ def parse_args() -> argparse.Namespace:
47
+ parser = argparse.ArgumentParser(description="Train baseline model (A/B/C).")
48
+ parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
49
+ parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
50
+ parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
51
+ parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
52
+ parser.add_argument("--run-name", type=str, required=True)
53
+ parser.add_argument("--overrides-json", type=str, default="")
54
+ parser.add_argument("--mode", type=str, default="full", choices=["smoke", "full"])
55
+ parser.add_argument("--device", type=str, default="auto", choices=["auto", "cuda", "cpu"])
56
+ return parser.parse_args()
57
+
58
+
59
+ def set_seed(seed: int, deterministic: bool) -> None:
60
+ random.seed(seed)
61
+ np.random.seed(seed)
62
+ torch.manual_seed(seed)
63
+ if torch.cuda.is_available():
64
+ torch.cuda.manual_seed_all(seed)
65
+ torch.backends.cudnn.deterministic = deterministic
66
+ torch.backends.cudnn.benchmark = not deterministic
67
+
68
+
69
+ def load_combined_config(args: argparse.Namespace) -> Dict[str, Any]:
70
+ dataset_cfg = load_yaml(args.dataset_config)
71
+ model_cfg = load_yaml(args.model_config)
72
+ train_cfg = load_yaml(args.train_config)
73
+ eval_cfg = load_yaml(args.eval_config)
74
+ combined = deep_merge(dataset_cfg, model_cfg)
75
+ combined = deep_merge(combined, train_cfg)
76
+ combined = deep_merge(combined, eval_cfg)
77
+ overrides = parse_overrides_json(args.overrides_json)
78
+ if overrides:
79
+ combined = apply_dot_overrides(combined, overrides)
80
+ return combined
81
+
82
+
83
+ def build_loaders(cfg: Dict[str, Any], batch_size: int, eval_batch_size: int, smoke: bool) -> Tuple[Any, Any, Any, Dict[str, int]]:
84
+ processed_root = REPO_ROOT / cfg["dataset"]["processed_dir"]
85
+ label_vocab_path = processed_root / cfg["processing"]["label_vocab_file"]
86
+ label_freq_path = processed_root / cfg["processing"]["label_frequency_file"]
87
+ cache_dir = REPO_ROOT / cfg["dataset"]["root_dir"] / "images"
88
+ splits = split_paths(processed_root=processed_root, split_dir_name=cfg["processing"]["split_dir"])
89
+
90
+ label_vocab = load_json(label_vocab_path)
91
+ label_freq = load_json(label_freq_path)
92
+
93
+ train_ds = ChairAttributeDataset(splits["train"], label_vocab_path, REPO_ROOT, cache_dir)
94
+ val_ds = ChairAttributeDataset(splits["val"], label_vocab_path, REPO_ROOT, cache_dir)
95
+ test_ds = ChairAttributeDataset(splits["test"], label_vocab_path, REPO_ROOT, cache_dir)
96
+
97
+ num_workers = int(cfg["dataset"].get("num_workers", 0))
98
+ if smoke:
99
+ num_workers = min(num_workers, 2)
100
+
101
+ train_loader = build_dataloader(
102
+ dataset=train_ds,
103
+ batch_size=batch_size,
104
+ shuffle=True,
105
+ num_workers=num_workers,
106
+ pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
107
+ )
108
+ val_loader = build_dataloader(
109
+ dataset=val_ds,
110
+ batch_size=eval_batch_size,
111
+ shuffle=False,
112
+ num_workers=num_workers,
113
+ pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
114
+ )
115
+ test_loader = build_dataloader(
116
+ dataset=test_ds,
117
+ batch_size=eval_batch_size,
118
+ shuffle=False,
119
+ num_workers=num_workers,
120
+ pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
121
+ )
122
+ return train_loader, val_loader, test_loader, {"vocab": label_vocab, "freq": label_freq}
123
+
124
+
125
+ def build_model(cfg: Dict[str, Any], num_labels: int, device: torch.device) -> BaselineClassifier:
126
+ use_clip = bool(cfg["encoders"]["clip"]["enabled"])
127
+ use_dino = bool(cfg["encoders"]["dino"]["enabled"])
128
+ clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
129
+ dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
130
+ if clip_name == "ViT-B-32":
131
+ clip_name = "vit_base_patch16_clip_224.openai"
132
+ if dino_name == "vit_base_patch14_dinov2":
133
+ dino_name = "vit_base_patch14_dinov2.lvd142m"
134
+
135
+ clip_backbone, dino_backbone, feature_dim = build_backbones(
136
+ use_clip=use_clip,
137
+ use_dino=use_dino,
138
+ clip_model_name=clip_name,
139
+ dino_model_name=dino_name,
140
+ clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
141
+ dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
142
+ )
143
+ hidden_dims = list(cfg["baseline_heads"]["mlp"].get("hidden_dims", [512, 256]))
144
+ dropout = float(cfg["baseline_heads"]["mlp"].get("dropout", 0.2))
145
+ model = BaselineClassifier(
146
+ clip_backbone=clip_backbone,
147
+ dino_backbone=dino_backbone,
148
+ feature_dim=feature_dim,
149
+ hidden_dims=hidden_dims,
150
+ dropout=dropout,
151
+ num_labels=num_labels,
152
+ )
153
+ model.freeze_backbones()
154
+ return model.to(device)
155
+
156
+
157
+ def run_eval(model: BaselineClassifier, loader: Any, device: torch.device, pos_weight: torch.Tensor) -> Dict[str, Any]:
158
+ model.eval()
159
+ all_logits: List[np.ndarray] = []
160
+ all_targets: List[np.ndarray] = []
161
+ losses: List[float] = []
162
+ with torch.no_grad():
163
+ for batch in loader:
164
+ targets = batch["targets"].to(device)
165
+ logits = model(batch["images"], device=device)
166
+ loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
167
+ losses.append(float(loss.item()))
168
+ all_logits.append(logits.detach().cpu().numpy())
169
+ all_targets.append(targets.detach().cpu().numpy())
170
+ logits_np = np.concatenate(all_logits, axis=0) if all_logits else np.zeros((0, 1))
171
+ targets_np = np.concatenate(all_targets, axis=0) if all_targets else np.zeros((0, 1))
172
+ metrics = compute_metrics(targets=targets_np, logits=logits_np)
173
+ metrics["loss"] = float(np.mean(losses)) if losses else 0.0
174
+ return metrics
175
+
176
+
177
+ def train() -> None:
178
+ args = parse_args()
179
+ log(f"Starting run_name={args.run_name} mode={args.mode} device_request={args.device}")
180
+ log(f"dataset_config={args.dataset_config}")
181
+ log(f"model_config={args.model_config}")
182
+ log(f"train_config={args.train_config}")
183
+ cfg = load_combined_config(args)
184
+ run_cfg = cfg.get("run", {})
185
+ seed = int(run_cfg.get("seed", 42))
186
+ deterministic = bool(run_cfg.get("deterministic", True))
187
+ set_seed(seed=seed, deterministic=deterministic)
188
+ log(f"Seed set to {seed}, deterministic={deterministic}")
189
+
190
+ smoke = args.mode == "smoke"
191
+ train_epochs = 2 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
192
+ batch_size = 8 if smoke else int(cfg["batching"].get("batch_size", 32))
193
+ eval_batch_size = 8 if smoke else int(cfg["batching"].get("eval_batch_size", 64))
194
+
195
+ cuda_available = torch.cuda.is_available()
196
+ selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
197
+ if args.device == "auto" and not cuda_available:
198
+ selected_device = "cpu"
199
+ if selected_device == "cuda" and not cuda_available:
200
+ raise RuntimeError(
201
+ "CUDA requested but not available in torch build/environment. "
202
+ "Install CUDA-enabled PyTorch in the active virtualenv."
203
+ )
204
+ device = torch.device(selected_device)
205
+ log(
206
+ f"Resolved device={device.type}, torch_cuda_available={cuda_available}, "
207
+ f"torch_version={torch.__version__}, torch_cuda={torch.version.cuda}"
208
+ )
209
+ log("Building dataloaders from processed manifests...")
210
+ train_loader, val_loader, test_loader, label_info = build_loaders(
211
+ cfg=cfg,
212
+ batch_size=batch_size,
213
+ eval_batch_size=eval_batch_size,
214
+ smoke=smoke,
215
+ )
216
+ log(
217
+ f"Dataloader sizes -> train_batches={len(train_loader)} val_batches={len(val_loader)} "
218
+ f"test_batches={len(test_loader)}"
219
+ )
220
+ label_vocab = label_info["vocab"]
221
+ label_freq = label_info["freq"]
222
+ num_labels = len(label_vocab)
223
+ log(f"Label space size: {num_labels}")
224
+ log("Building encoder backbones (first run may download pretrained weights)...")
225
+ model = build_model(cfg=cfg, num_labels=num_labels, device=device)
226
+ log("Model construction complete")
227
+ pos_weight = class_pos_weights(label_frequencies=label_freq, label_vocab=label_vocab).to(device)
228
+
229
+ lr = float(cfg["training"]["stage1"].get("lr_head", 1e-3))
230
+ weight_decay = float(cfg["optimization"].get("weight_decay", 1e-4))
231
+ optimizer = torch.optim.AdamW(
232
+ [p for p in model.parameters() if p.requires_grad],
233
+ lr=lr,
234
+ weight_decay=weight_decay,
235
+ )
236
+
237
+ output_root = REPO_ROOT / run_cfg.get("output_dir", "outputs")
238
+ run_dir = output_root / args.run_name
239
+ run_dir.mkdir(parents=True, exist_ok=True)
240
+
241
+ history: List[Dict[str, Any]] = []
242
+ best_map = -1.0
243
+ best_path = run_dir / "best.pt"
244
+ log(f"Training for epochs={train_epochs}, batch_size={batch_size}, eval_batch_size={eval_batch_size}")
245
+ for epoch in range(1, train_epochs + 1):
246
+ model.train()
247
+ train_losses: List[float] = []
248
+ for batch in tqdm(train_loader, desc=f"{args.run_name} epoch {epoch}/{train_epochs}"):
249
+ targets = batch["targets"].to(device)
250
+ logits = model(batch["images"], device=device)
251
+ loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
252
+ optimizer.zero_grad(set_to_none=True)
253
+ loss.backward()
254
+ optimizer.step()
255
+ train_losses.append(float(loss.item()))
256
+
257
+ val_metrics = run_eval(model=model, loader=val_loader, device=device, pos_weight=pos_weight)
258
+ epoch_row = {
259
+ "epoch": epoch,
260
+ "train_loss": float(np.mean(train_losses)) if train_losses else 0.0,
261
+ "val_loss": val_metrics["loss"],
262
+ "val_map": val_metrics["map"],
263
+ "val_macro_f1": val_metrics["macro_f1"],
264
+ "val_micro_f1": val_metrics["micro_f1"],
265
+ }
266
+ history.append(epoch_row)
267
+ log(
268
+ f"Epoch {epoch}/{train_epochs} -> train_loss={epoch_row['train_loss']:.4f} "
269
+ f"val_map={epoch_row['val_map']:.4f} val_macro_f1={epoch_row['val_macro_f1']:.4f}"
270
+ )
271
+ if val_metrics["map"] > best_map:
272
+ best_map = val_metrics["map"]
273
+ torch.save(
274
+ {
275
+ "model_state": model.state_dict(),
276
+ "label_vocab": label_vocab,
277
+ "config": cfg,
278
+ },
279
+ best_path,
280
+ )
281
+ log(f"Saved new best checkpoint at epoch {epoch}: {best_path}")
282
+
283
+ log("Running final test evaluation using best checkpoint...")
284
+ checkpoint = torch.load(best_path, map_location=device)
285
+ model.load_state_dict(checkpoint["model_state"])
286
+ test_metrics = run_eval(model=model, loader=test_loader, device=device, pos_weight=pos_weight)
287
+ payload = {
288
+ "run_name": args.run_name,
289
+ "mode": args.mode,
290
+ "num_labels": num_labels,
291
+ "best_val_map": best_map,
292
+ "history": history,
293
+ "test_metrics": test_metrics,
294
+ }
295
+ write_json(run_dir / "metrics.json", payload)
296
+ write_json(run_dir / "label_vocab.json", label_vocab)
297
+ with (run_dir / "overrides.json").open("w", encoding="utf-8") as handle:
298
+ json.dump(parse_overrides_json(args.overrides_json), handle, indent=2, ensure_ascii=True)
299
+ log(f"Training complete for {args.run_name}")
300
+ print(json.dumps({"run_name": args.run_name, "test_map": test_metrics["map"]}, indent=2), flush=True)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ train()
src/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Data pipeline utilities and processing modules."""
src/data/io_utils.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import tarfile
6
+ import time
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.request import Request, urlopen
12
+
13
+ import yaml
14
+
15
+
16
+ def load_yaml(path: Path) -> Dict[str, Any]:
17
+ with path.open("r", encoding="utf-8") as handle:
18
+ data = yaml.safe_load(handle)
19
+ return data or {}
20
+
21
+
22
+ def ensure_dir(path: Path) -> None:
23
+ path.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
27
+ digest = hashlib.sha256()
28
+ with path.open("rb") as handle:
29
+ while True:
30
+ block = handle.read(chunk_size)
31
+ if not block:
32
+ break
33
+ digest.update(block)
34
+ return digest.hexdigest()
35
+
36
+
37
+ def write_json(path: Path, payload: Dict[str, Any]) -> None:
38
+ ensure_dir(path.parent)
39
+ with path.open("w", encoding="utf-8") as handle:
40
+ json.dump(payload, handle, indent=2, ensure_ascii=True)
41
+
42
+
43
+ def read_json(path: Path) -> Any:
44
+ with path.open("r", encoding="utf-8") as handle:
45
+ return json.load(handle)
46
+
47
+
48
+ def _download_once(
49
+ url: str,
50
+ destination: Path,
51
+ timeout_seconds: int,
52
+ chunk_size: int,
53
+ ) -> None:
54
+ temp_file = destination.with_suffix(destination.suffix + ".part")
55
+ resume_pos = temp_file.stat().st_size if temp_file.exists() else 0
56
+
57
+ headers = {}
58
+ if resume_pos > 0:
59
+ headers["Range"] = f"bytes={resume_pos}-"
60
+
61
+ request = Request(url=url, headers=headers)
62
+ with urlopen(request, timeout=timeout_seconds) as response:
63
+ status = getattr(response, "status", 200)
64
+ supports_resume = status == 206 and resume_pos > 0
65
+ mode = "ab" if supports_resume else "wb"
66
+ with temp_file.open(mode) as out:
67
+ while True:
68
+ chunk = response.read(chunk_size)
69
+ if not chunk:
70
+ break
71
+ out.write(chunk)
72
+ temp_file.replace(destination)
73
+
74
+
75
+ def download_file(
76
+ url: str,
77
+ destination: Path,
78
+ timeout_seconds: int = 120,
79
+ retries: int = 3,
80
+ chunk_size: int = 1024 * 1024,
81
+ ) -> None:
82
+ ensure_dir(destination.parent)
83
+ attempts = max(1, retries)
84
+ for attempt in range(1, attempts + 1):
85
+ try:
86
+ _download_once(
87
+ url=url,
88
+ destination=destination,
89
+ timeout_seconds=timeout_seconds,
90
+ chunk_size=chunk_size,
91
+ )
92
+ return
93
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
94
+ if attempt == attempts:
95
+ raise RuntimeError(
96
+ f"Download failed after {attempts} attempts for {url}: {exc}"
97
+ ) from exc
98
+ sleep_s = min(10, attempt * 2)
99
+ time.sleep(sleep_s)
100
+
101
+
102
+ def verify_checksum(path: Path, expected_sha256: Optional[str]) -> bool:
103
+ if not expected_sha256:
104
+ return True
105
+ actual = sha256_file(path)
106
+ return actual.lower() == expected_sha256.lower()
107
+
108
+
109
+ def _is_within_directory(base_dir: Path, target_path: Path) -> bool:
110
+ try:
111
+ base = base_dir.resolve(strict=False)
112
+ target = target_path.resolve(strict=False)
113
+ target.relative_to(base)
114
+ return True
115
+ except ValueError:
116
+ return False
117
+
118
+
119
+ def extract_archive(archive_path: Path, output_dir: Path, safe_extract: bool = True) -> None:
120
+ ensure_dir(output_dir)
121
+ lower_name = archive_path.name.lower()
122
+
123
+ if zipfile.is_zipfile(archive_path):
124
+ with zipfile.ZipFile(archive_path, "r") as zf:
125
+ members = zf.infolist()
126
+ if safe_extract:
127
+ for member in members:
128
+ target = output_dir / member.filename
129
+ if not _is_within_directory(output_dir, target):
130
+ raise RuntimeError(
131
+ f"Unsafe zip member path detected: {member.filename}"
132
+ )
133
+ zf.extractall(output_dir)
134
+ return
135
+
136
+ if tarfile.is_tarfile(archive_path):
137
+ with tarfile.open(archive_path, "r:*") as tf:
138
+ members = tf.getmembers()
139
+ if safe_extract:
140
+ for member in members:
141
+ target = output_dir / member.name
142
+ if not _is_within_directory(output_dir, target):
143
+ raise RuntimeError(
144
+ f"Unsafe tar member path detected: {member.name}"
145
+ )
146
+ tf.extractall(output_dir)
147
+ return
148
+
149
+ raise RuntimeError(f"Unsupported archive format: {lower_name}")
src/data/normalization.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from typing import Dict, Iterable, List, Sequence, Set, Tuple
6
+
7
+
8
+ _NON_ALPHA_NUM = re.compile(r"[^a-z0-9 ]+")
9
+ _MULTI_SPACE = re.compile(r"\s+")
10
+
11
+
12
+ def simple_lemma(token: str) -> str:
13
+ if token.endswith("ies") and len(token) > 3:
14
+ return token[:-3] + "y"
15
+ if token.endswith("ses") and len(token) > 3:
16
+ return token[:-2]
17
+ if token.endswith("s") and len(token) > 3:
18
+ return token[:-1]
19
+ return token
20
+
21
+
22
+ def normalize_text(value: str, lowercase: bool = True, lemmatize: bool = True) -> str:
23
+ text = value.strip()
24
+ if lowercase:
25
+ text = text.lower()
26
+ text = _NON_ALPHA_NUM.sub(" ", text)
27
+ text = _MULTI_SPACE.sub(" ", text).strip()
28
+ if not text:
29
+ return ""
30
+ if lemmatize:
31
+ text = " ".join(simple_lemma(part) for part in text.split(" "))
32
+ return text
33
+
34
+
35
+ def normalize_label(
36
+ label: str,
37
+ lowercase: bool,
38
+ lemmatize: bool,
39
+ synonym_map: Dict[str, str],
40
+ ) -> str:
41
+ normalized = normalize_text(label, lowercase=lowercase, lemmatize=lemmatize)
42
+ if not normalized:
43
+ return ""
44
+ if normalized in synonym_map:
45
+ return synonym_map[normalized]
46
+ return normalized
47
+
48
+
49
+ def normalize_labels(
50
+ labels: Sequence[str],
51
+ lowercase: bool,
52
+ lemmatize: bool,
53
+ synonym_map: Dict[str, str],
54
+ keep_unmapped: bool,
55
+ allowed_labels: Set[str],
56
+ ) -> List[str]:
57
+ out: List[str] = []
58
+ for label in labels:
59
+ normalized = normalize_label(
60
+ label=label,
61
+ lowercase=lowercase,
62
+ lemmatize=lemmatize,
63
+ synonym_map=synonym_map,
64
+ )
65
+ if not normalized:
66
+ continue
67
+ if normalized in allowed_labels or keep_unmapped:
68
+ out.append(normalized)
69
+ return sorted(set(out))
70
+
71
+
72
+ def filter_by_min_support(
73
+ normalized_label_lists: Sequence[Sequence[str]],
74
+ min_support: int,
75
+ ) -> Tuple[List[List[str]], Dict[str, int]]:
76
+ counter: Counter[str] = Counter()
77
+ for labels in normalized_label_lists:
78
+ counter.update(set(labels))
79
+
80
+ keep_labels = {name for name, count in counter.items() if count >= min_support}
81
+ filtered: List[List[str]] = []
82
+ for labels in normalized_label_lists:
83
+ filtered.append(sorted([label for label in set(labels) if label in keep_labels]))
84
+
85
+ filtered_counter: Counter[str] = Counter()
86
+ for labels in filtered:
87
+ filtered_counter.update(set(labels))
88
+ return filtered, dict(sorted(filtered_counter.items()))
89
+
90
+
91
+ def build_label_vocab(freq_map: Dict[str, int]) -> Dict[str, int]:
92
+ labels = sorted(freq_map.keys())
93
+ return {label: idx for idx, label in enumerate(labels)}
src/data/schemas.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Dict, List
5
+
6
+
7
+ @dataclass
8
+ class SampleRecord:
9
+ image_id: int
10
+ object_id: int
11
+ object_name: str
12
+ image_url: str
13
+ image_path: str
14
+ attributes_raw: List[str]
15
+ attributes_norm: List[str]
16
+
17
+ def to_dict(self) -> Dict[str, object]:
18
+ return asdict(self)
src/train/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Training utilities for baseline models."""
src/train/config_utils.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+
8
+ import yaml
9
+
10
+
11
+ def load_yaml(path: Path) -> Dict[str, Any]:
12
+ with path.open("r", encoding="utf-8") as handle:
13
+ payload = yaml.safe_load(handle)
14
+ return payload or {}
15
+
16
+
17
+ def deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
18
+ merged = copy.deepcopy(base)
19
+ for key, value in update.items():
20
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
21
+ merged[key] = deep_merge(merged[key], value)
22
+ else:
23
+ merged[key] = copy.deepcopy(value)
24
+ return merged
25
+
26
+
27
+ def set_dot_key(target: Dict[str, Any], dot_key: str, value: Any) -> None:
28
+ parts = dot_key.split(".")
29
+ current = target
30
+ for part in parts[:-1]:
31
+ if part not in current or not isinstance(current[part], dict):
32
+ current[part] = {}
33
+ current = current[part]
34
+ current[parts[-1]] = value
35
+
36
+
37
+ def apply_dot_overrides(target: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
38
+ out = copy.deepcopy(target)
39
+ for dot_key, value in overrides.items():
40
+ set_dot_key(out, dot_key, value)
41
+ return out
42
+
43
+
44
+ def parse_overrides_json(raw: str | None) -> Dict[str, Any]:
45
+ if not raw:
46
+ return {}
47
+ payload = json.loads(raw)
48
+ if not isinstance(payload, dict):
49
+ raise ValueError("Overrides JSON must be an object.")
50
+ return payload
src/train/dataset.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Tuple
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request, urlopen
9
+
10
+ import torch
11
+ from PIL import Image, UnidentifiedImageError
12
+ from torch.utils.data import DataLoader, Dataset
13
+
14
+
15
+ def load_json(path: Path) -> Any:
16
+ with path.open("r", encoding="utf-8") as handle:
17
+ return json.load(handle)
18
+
19
+
20
+ class ChairAttributeDataset(Dataset):
21
+ def __init__(
22
+ self,
23
+ split_path: Path,
24
+ label_vocab_path: Path,
25
+ repo_root: Path,
26
+ image_cache_dir: Path,
27
+ ) -> None:
28
+ payload = load_json(split_path)
29
+ if "samples" not in payload:
30
+ raise RuntimeError(f"Invalid split file format: {split_path}")
31
+ self.samples: List[Dict[str, Any]] = payload["samples"]
32
+ self.label_vocab: Dict[str, int] = load_json(label_vocab_path)
33
+ self.num_labels = len(self.label_vocab)
34
+ self.repo_root = repo_root
35
+ self.image_cache_dir = image_cache_dir
36
+ self.image_cache_dir.mkdir(parents=True, exist_ok=True)
37
+ self.corrupt_image_count = 0
38
+
39
+ def __len__(self) -> int:
40
+ return len(self.samples)
41
+
42
+ def _resolve_image_path(self, row: Dict[str, Any]) -> Path:
43
+ local_rel = row.get("image_path", "")
44
+ if local_rel:
45
+ local = self.repo_root / local_rel
46
+ if local.exists():
47
+ return local
48
+ image_id = str(row.get("image_id"))
49
+ return self.image_cache_dir / f"{image_id}.jpg"
50
+
51
+ def _candidate_urls(self, row: Dict[str, Any]) -> List[str]:
52
+ primary = str(row.get("image_url") or "").strip()
53
+ if not primary:
54
+ return []
55
+ candidates = [primary]
56
+ if "/VG_100K/" in primary:
57
+ candidates.append(primary.replace("/VG_100K/", "/VG_100K_2/"))
58
+ elif "/VG_100K_2/" in primary:
59
+ candidates.append(primary.replace("/VG_100K_2/", "/VG_100K/"))
60
+ deduped: List[str] = []
61
+ seen = set()
62
+ for url in candidates:
63
+ if url in seen:
64
+ continue
65
+ seen.add(url)
66
+ deduped.append(url)
67
+ return deduped
68
+
69
+ def _download_if_missing(self, row: Dict[str, Any], path: Path, force: bool = False) -> None:
70
+ if path.exists() and not force:
71
+ return
72
+ urls = self._candidate_urls(row)
73
+ if not urls:
74
+ raise RuntimeError(f"Missing image_url for image_id={row.get('image_id')}")
75
+ last_error: Exception | None = None
76
+ for url in urls:
77
+ try:
78
+ request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
79
+ with urlopen(request, timeout=30) as response:
80
+ image_bytes = response.read()
81
+ # Validate bytes are actually decodable image data before caching.
82
+ with Image.open(BytesIO(image_bytes)) as probe:
83
+ probe.verify()
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ with path.open("wb") as handle:
86
+ handle.write(image_bytes)
87
+ return
88
+ except (HTTPError, URLError, TimeoutError, OSError, UnidentifiedImageError) as exc:
89
+ last_error = exc
90
+ continue
91
+ raise RuntimeError(
92
+ f"Failed to download valid image for image_id={row.get('image_id')} from candidate URLs."
93
+ ) from last_error
94
+
95
+ def _open_local_image(self, path: Path) -> Image.Image:
96
+ with Image.open(path) as image:
97
+ return image.convert("RGB")
98
+
99
+ def _load_image(self, row: Dict[str, Any]) -> Image.Image:
100
+ image_path = self._resolve_image_path(row)
101
+ self._download_if_missing(row, image_path)
102
+ try:
103
+ return self._open_local_image(image_path)
104
+ except (UnidentifiedImageError, OSError):
105
+ self.corrupt_image_count += 1
106
+ if image_path.exists():
107
+ image_path.unlink(missing_ok=True)
108
+ # Retry once by re-downloading. Some VG image URLs occasionally produce
109
+ # partial/bad files; this makes loading resilient instead of crashing workers.
110
+ try:
111
+ self._download_if_missing(row, image_path, force=True)
112
+ return self._open_local_image(image_path)
113
+ except Exception:
114
+ return Image.new("RGB", (224, 224), color=(0, 0, 0))
115
+
116
+ def _labels_to_multihot(self, labels: List[str]) -> torch.Tensor:
117
+ target = torch.zeros(self.num_labels, dtype=torch.float32)
118
+ for label in labels:
119
+ if label in self.label_vocab:
120
+ target[self.label_vocab[label]] = 1.0
121
+ return target
122
+
123
+ def __getitem__(self, idx: int) -> Dict[str, Any]:
124
+ row = self.samples[idx]
125
+ image = self._load_image(row)
126
+ labels = row.get("attributes_norm", [])
127
+ target = self._labels_to_multihot(labels)
128
+ return {
129
+ "image": image,
130
+ "target": target,
131
+ "image_id": int(row["image_id"]),
132
+ "labels": labels,
133
+ }
134
+
135
+
136
+ def collate_samples(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
137
+ return {
138
+ "images": [item["image"] for item in batch],
139
+ "targets": torch.stack([item["target"] for item in batch], dim=0),
140
+ "image_ids": [item["image_id"] for item in batch],
141
+ "labels": [item["labels"] for item in batch],
142
+ }
143
+
144
+
145
+ def build_dataloader(
146
+ dataset: Dataset,
147
+ batch_size: int,
148
+ shuffle: bool,
149
+ num_workers: int,
150
+ pin_memory: bool,
151
+ ) -> DataLoader:
152
+ return DataLoader(
153
+ dataset,
154
+ batch_size=batch_size,
155
+ shuffle=shuffle,
156
+ num_workers=num_workers,
157
+ pin_memory=pin_memory,
158
+ collate_fn=collate_samples,
159
+ )
160
+
161
+
162
+ def class_pos_weights(label_frequencies: Dict[str, int], label_vocab: Dict[str, int]) -> torch.Tensor:
163
+ counts = torch.ones(len(label_vocab), dtype=torch.float32)
164
+ for label, idx in label_vocab.items():
165
+ counts[idx] = float(max(1, label_frequencies.get(label, 1)))
166
+ weights = counts.max() / counts
167
+ return weights
168
+
169
+
170
+ def split_paths(processed_root: Path, split_dir_name: str = "splits") -> Dict[str, Path]:
171
+ split_dir = processed_root / split_dir_name
172
+ return {
173
+ "train": split_dir / "train.json",
174
+ "val": split_dir / "val.json",
175
+ "test": split_dir / "test.json",
176
+ }
src/train/encoders.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Tuple
4
+
5
+ import timm
6
+ import torch
7
+ from PIL import Image
8
+ from timm.data import create_transform, resolve_data_config
9
+
10
+
11
+ class ImageBackbone(torch.nn.Module):
12
+ def __init__(self, model_name: str, pretrained: bool = True) -> None:
13
+ super().__init__()
14
+ self.model = timm.create_model(model_name, pretrained=pretrained, num_classes=0)
15
+ cfg = resolve_data_config(self.model.pretrained_cfg, model=self.model)
16
+ self.transform = create_transform(**cfg, is_training=False)
17
+ self.output_dim = getattr(self.model, "num_features", None)
18
+ if self.output_dim is None:
19
+ raise RuntimeError(f"Could not infer output dim for {model_name}")
20
+
21
+ def forward_pil(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
22
+ batch = torch.stack([self.transform(img) for img in images], dim=0).to(device)
23
+ return self.model(batch)
24
+
25
+
26
+ def build_backbones(
27
+ use_clip: bool,
28
+ use_dino: bool,
29
+ clip_model_name: str,
30
+ dino_model_name: str,
31
+ clip_pretrained: bool,
32
+ dino_pretrained: bool,
33
+ ) -> Tuple[ImageBackbone | None, ImageBackbone | None, int]:
34
+ clip = None
35
+ dino = None
36
+ feature_dim = 0
37
+ if use_clip:
38
+ clip = ImageBackbone(model_name=clip_model_name, pretrained=clip_pretrained)
39
+ feature_dim += int(clip.output_dim)
40
+ if use_dino:
41
+ dino = ImageBackbone(model_name=dino_model_name, pretrained=dino_pretrained)
42
+ feature_dim += int(dino.output_dim)
43
+ if feature_dim <= 0:
44
+ raise RuntimeError("At least one encoder must be enabled for baseline training.")
45
+ return clip, dino, feature_dim
src/train/losses.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+
6
+ def bce_logits_loss(logits: torch.Tensor, targets: torch.Tensor, pos_weight: torch.Tensor) -> torch.Tensor:
7
+ loss_fn = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight)
8
+ return loss_fn(logits, targets)
src/train/metrics.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ import numpy as np
6
+ from sklearn.metrics import average_precision_score, f1_score
7
+
8
+
9
+ def _safe_average_precision(y_true: np.ndarray, y_score: np.ndarray) -> float:
10
+ if np.unique(y_true).size < 2:
11
+ return 0.0
12
+ return float(average_precision_score(y_true, y_score))
13
+
14
+
15
+ def compute_metrics(
16
+ targets: np.ndarray,
17
+ logits: np.ndarray,
18
+ threshold: float = 0.5,
19
+ ) -> Dict[str, Any]:
20
+ probs = 1.0 / (1.0 + np.exp(-logits))
21
+ preds = (probs >= threshold).astype(np.int32)
22
+ per_attr_ap = []
23
+ per_attr_f1 = []
24
+ for idx in range(targets.shape[1]):
25
+ yt = targets[:, idx]
26
+ yp = preds[:, idx]
27
+ ys = probs[:, idx]
28
+ per_attr_ap.append(_safe_average_precision(yt, ys))
29
+ per_attr_f1.append(float(f1_score(yt, yp, zero_division=0)))
30
+
31
+ macro_f1 = float(f1_score(targets, preds, average="macro", zero_division=0))
32
+ micro_f1 = float(f1_score(targets, preds, average="micro", zero_division=0))
33
+ map_score = float(np.mean(per_attr_ap)) if per_attr_ap else 0.0
34
+ return {
35
+ "map": map_score,
36
+ "macro_f1": macro_f1,
37
+ "micro_f1": micro_f1,
38
+ "per_attribute_ap": per_attr_ap,
39
+ "per_attribute_f1": per_attr_f1,
40
+ }
src/train/models.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+
5
+ import torch
6
+ from PIL import Image
7
+
8
+ from .encoders import ImageBackbone
9
+
10
+
11
+ class BaselineClassifier(torch.nn.Module):
12
+ def __init__(
13
+ self,
14
+ clip_backbone: ImageBackbone | None,
15
+ dino_backbone: ImageBackbone | None,
16
+ feature_dim: int,
17
+ hidden_dims: List[int],
18
+ dropout: float,
19
+ num_labels: int,
20
+ ) -> None:
21
+ super().__init__()
22
+ self.clip_backbone = clip_backbone
23
+ self.dino_backbone = dino_backbone
24
+ layers: List[torch.nn.Module] = []
25
+ input_dim = feature_dim
26
+ for hidden in hidden_dims:
27
+ layers.append(torch.nn.Linear(input_dim, hidden))
28
+ layers.append(torch.nn.ReLU())
29
+ layers.append(torch.nn.Dropout(dropout))
30
+ input_dim = hidden
31
+ layers.append(torch.nn.Linear(input_dim, num_labels))
32
+ self.classifier = torch.nn.Sequential(*layers)
33
+
34
+ def encode(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
35
+ features = []
36
+ if self.clip_backbone is not None:
37
+ features.append(self.clip_backbone.forward_pil(images, device=device))
38
+ if self.dino_backbone is not None:
39
+ features.append(self.dino_backbone.forward_pil(images, device=device))
40
+ if len(features) == 1:
41
+ return features[0]
42
+ return torch.cat(features, dim=1)
43
+
44
+ def forward(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
45
+ feats = self.encode(images=images, device=device)
46
+ return self.classifier(feats)
47
+
48
+ def freeze_backbones(self) -> None:
49
+ for backbone in (self.clip_backbone, self.dino_backbone):
50
+ if backbone is None:
51
+ continue
52
+ for param in backbone.parameters():
53
+ param.requires_grad = False