ArkidMitra commited on
Commit
326e3fb
·
verified ·
1 Parent(s): 148b850

MODA Duo: routes each query to the constituent that suits its shape

Browse files
Files changed (3) hide show
  1. README.md +102 -0
  2. config.json +82 -0
  3. serving_ann.py +215 -0
README.md ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - fashion
5
+ - retrieval
6
+ - text-to-image
7
+ - open_clip
8
+ - routing
9
+ pipeline_tag: feature-extraction
10
+ ---
11
+
12
+ # MODA Duo
13
+
14
+ **Two open constituents, one answer per query.** Duo routes each text query to whichever
15
+ open MODA system suits its shape — short catalogue titles to
16
+ [MODA Pro Lite+](https://huggingface.co/HopitAI/moda-pro-lite), longer descriptions to
17
+ [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) — and runs
18
+ **one encoder and one nearest-neighbour query per search**.
19
+
20
+ Duo adds **zero parameters**. It is a serving recipe over two open systems, not a new model.
21
+
22
+ ## Why
23
+
24
+ Fashion search queries come in two shapes, and no single small model is best at both:
25
+
26
+ | query shape | example | best open system ≤250M |
27
+ |---|---|---|
28
+ | catalogue title | `buckle round toe flat shoes` | MODA Pro Lite+ |
29
+ | natural description | `When warm weekends are abound, make sure your closet…` | MODA |
30
+
31
+ Duo picks per query. On a mixed workload it beats **both** constituents.
32
+
33
+ ## Results
34
+
35
+ MAP@10, full corpus, all ground-truth queries, one evaluator (`pytrec_eval map_cut.10`),
36
+ paired bootstrap 10,000 resamples. Routing policy frozen on development data before any
37
+ benchmark was scored.
38
+
39
+ | benchmark | MODA | MODA Pro Lite+ | **MODA Duo** |
40
+ |---|---:|---:|---:|
41
+ | KAGL | 0.2887 | 0.3201 | **0.3201** |
42
+ | Polyvore | 0.3726 | 0.4049 | **0.4049** |
43
+ | Atlas | 0.1862 | 0.1904 | **0.1904** |
44
+ | Fashion200K | **0.1946** | 0.1846 | 0.1866 |
45
+ | DeepFashion In-Shop | **0.1642** | 0.1026 | 0.1640 |
46
+ | DeepFashion Multimodal | 0.0147 | 0.0133 | **0.0159** |
47
+ | **pooled, 12,000 queries** | 0.2035 | 0.2026 | **0.2137** |
48
+
49
+ Pooled across all six benchmarks — the mixed workload a router exists for — Duo is
50
+ **+5.0% over MODA and +5.4% over MODA Pro Lite+**, both significant.
51
+
52
+ Fashion200K is the honest miss: its queries sit where the two constituents are hardest to
53
+ tell apart, and Duo trails MODA there by 4%. Where a workload is known to be all long descriptions, use MODA
54
+ directly.
55
+
56
+ ## Serving cost
57
+
58
+ ```
59
+ indexes 2 one per constituent, built offline
60
+ stored vectors per item 2
61
+ encoders run per query 1 only the routed constituent's text tower
62
+ ANN queries per search 1
63
+ re-ranking none
64
+ ```
65
+
66
+ Compared with a single open model, Duo costs one extra index at build time and nothing
67
+ extra at query time.
68
+
69
+ ## Use
70
+
71
+ ```bash
72
+ pip install open_clip_torch pillow numpy hnswlib
73
+ python serving_ann.py --demo
74
+ ```
75
+
76
+ ```python
77
+ from serving_ann import Duo
78
+ duo = Duo() # loads both constituents
79
+ duo.build(images) # encodes the catalogue with both, builds two indexes
80
+ ids, scores, routes = duo.search(["black leather ankle boots"], k=10)
81
+ ```
82
+
83
+ The router is a callable — replace it with any policy that maps a query to a constituent:
84
+
85
+ ```python
86
+ duo = Duo(router=lambda q: "moda" if looks_like_a_description(q) else "moda_pro_lite_plus")
87
+ ```
88
+
89
+ ## Selection and provenance
90
+
91
+ - Constituents: MODA and MODA Pro Lite+, each with its own published harness, unchanged.
92
+ - The routing policy was chosen on OpenVTON validation and GLAMI development data by
93
+ minimising worst-regime regret against the per-regime oracle, frozen, then applied once to
94
+ the benchmarks above. No benchmark was consulted during selection.
95
+ - Full receipts, per-query results, and the sensitivity curve are in the
96
+ [repository](https://github.com/hopit-ai/Moda).
97
+
98
+ ## Related
99
+
100
+ - [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) — FashionSigLIP with a serving harness. Open source, open weights.
101
+ - [MODA Pro Lite](https://huggingface.co/HopitAI/moda-pro-lite) — a trained fashion encoder. Open weights.
102
+ - MODA Pro — hosted. Fuses both constituents rather than choosing between them.
config.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "MODA Duo",
3
+ "artifact_type": "retrieval_system",
4
+ "description": "Two open constituents, one answer per query. Each query is routed to whichever constituent suits its shape: short catalogue titles to MODA Pro Lite+, longer descriptions to MODA. One text forward and one nearest-neighbour query per search.",
5
+ "additional_learned_parameters": 0,
6
+ "constituents": {
7
+ "moda": {
8
+ "base_model": "Marqo/marqo-fashionSigLIP",
9
+ "parameters": 203155970,
10
+ "embedding_dimension": 768,
11
+ "gallery": {
12
+ "views": {
13
+ "official": 1.0,
14
+ "pad_white": 0.25,
15
+ "center_crop": 0.25
16
+ },
17
+ "normalize": true
18
+ },
19
+ "query": {
20
+ "prompts": {
21
+ "raw": 1.0,
22
+ "product": 0.25
23
+ },
24
+ "normalize": true
25
+ },
26
+ "prompt_templates": {
27
+ "raw": "{query}",
28
+ "product": "a fashion product photo of {query}"
29
+ }
30
+ },
31
+ "moda_pro_lite_plus": {
32
+ "base_model": "HopitAI/moda-pro-lite",
33
+ "parameters": 213159938,
34
+ "embedding_dimension": 768,
35
+ "gallery": {
36
+ "views": {
37
+ "official": 1.0,
38
+ "pad": 0.25,
39
+ "foreground_pad": 0.25
40
+ },
41
+ "normalize": true
42
+ },
43
+ "query": {
44
+ "prompts": {
45
+ "raw": 1.0,
46
+ "photo": 0.25
47
+ },
48
+ "normalize": true
49
+ },
50
+ "prompt_templates": {
51
+ "raw": "{query}",
52
+ "photo": "a photo of {query}"
53
+ }
54
+ }
55
+ },
56
+ "router": {
57
+ "kind": "query_word_count",
58
+ "rule": "words(query) <= threshold -> moda_pro_lite_plus, else moda",
59
+ "threshold": 36,
60
+ "pluggable": true,
61
+ "note": "The router is a callable; any policy mapping a query to a constituent name may replace it."
62
+ },
63
+ "serving": {
64
+ "stored_vectors_per_item": 2,
65
+ "indexes": 2,
66
+ "text_forwards_per_query": 2,
67
+ "encoders_run_per_query": 1,
68
+ "ann_queries_per_search": 1,
69
+ "rerank": false
70
+ },
71
+ "selection": {
72
+ "criterion": "maximin regret against the per-regime oracle (better of the two constituents), tie-break mean regret",
73
+ "target_benchmarks_accessed_during_selection": false,
74
+ "frozen_before_target_evaluation": true,
75
+ "receipt": "results/multiview_recipe_rebuild/SELECTION_duo.json",
76
+ "threshold_selected_on": "development data only (OpenVTON validation 4,989 + GLAMI 2,000 x 3 query views)"
77
+ },
78
+ "evaluation": {
79
+ "protocol": "full corpus, all ground-truth queries, MAP@10, pytrec_eval map_cut.10, paired bootstrap 10000 @ 20260728",
80
+ "receipt": "results/duo_target_eval.json"
81
+ }
82
+ }
serving_ann.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MODA Duo -- two open constituents, one answer per query.
2
+
3
+ Every query is routed to whichever constituent suits its shape:
4
+
5
+ catalogue titles -> MODA Pro Lite+ (HopitAI/moda-pro-lite + its recipe)
6
+ long descriptions -> MODA (Marqo/marqo-fashionSigLIP + its recipe)
7
+
8
+ Both constituents are open. Duo adds ZERO parameters. The default router is a
9
+ word-count rule frozen on development data; it is a plain callable, so any policy
10
+ that maps a query string to a constituent name may replace it.
11
+
12
+ Serving cost
13
+ ------------
14
+ indexes : 2 one per constituent, both built offline
15
+ stored vectors per item : 2
16
+ encoders run per query : 1 only the routed constituent's text tower
17
+ ANN queries per search : 1
18
+ re-ranking : none
19
+
20
+ pip install open_clip_torch pillow numpy hnswlib
21
+ python serving_ann.py --demo
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import math
27
+ from dataclasses import dataclass, field
28
+ from typing import Callable, Sequence
29
+
30
+ import numpy as np
31
+ import torch
32
+ import torch.nn.functional as F
33
+ from PIL import Image
34
+
35
+ THRESHOLD_WORDS = 36 # frozen on development data; see config.json
36
+
37
+ PROMPT_TEMPLATES = {
38
+ "raw": "{query}",
39
+ "photo": "a photo of {query}",
40
+ "product": "a fashion product photo of {query}",
41
+ }
42
+
43
+
44
+ # ---------------------------------------------------------------- views ----
45
+ def square_pad(image: Image.Image, fill: int = 128) -> Image.Image:
46
+ image = image.convert("RGB")
47
+ side = max(image.size)
48
+ canvas = Image.new("RGB", (side, side), (fill, fill, fill))
49
+ canvas.paste(image, ((side - image.width) // 2, (side - image.height) // 2))
50
+ return canvas
51
+
52
+
53
+ def center_square(image: Image.Image) -> Image.Image:
54
+ side = min(image.size)
55
+ left, top = (image.width - side) // 2, (image.height - side) // 2
56
+ return image.convert("RGB").crop((left, top, left + side, top + side))
57
+
58
+
59
+ def foreground_square(image: Image.Image) -> Image.Image:
60
+ """Crop a near-white catalogue border, then pad without distorting aspect."""
61
+ image = image.convert("RGB")
62
+ preview = image.copy()
63
+ preview.thumbnail((256, 256), Image.Resampling.BILINEAR)
64
+ mask = np.any(np.asarray(preview, dtype=np.uint8) < 242, axis=-1)
65
+ if float(mask.mean()) < 0.01:
66
+ return square_pad(image)
67
+ ys, xs = np.nonzero(mask)
68
+ sx, sy = image.width / preview.width, image.height / preview.height
69
+ left = max(0, math.floor(float(xs.min()) * sx))
70
+ right = min(image.width, math.ceil(float(xs.max() + 1) * sx))
71
+ top = max(0, math.floor(float(ys.min()) * sy))
72
+ bottom = min(image.height, math.ceil(float(ys.max() + 1) * sy))
73
+ mx, my = max(1, round((right - left) * 0.05)), max(1, round((bottom - top) * 0.05))
74
+ return square_pad(image.crop((max(0, left - mx), max(0, top - my),
75
+ min(image.width, right + mx), min(image.height, bottom + my))))
76
+
77
+
78
+ VIEWS = {
79
+ "official": lambda im: im.convert("RGB"),
80
+ "pad": square_pad,
81
+ "pad_white": lambda im: square_pad(im, fill=255),
82
+ "center_crop": center_square,
83
+ "foreground_pad": foreground_square,
84
+ }
85
+
86
+
87
+ # ---------------------------------------------------------- constituents ----
88
+ @dataclass
89
+ class Constituent:
90
+ name: str
91
+ repo: str
92
+ image_mix: dict[str, float]
93
+ prompt_mix: dict[str, float]
94
+ enc: tuple = field(default=None, repr=False)
95
+
96
+ def load(self, device: str = "cpu") -> "Constituent":
97
+ import open_clip
98
+ model, _, preprocess = open_clip.create_model_and_transforms(self.repo)
99
+ model.eval().to(device)
100
+ for p in model.parameters():
101
+ p.requires_grad = False
102
+ self.enc = (model, preprocess, open_clip.get_tokenizer(self.repo), device)
103
+ return self
104
+
105
+ @torch.inference_mode()
106
+ def encode_images(self, images: Sequence[Image.Image], batch_size: int = 32) -> np.ndarray:
107
+ model, preprocess, _, device = self.enc
108
+ parts = {v: [] for v in self.image_mix}
109
+ for s in range(0, len(images), batch_size):
110
+ chunk = images[s:s + batch_size]
111
+ for view in self.image_mix:
112
+ px = torch.stack([preprocess(VIEWS[view](im)) for im in chunk]).to(device)
113
+ parts[view].append(F.normalize(model.encode_image(px).float(), dim=-1).cpu())
114
+ fused = sum(w * torch.cat(parts[v]) for v, w in self.image_mix.items())
115
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
116
+
117
+ @torch.inference_mode()
118
+ def encode_queries(self, texts: Sequence[str], batch_size: int = 128) -> np.ndarray:
119
+ model, _, tokenizer, device = self.enc
120
+ parts = {p: [] for p in self.prompt_mix}
121
+ for s in range(0, len(texts), batch_size):
122
+ chunk = texts[s:s + batch_size]
123
+ for prompt in self.prompt_mix:
124
+ tok = tokenizer([PROMPT_TEMPLATES[prompt].format(query=t) for t in chunk]).to(device)
125
+ parts[prompt].append(F.normalize(model.encode_text(tok).float(), dim=-1).cpu())
126
+ fused = sum(w * torch.cat(parts[p]) for p, w in self.prompt_mix.items())
127
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
128
+
129
+
130
+ CONSTITUENTS = {
131
+ "moda": Constituent(
132
+ "MODA", "hf-hub:Marqo/marqo-fashionSigLIP",
133
+ {"official": 1.0, "pad_white": 0.25, "center_crop": 0.25},
134
+ {"raw": 1.0, "product": 0.25}),
135
+ "moda_pro_lite_plus": Constituent(
136
+ "MODA Pro Lite+", "hf-hub:HopitAI/moda-pro-lite",
137
+ {"official": 1.0, "pad": 0.25, "foreground_pad": 0.25},
138
+ {"raw": 1.0, "photo": 0.25}),
139
+ }
140
+
141
+
142
+ # ---------------------------------------------------------------- router ----
143
+ def word_count_router(query: str, threshold: int = THRESHOLD_WORDS) -> str:
144
+ """Default policy. Replace with any callable(query) -> constituent name."""
145
+ return "moda_pro_lite_plus" if len(query.split()) <= threshold else "moda"
146
+
147
+
148
+ # ------------------------------------------------------------------ Duo ----
149
+ class Duo:
150
+ def __init__(self, router: Callable[[str], str] = word_count_router, device: str = "cpu"):
151
+ self.router = router
152
+ self.c = {k: v.load(device) for k, v in CONSTITUENTS.items()}
153
+ self.index: dict[str, object] = {}
154
+ self.vectors: dict[str, np.ndarray] = {}
155
+
156
+ def build(self, images: Sequence[Image.Image], m: int = 32, ef_construction: int = 200) -> None:
157
+ """Index time: encode the catalogue with BOTH constituents, once."""
158
+ import hnswlib
159
+ for name, c in self.c.items():
160
+ vec = c.encode_images(images)
161
+ idx = hnswlib.Index(space="ip", dim=vec.shape[1])
162
+ idx.init_index(max_elements=len(vec), ef_construction=ef_construction, M=m)
163
+ idx.add_items(vec, np.arange(len(vec)))
164
+ self.index[name], self.vectors[name] = idx, vec
165
+
166
+ def search(self, queries: Sequence[str], k: int = 10, ef: int = 64):
167
+ """Query time: ONE encoder, ONE ANN query."""
168
+ routes = [self.router(q) for q in queries]
169
+ ids = np.zeros((len(queries), k), dtype=np.int64)
170
+ scores = np.zeros((len(queries), k), dtype=np.float32)
171
+ for name in set(routes):
172
+ rows = [i for i, r in enumerate(routes) if r == name]
173
+ qv = self.c[name].encode_queries([queries[i] for i in rows])
174
+ self.index[name].set_ef(max(ef, k))
175
+ got_ids, dist = self.index[name].knn_query(qv, k=k)
176
+ ids[rows], scores[rows] = got_ids, 1.0 - dist
177
+ return ids, scores, routes
178
+
179
+ def search_exact(self, queries: Sequence[str], k: int = 10):
180
+ """Ground truth, for checking ANN recall on your own corpus."""
181
+ routes = [self.router(q) for q in queries]
182
+ ids = np.zeros((len(queries), k), dtype=np.int64)
183
+ for name in set(routes):
184
+ rows = [i for i, r in enumerate(routes) if r == name]
185
+ qv = self.c[name].encode_queries([queries[i] for i in rows])
186
+ sims = qv @ self.vectors[name].T
187
+ ids[rows] = np.argsort(-sims, axis=1)[:, :k]
188
+ return ids, routes
189
+
190
+
191
+ def _demo() -> None:
192
+ duo = Duo()
193
+ corpus = [Image.new("RGB", (w, h), c) for w, h, c in
194
+ [(224, 300, "white"), (300, 224, "black"), (256, 256, "navy"),
195
+ (400, 200, "beige"), (200, 400, "maroon")]]
196
+ duo.build(corpus)
197
+ queries = [
198
+ "black leather ankle boots",
199
+ "A woman is wearing a long navy wool coat with wide lapels, belted at the waist, "
200
+ "over a cream turtleneck and dark trousers, styled for a cold city morning with a "
201
+ "leather tote and ankle boots and a soft grey scarf wrapped twice",
202
+ ]
203
+ ids, scores, routes = duo.search(queries, k=3)
204
+ exact, _ = duo.search_exact(queries, k=3)
205
+ for q, r, a, b in zip(queries, routes, ids, exact):
206
+ print(f"[{r:18s}] {q[:44]!r:48s} ANN {a.tolist()} exact {b.tolist()}")
207
+ agree = float(np.mean([len(set(a) & set(b)) / len(b) for a, b in zip(ids, exact)]))
208
+ print(f"ANN/exact overlap@3: {agree:.3f} indexes: {len(duo.index)} encoders per query: 1")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ ap = argparse.ArgumentParser(description=__doc__)
213
+ ap.add_argument("--demo", action="store_true")
214
+ args = ap.parse_args()
215
+ _demo() if args.demo else ap.print_help()