reid 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reid/catalog.py ADDED
@@ -0,0 +1,222 @@
1
+ # ------------------------------------------------------------------------
2
+ # re-ID
3
+ # Copyright (c) 2026 Roboflow. All Rights Reserved.
4
+ # Licensed under the Apache License, Version 2.0 [see LICENSE for details]
5
+ # ------------------------------------------------------------------------
6
+
7
+ """Curated ReID aliases, ``ModelCard``, and ``reid_config.json`` I/O."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+
15
+ from huggingface_hub import hf_hub_download
16
+ from huggingface_hub.utils import EntryNotFoundError, HfHubHTTPError
17
+
18
+ from reid.architectures.fastreid_sbs import FASTREID_SBS_ARCHITECTURE
19
+ from reid.preprocessing import ReIDPreprocessing
20
+
21
+ # OSNet x1.0 trained on MSMT17 with combineall (train + query + gallery).
22
+ # Strong general-purpose pedestrian features, but combineall includes MSMT17
23
+ # test IDs — not suitable for MSMT17 evaluation.
24
+ _DEFAULT_OSNET_WEIGHTS = (
25
+ "hf://kaiyangzhou/osnet/"
26
+ "osnet_x1_0_msmt17_combineall_256x128_amsgrad_ep150_stp60_lr0.0015_b64_fb10"
27
+ "_softmax_labelsmooth_flip_jitter.pth"
28
+ )
29
+
30
+ _DOMAIN_WARNING = (
31
+ "The default ReIDModel weights were trained on MSMT17 pedestrian images. "
32
+ "Performance may degrade significantly on non-person objects (vehicles, "
33
+ "animals, products, etc.). Pass a domain-specific checkpoint via "
34
+ "`ReIDModel.from_pretrained(source, architecture=...)` for other use cases."
35
+ )
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Public constants and types
39
+ # ---------------------------------------------------------------------------
40
+
41
+ DEFAULT_MODEL = "osnet_x1_0_msmt17_combineall"
42
+ """Curated alias used when ``ReIDModel.from_pretrained`` gets no ``source``."""
43
+
44
+ # BoT-SORT MOT17 SBS-S50 (ResNeSt50 + GeM + BNNeck), trained on MOT17 train-half GT crops.
45
+ # Weights: https://github.com/niraharon/bot-sort#model-zoo
46
+ _FASTREID_MOT17_WEIGHTS = "gd://1QZFWpoa80rqo7O-HXmlss8J8CnS7IUsN/mot17_sbs_S50.pth"
47
+ _FASTREID_MOT17_WARNING = (
48
+ "The fastreid_mot17_sbs50 weights were trained on MOT17 pedestrian crops. "
49
+ "Use this encoder for MOT tracking benchmarks (BoT-SORT-ReID replication); "
50
+ "cross-domain retrieval on Market-1501 / MSMT17 is expected to underperform."
51
+ )
52
+
53
+ FASTREID_MOT17_SBS50 = "fastreid_mot17_sbs50"
54
+
55
+
56
+ @dataclass
57
+ class ModelCard:
58
+ """Architecture, weights source, and optional domain metadata.
59
+
60
+ Produced by curated ``ALIASES`` entries or by loading ``reid_config.json``
61
+ from a ``save_pretrained`` directory / HF repo. Preprocessing for curated
62
+ aliases is derived from the architecture at load time; saved configs may
63
+ carry a preprocessing override in ``reid_config.json`` (see
64
+ :func:`resolve_model_card`).
65
+ """
66
+
67
+ architecture: str
68
+ weights: str | None
69
+ domain_warning: str | None = None
70
+
71
+
72
+ ALIASES: dict[str, ModelCard] = {
73
+ DEFAULT_MODEL: ModelCard(
74
+ architecture="osnet_x1_0",
75
+ weights=_DEFAULT_OSNET_WEIGHTS,
76
+ domain_warning=_DOMAIN_WARNING,
77
+ ),
78
+ FASTREID_MOT17_SBS50: ModelCard(
79
+ architecture=FASTREID_SBS_ARCHITECTURE,
80
+ weights=_FASTREID_MOT17_WEIGHTS,
81
+ domain_warning=_FASTREID_MOT17_WARNING,
82
+ ),
83
+ }
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Resolution helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def list_aliases() -> list[str]:
91
+ """Return curated alias names (sorted keys of :data:`ALIASES`)."""
92
+ return sorted(ALIASES)
93
+
94
+
95
+ def resolve_model_card(source: str) -> tuple[ModelCard, ReIDPreprocessing | None] | None:
96
+ """Resolve an alias or ``reid_config.json`` directory/repo to a load recipe.
97
+
98
+ Returns ``None`` when ``source`` is a bare weights *file* URL/path without a
99
+ config. Curated aliases return ``(card, None)``; saved / HF configs return
100
+ ``(card, preprocessing)`` with the preprocessing dict from
101
+ ``reid_config.json``.
102
+
103
+ Raises:
104
+ ValueError: If ``source`` is a local directory that does not contain
105
+ ``reid_config.json`` (directories are never bare weight files).
106
+ """
107
+ if source in ALIASES:
108
+ return ALIASES[source], None
109
+
110
+ # Local directory must be a save_pretrained() tree (reid_config.json).
111
+ if os.path.isdir(source):
112
+ config_path = os.path.join(source, "reid_config.json")
113
+ if not os.path.isfile(config_path):
114
+ raise ValueError(
115
+ f"Cannot load {source!r}: directory has no reid_config.json. "
116
+ "Point to a directory produced by ReIDModel.save_pretrained(), "
117
+ "or pass a weights *file* path together with architecture=... ."
118
+ )
119
+ return load_model_config(source)
120
+
121
+ # HF repo URL (no trailing filename) — try to fetch reid_config.json.
122
+ # hf://org/repo has exactly 2 non-empty parts after "hf://".
123
+ # hf://org/repo/file.pth has 3+ parts and is a bare weights file.
124
+ if source.startswith("hf://"):
125
+ rest = source[len("hf://") :]
126
+ parts = [p for p in rest.split("/") if p]
127
+ if len(parts) == 2:
128
+ try:
129
+ return _load_hf_repo_config(source)
130
+ except (HfHubHTTPError, EntryNotFoundError, OSError, ValueError) as exc:
131
+ raise RuntimeError(f"Failed to resolve Hugging Face repo config for {source!r}.") from exc
132
+
133
+ return None
134
+
135
+
136
+ def load_model_config(directory_or_repo: str) -> tuple[ModelCard, ReIDPreprocessing]:
137
+ """Load a ``ModelCard`` and preprocessing from ``reid_config.json``.
138
+
139
+ Local directories may also include ``weights.safetensors``.
140
+ """
141
+ if directory_or_repo.startswith("hf://"):
142
+ card, preprocessing = _load_hf_repo_config(directory_or_repo)
143
+ if preprocessing is None:
144
+ raise ValueError(f"No preprocessing found in config for {directory_or_repo!r}.")
145
+ return card, preprocessing
146
+
147
+ config_path = os.path.join(directory_or_repo, "reid_config.json")
148
+ if not os.path.exists(config_path):
149
+ raise FileNotFoundError(
150
+ f"No reid_config.json found in {directory_or_repo!r}. "
151
+ "Use ReIDModel.save_pretrained() to create a self-describing "
152
+ "model directory."
153
+ )
154
+ weights_path = os.path.join(directory_or_repo, "weights.safetensors")
155
+ weights_source = weights_path if os.path.exists(weights_path) else None
156
+ return _parse_config_file(config_path, weights_source=weights_source)
157
+
158
+
159
+ def save_model_config(
160
+ card: ModelCard,
161
+ directory: str,
162
+ *,
163
+ preprocessing: ReIDPreprocessing,
164
+ ) -> None:
165
+ """Write ``reid_config.json`` to ``directory``."""
166
+ if not isinstance(card.architecture, str):
167
+ raise ValueError(
168
+ "Cannot save a ModelCard whose architecture is an nn.Module. "
169
+ "Use a named architecture string (e.g. 'osnet_x1_0', "
170
+ "'timm:resnet50') to enable save_pretrained()."
171
+ )
172
+
173
+ config = {
174
+ "architecture": card.architecture,
175
+ "preprocessing": preprocessing.to_dict(),
176
+ }
177
+ config_path = os.path.join(directory, "reid_config.json")
178
+ with open(config_path, "w") as f:
179
+ json.dump(config, f, indent=2)
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Internal helpers
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def _load_hf_repo_config(hf_repo_url: str) -> tuple[ModelCard, ReIDPreprocessing | None]:
188
+ """Download and parse ``reid_config.json`` from an HF repo URL."""
189
+ rest = hf_repo_url[len("hf://") :]
190
+ parts = [p for p in rest.split("/") if p]
191
+ repo_id = "/".join(parts[:2])
192
+ revision = None
193
+ if "@" in repo_id:
194
+ repo_id, revision = repo_id.split("@", 1)
195
+
196
+ config_path = hf_hub_download(
197
+ repo_id=repo_id,
198
+ filename="reid_config.json",
199
+ revision=revision,
200
+ )
201
+ weights_source = hf_repo_url.rstrip("/") + "/weights.safetensors"
202
+ return _parse_config_file(config_path, weights_source=weights_source)
203
+
204
+
205
+ def _parse_config_file(
206
+ config_path: str,
207
+ *,
208
+ weights_source: str | None,
209
+ ) -> tuple[ModelCard, ReIDPreprocessing]:
210
+ """Parse a ``reid_config.json`` file into a ``ModelCard`` and preprocessing."""
211
+ with open(config_path) as f:
212
+ data = json.load(f)
213
+
214
+ architecture = data["architecture"]
215
+ preprocessing = ReIDPreprocessing.from_dict(data.get("preprocessing", {}))
216
+ return (
217
+ ModelCard(
218
+ architecture=architecture,
219
+ weights=weights_source,
220
+ ),
221
+ preprocessing,
222
+ )
reid/data/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # ------------------------------------------------------------------------
2
+ # re-ID
3
+ # Copyright (c) 2026 Roboflow. All Rights Reserved.
4
+ # Licensed under the Apache License, Version 2.0 [see LICENSE for details]
5
+ # ------------------------------------------------------------------------
6
+
7
+ """Shared ReID dataset types, benchmark loaders, and crop partitioners."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from reid.data.benchmarks import load_market1501, load_msmt17
12
+ from reid.data.identity_crops import (
13
+ build_identity_crop_holdout_split,
14
+ build_identity_crop_retrieval_split,
15
+ load_identity_crops,
16
+ )
17
+ from reid.data.splits import MARKET1501_GALLERY_JUNK_PIDS, ReIDSplit
18
+
19
+ __all__ = [
20
+ "MARKET1501_GALLERY_JUNK_PIDS",
21
+ "ReIDSplit",
22
+ "build_identity_crop_holdout_split",
23
+ "build_identity_crop_retrieval_split",
24
+ "load_identity_crops",
25
+ "load_market1501",
26
+ "load_msmt17",
27
+ ]
@@ -0,0 +1,163 @@
1
+ # ------------------------------------------------------------------------
2
+ # re-ID
3
+ # Copyright (c) 2026 Roboflow. All Rights Reserved.
4
+ # Licensed under the Apache License, Version 2.0 [see LICENSE for details]
5
+ # ------------------------------------------------------------------------
6
+
7
+ """ReID benchmark dataset loaders (Market-1501, MSMT17)."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from reid.data.splits import MARKET1501_GALLERY_JUNK_PIDS, ReIDSplit
16
+
17
+ # --------------------------------------------------------------------------- #
18
+ # MSMT17
19
+ # --------------------------------------------------------------------------- #
20
+
21
+
22
+ def _to_zero_indexed_camid(camid_1indexed: int) -> int:
23
+ """Convert a 1-indexed MSMT17 camera id to the 0-indexed value used here.
24
+
25
+ MSMT17 camera ids are 1-indexed both in image filenames (the camera field)
26
+ and in the optional camid column of the list files, so both paths share this
27
+ single conversion to stay consistent.
28
+ """
29
+ return camid_1indexed - 1
30
+
31
+
32
+ def _parse_msmt17_camid(filename: str) -> int:
33
+ """Extract 0-indexed camid from an MSMT17 image filename.
34
+
35
+ Filename format: ``<pid>_<idx>_<camid>_<scene>_<frame>_<track>.jpg``
36
+ Example: ``0001_019_07_0303morning_0020_1.jpg`` → camid = 6 (0-indexed).
37
+ """
38
+ stem = Path(filename).stem
39
+ return _to_zero_indexed_camid(int(stem.split("_")[2]))
40
+
41
+
42
+ def _load_msmt17_list(list_file: Path, image_root: Path) -> ReIDSplit:
43
+ """Load one MSMT17 query/gallery list file."""
44
+ if not list_file.exists():
45
+ raise FileNotFoundError(f"MSMT17 list file not found: {list_file}")
46
+ paths, pids, camids = [], [], []
47
+ for line in list_file.read_text().splitlines():
48
+ line = line.strip()
49
+ if not line:
50
+ continue
51
+ parts = line.split()
52
+ if len(parts) < 2:
53
+ continue
54
+ rel_path = parts[0]
55
+ pid = int(parts[1])
56
+ camid = _to_zero_indexed_camid(int(parts[2])) if len(parts) >= 3 else _parse_msmt17_camid(Path(rel_path).name)
57
+ paths.append(str(image_root / rel_path))
58
+ pids.append(pid)
59
+ camids.append(camid)
60
+ return ReIDSplit(
61
+ image_paths=paths,
62
+ pids=np.array(pids, dtype=np.int32),
63
+ camids=np.array(camids, dtype=np.int32),
64
+ gallery_junk_pids=frozenset({-1}),
65
+ )
66
+
67
+
68
+ def load_msmt17(root: str | Path) -> tuple[ReIDSplit, ReIDSplit]:
69
+ """Load MSMT17 query and gallery splits from ``root``.
70
+
71
+ Expects ``test/``, ``list_query.txt``, and ``list_gallery.txt``. List files
72
+ may be 2-column (``path pid``; camid parsed from the filename) or 3-column
73
+ (``path pid camid``). In both cases the camera id is treated as 1-indexed
74
+ and converted to 0-indexed, so the two formats stay consistent.
75
+
76
+ Args:
77
+ root: Path to the MSMT17 directory.
78
+
79
+ Returns:
80
+ Query and gallery as ``(ReIDSplit, ReIDSplit)``.
81
+
82
+ Raises:
83
+ FileNotFoundError: If ``root``, ``list_query.txt``, or ``list_gallery.txt``
84
+ does not exist.
85
+ """
86
+ root = Path(root)
87
+ if not root.exists():
88
+ raise FileNotFoundError(f"MSMT17 root not found: {root}")
89
+
90
+ test_root = root / "test"
91
+ query = _load_msmt17_list(root / "list_query.txt", test_root)
92
+ gallery = _load_msmt17_list(root / "list_gallery.txt", test_root)
93
+ return query, gallery
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # Market-1501
98
+ # --------------------------------------------------------------------------- #
99
+
100
+
101
+ def _parse_market_filename(filename: str) -> tuple[int, int]:
102
+ """Parse ``(pid, camid)`` from a Market-1501 filename.
103
+
104
+ ``-1_*`` → junk (``pid=-1``); ``0000_*`` → distractor (``pid=0``).
105
+ """
106
+ stem = Path(filename).stem
107
+ parts = stem.split("_")
108
+ pid = int(parts[0])
109
+ camid = int(parts[1][1]) - 1 # "c1" → 0
110
+ return pid, camid
111
+
112
+
113
+ def _load_market1501_dir(
114
+ subdir: Path,
115
+ *,
116
+ gallery_junk_pids: frozenset[int],
117
+ ) -> ReIDSplit:
118
+ """Load one Market-1501 image directory."""
119
+ if not subdir.exists():
120
+ raise FileNotFoundError(f"Market-1501 sub-directory not found: {subdir}")
121
+ paths, pids, camids = [], [], []
122
+ for img_path in sorted(subdir.glob("*.jpg")):
123
+ pid, camid = _parse_market_filename(img_path.name)
124
+ paths.append(str(img_path))
125
+ pids.append(pid)
126
+ camids.append(camid)
127
+ return ReIDSplit(
128
+ image_paths=paths,
129
+ pids=np.array(pids, dtype=np.int32),
130
+ camids=np.array(camids, dtype=np.int32),
131
+ gallery_junk_pids=gallery_junk_pids,
132
+ )
133
+
134
+
135
+ def load_market1501(root: str | Path) -> tuple[ReIDSplit, ReIDSplit]:
136
+ """Load Market-1501 query and gallery splits from ``root``.
137
+
138
+ Expects ``query/`` and ``bounding_box_test/``. Person and camera IDs are
139
+ parsed from filenames.
140
+
141
+ Args:
142
+ root: Path to the Market-1501 directory.
143
+
144
+ Returns:
145
+ Query and gallery as ``(ReIDSplit, ReIDSplit)``.
146
+
147
+ Raises:
148
+ FileNotFoundError: If ``root``, ``query/``, or ``bounding_box_test/``
149
+ does not exist.
150
+ """
151
+ root = Path(root)
152
+ if not root.exists():
153
+ raise FileNotFoundError(f"Market-1501 root not found: {root}")
154
+
155
+ query = _load_market1501_dir(
156
+ root / "query",
157
+ gallery_junk_pids=frozenset({-1}),
158
+ )
159
+ gallery = _load_market1501_dir(
160
+ root / "bounding_box_test",
161
+ gallery_junk_pids=MARKET1501_GALLERY_JUNK_PIDS,
162
+ )
163
+ return query, gallery
@@ -0,0 +1,271 @@
1
+ # ------------------------------------------------------------------------
2
+ # re-ID
3
+ # Copyright (c) 2026 Roboflow. All Rights Reserved.
4
+ # Licensed under the Apache License, Version 2.0 [see LICENSE for details]
5
+ # ------------------------------------------------------------------------
6
+
7
+ """Load and partition per-identity crop folders into ``ReIDSplit`` objects.
8
+
9
+ The canonical flow is ``source format -> loader -> ReIDSplit -> optional
10
+ partitioning -> Dataset/evaluator``. :func:`load_identity_crops` turns an
11
+ identity-folder layout (``<root>/<identity>/<crop>.jpg``) into a single
12
+ ``ReIDSplit``. The ``build_identity_crop_*`` partitioners then consume that split to
13
+ produce query/gallery (and optional train) splits; they never touch the
14
+ filesystem.
15
+
16
+ Query and gallery crops receive synthetic ``camid`` 0/1 so single-camera MOT
17
+ crops still form valid cross-camera retrieval pairs under the gallery-eval junk
18
+ rule.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import random
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ from reid.data.splits import ReIDSplit
29
+
30
+ # Image suffixes scanned inside each identity folder. Kept private to this
31
+ # module; there is no shared ``constants`` module by design.
32
+ _IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp")
33
+
34
+ _QUERY_CAMID = 0
35
+ _GALLERY_CAMID = 1
36
+
37
+
38
+ def load_identity_crops(
39
+ root: str | Path,
40
+ *,
41
+ identities: list[str] | None = None,
42
+ ) -> ReIDSplit:
43
+ """Scan an identity-folder crop dataset into a single :class:`ReIDSplit`.
44
+
45
+ Expects ``<root>/<identity>/<crop>.(jpg|jpeg|png|bmp)``. Identity folders are
46
+ sorted by name and assigned contiguous person IDs (``0..N-1``). Camera IDs
47
+ are set to ``-1`` because on-disk crops carry no camera information; the
48
+ ``build_identity_crop_*`` partitioners assign synthetic camera IDs where retrieval
49
+ needs them.
50
+
51
+ Args:
52
+ root: Dataset root with one subdirectory per identity.
53
+ identities: Optional whitelist of identity folder names. Folders outside
54
+ the whitelist are skipped before person IDs are assigned.
55
+
56
+ Returns:
57
+ A :class:`ReIDSplit` over every crop found, with contiguous ``pids`` and
58
+ ``camids`` all equal to ``-1``.
59
+
60
+ Raises:
61
+ FileNotFoundError: If ``root`` is not a directory, or if no crops are
62
+ found (after applying ``identities``).
63
+ """
64
+ root = Path(root)
65
+ if not root.is_dir():
66
+ raise FileNotFoundError(f"Crop dataset root not found: {root}")
67
+
68
+ allowed = set(identities) if identities is not None else None
69
+
70
+ image_paths: list[str] = []
71
+ pids: list[int] = []
72
+ next_pid = 0
73
+ for identity_dir in sorted(p for p in root.iterdir() if p.is_dir()):
74
+ if allowed is not None and identity_dir.name not in allowed:
75
+ continue
76
+ images = sorted(p for p in identity_dir.iterdir() if p.suffix.lower() in _IMAGE_EXTENSIONS)
77
+ if not images:
78
+ continue
79
+ for image_path in images:
80
+ image_paths.append(str(image_path))
81
+ pids.append(next_pid)
82
+ next_pid += 1
83
+
84
+ if not image_paths:
85
+ raise FileNotFoundError(f"No identity crops found under {root}. Expected <root>/<identity>/<crop>.jpg.")
86
+
87
+ return ReIDSplit(
88
+ image_paths=image_paths,
89
+ pids=np.array(pids, dtype=np.int32),
90
+ camids=np.full(len(image_paths), -1, dtype=np.int32),
91
+ )
92
+
93
+
94
+ def build_identity_crop_retrieval_split(
95
+ split: ReIDSplit,
96
+ *,
97
+ queries_per_id: int = 1,
98
+ min_crops: int = 2,
99
+ seed: int = 0,
100
+ ) -> tuple[ReIDSplit, ReIDSplit]:
101
+ """Partition a crop split into query and gallery splits for retrieval eval.
102
+
103
+ Every identity with at least ``min_crops`` crops contributes ``queries_per_id``
104
+ crops to the query split (synthetic ``camid`` 0) and the remainder to the
105
+ gallery split (synthetic ``camid`` 1). Crops are shuffled per identity with
106
+ ``seed`` before the query/gallery cut so query selection is deterministic but
107
+ not tied to filename order.
108
+
109
+ Args:
110
+ split: A crop split, typically from :func:`load_identity_crops`.
111
+ queries_per_id: Query crops taken from each eligible identity.
112
+ min_crops: Minimum crops required for an identity to be eligible.
113
+ seed: Shuffle seed for per-identity query selection.
114
+
115
+ Returns:
116
+ ``(query, gallery)`` splits.
117
+
118
+ Raises:
119
+ ValueError: If ``min_crops``/``queries_per_id`` are incompatible (see
120
+ :func:`_check_query_params`), or if no identity is eligible.
121
+ """
122
+ _check_query_params(queries_per_id=queries_per_id, min_crops=min_crops)
123
+
124
+ indices_by_pid = _indices_by_pid(split)
125
+ eligible_pids = sorted(pid for pid, indices in indices_by_pid.items() if len(indices) >= min_crops)
126
+ if not eligible_pids:
127
+ raise ValueError(f"No identity has >= {min_crops} crops; cannot build a retrieval split.")
128
+
129
+ return _query_gallery_from_pids(
130
+ split,
131
+ indices_by_pid,
132
+ eligible_pids,
133
+ queries_per_id=queries_per_id,
134
+ seed=seed,
135
+ )
136
+
137
+
138
+ def build_identity_crop_holdout_split(
139
+ split: ReIDSplit,
140
+ *,
141
+ holdout_fraction: float = 0.2,
142
+ queries_per_id: int = 1,
143
+ min_crops: int = 2,
144
+ seed: int = 0,
145
+ ) -> tuple[ReIDSplit, ReIDSplit, ReIDSplit]:
146
+ """Hold out whole identities for retrieval; return train, query, gallery.
147
+
148
+ A fraction of the identities that have at least ``min_crops`` crops is drawn
149
+ (deterministically via ``seed``) and turned into query/gallery splits exactly
150
+ as in :func:`build_identity_crop_retrieval_split`. Every remaining crop, including
151
+ crops of identities too small to be eligible for holdout, forms the train
152
+ split with its original camera IDs preserved. The train and evaluation splits
153
+ are identity-disjoint, so retrieval never scores an identity the model saw
154
+ during training.
155
+
156
+ Args:
157
+ split: A crop split, typically from :func:`load_identity_crops`.
158
+ holdout_fraction: Fraction of eligible identities reserved for retrieval.
159
+ queries_per_id: Query crops per held-out identity.
160
+ min_crops: Minimum crops required for an identity to be eligible for
161
+ holdout.
162
+ seed: Shuffle seed for the holdout draw and per-identity query selection.
163
+
164
+ Returns:
165
+ ``(train, query, gallery)`` splits.
166
+
167
+ Raises:
168
+ ValueError: If ``holdout_fraction`` is outside ``(0, 1]``, if
169
+ ``min_crops``/``queries_per_id`` are incompatible, or if no identity
170
+ is eligible for holdout.
171
+ """
172
+ _check_query_params(queries_per_id=queries_per_id, min_crops=min_crops)
173
+ if not 0.0 < holdout_fraction <= 1.0:
174
+ raise ValueError(f"holdout_fraction must be in (0, 1], got {holdout_fraction}.")
175
+
176
+ indices_by_pid = _indices_by_pid(split)
177
+ eligible_pids = sorted(pid for pid, indices in indices_by_pid.items() if len(indices) >= min_crops)
178
+ if not eligible_pids:
179
+ raise ValueError(f"No identity has >= {min_crops} crops; cannot build a retrieval holdout.")
180
+
181
+ shuffled = eligible_pids.copy()
182
+ random.Random(seed).shuffle(shuffled)
183
+ num_holdout = max(1, round(len(shuffled) * holdout_fraction))
184
+ holdout_pids = set(shuffled[:num_holdout])
185
+
186
+ train_indices = [index for index, pid in enumerate(split.pids.tolist()) if pid not in holdout_pids]
187
+ train = _subset_split(split, train_indices)
188
+ query, gallery = _query_gallery_from_pids(
189
+ split,
190
+ indices_by_pid,
191
+ sorted(holdout_pids),
192
+ queries_per_id=queries_per_id,
193
+ seed=seed,
194
+ )
195
+ return train, query, gallery
196
+
197
+
198
+ def _check_query_params(*, queries_per_id: int, min_crops: int) -> None:
199
+ """Reject incompatible ``queries_per_id`` / ``min_crops`` combinations.
200
+
201
+ An eligible identity must yield at least one query and one gallery crop, so
202
+ ``min_crops`` has to exceed ``queries_per_id``. The combination is rejected
203
+ explicitly rather than silently widened.
204
+ """
205
+ if queries_per_id < 1:
206
+ raise ValueError(f"queries_per_id must be >= 1, got {queries_per_id}.")
207
+ if min_crops <= queries_per_id:
208
+ raise ValueError(
209
+ f"min_crops must be > queries_per_id to leave gallery crops, "
210
+ f"got min_crops={min_crops}, queries_per_id={queries_per_id}."
211
+ )
212
+
213
+
214
+ def _indices_by_pid(split: ReIDSplit) -> dict[int, list[int]]:
215
+ """Group crop indices by person ID, preserving the split's order."""
216
+ grouped: dict[int, list[int]] = {}
217
+ for index, pid in enumerate(split.pids.tolist()):
218
+ grouped.setdefault(pid, []).append(index)
219
+ return grouped
220
+
221
+
222
+ def _query_gallery_from_pids(
223
+ split: ReIDSplit,
224
+ indices_by_pid: dict[int, list[int]],
225
+ pids: list[int],
226
+ *,
227
+ queries_per_id: int,
228
+ seed: int,
229
+ ) -> tuple[ReIDSplit, ReIDSplit]:
230
+ """Cut each identity in ``pids`` into query and gallery crops."""
231
+ rng = random.Random(seed)
232
+ query_indices: list[int] = []
233
+ gallery_indices: list[int] = []
234
+ for pid in pids:
235
+ indices = indices_by_pid[pid].copy()
236
+ rng.shuffle(indices)
237
+ query_indices.extend(indices[:queries_per_id])
238
+ gallery_indices.extend(indices[queries_per_id:])
239
+
240
+ query = _subset_split(split, query_indices, camid=_QUERY_CAMID)
241
+ gallery = _subset_split(split, gallery_indices, camid=_GALLERY_CAMID)
242
+ return query, gallery
243
+
244
+
245
+ def _subset_split(
246
+ split: ReIDSplit,
247
+ indices: list[int],
248
+ *,
249
+ camid: int | None = None,
250
+ ) -> ReIDSplit:
251
+ """Select ``indices`` from ``split`` into a new :class:`ReIDSplit`.
252
+
253
+ When ``camid`` is given, every crop in the subset is stamped with that
254
+ synthetic camera ID and the default junk rule; otherwise the original camera
255
+ IDs and junk rule are preserved.
256
+ """
257
+ image_paths = [split.image_paths[index] for index in indices]
258
+ pids = np.array([int(split.pids[index]) for index in indices], dtype=np.int32)
259
+ if camid is None:
260
+ camids = np.array([int(split.camids[index]) for index in indices], dtype=np.int32)
261
+ return ReIDSplit(
262
+ image_paths=image_paths,
263
+ pids=pids,
264
+ camids=camids,
265
+ gallery_junk_pids=split.gallery_junk_pids,
266
+ )
267
+ return ReIDSplit(
268
+ image_paths=image_paths,
269
+ pids=pids,
270
+ camids=np.full(len(indices), camid, dtype=np.int32),
271
+ )