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/__init__.py +47 -0
- reid/architectures/__init__.py +23 -0
- reid/architectures/fastreid_sbs.py +141 -0
- reid/architectures/osnet.py +314 -0
- reid/architectures/registry.py +104 -0
- reid/catalog.py +222 -0
- reid/data/__init__.py +27 -0
- reid/data/benchmarks.py +163 -0
- reid/data/identity_crops.py +271 -0
- reid/data/splits.py +34 -0
- reid/eval/__init__.py +21 -0
- reid/eval/datasets.py +20 -0
- reid/eval/evaluator.py +148 -0
- reid/eval/metrics.py +142 -0
- reid/loaders.py +404 -0
- reid/model.py +322 -0
- reid/preprocessing.py +170 -0
- reid/py.typed +1 -0
- reid-0.1.0.dev0.dist-info/METADATA +173 -0
- reid-0.1.0.dev0.dist-info/RECORD +23 -0
- reid-0.1.0.dev0.dist-info/WHEEL +5 -0
- reid-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- reid-0.1.0.dev0.dist-info/top_level.txt +1 -0
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
|
+
]
|
reid/data/benchmarks.py
ADDED
|
@@ -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
|
+
)
|