ArkidMitra commited on
Commit
3eaff2e
·
verified ·
1 Parent(s): aad36b2

MODA Pro Lite+: moda-pro-lite with its calibrated serving recipe

Browse files
Files changed (3) hide show
  1. README.md +118 -0
  2. config.json +39 -0
  3. serving_ann.py +173 -0
README.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - fashion
5
+ - retrieval
6
+ - text-to-image
7
+ - open_clip
8
+ - siglip2
9
+ pipeline_tag: feature-extraction
10
+ library_name: open_clip
11
+ base_model: HopitAI/moda-pro-lite
12
+ ---
13
+
14
+ # MODA Pro Lite+
15
+
16
+ **The strongest open system at ≤250M parameters on catalogue and title search.**
17
+
18
+ MODA Pro Lite+ is [MODA Pro Lite](https://huggingface.co/HopitAI/moda-pro-lite) — a 213M
19
+ fashion retrieval encoder — served with a calibrated multi-view recipe. This repository holds
20
+ the recipe, as runnable code; the weights are pulled from `moda-pro-lite` at load time.
21
+
22
+ Zero added parameters. One stored vector per item. The uplift is paid once at index time and
23
+ costs nothing at query time.
24
+
25
+ ## Results
26
+
27
+ MAP@10, full corpus, all ground-truth queries, one evaluator (`pytrec_eval map_cut.10`).
28
+ `MODA` is FashionSigLIP with its own serving recipe, shown for reference.
29
+
30
+ | benchmark | MODA | Pro Lite (bare) | **Pro Lite+** (with recipe) |
31
+ |---|---:|---:|---:|
32
+ | KAGL | 0.2887 | 0.3055 | **0.3201** |
33
+ | Polyvore | 0.3726 | 0.3952 | **0.4049** |
34
+ | Atlas | 0.1862 | 0.1814 | **0.1904** |
35
+ | Fashion200K | **0.1946** | 0.1758 | 0.1846 |
36
+ | DeepFashion In-Shop | **0.1642** | 0.0930 | 0.1026 |
37
+ | DeepFashion Multimodal | **0.0147** | 0.0118 | 0.0133 |
38
+
39
+ **Pro Lite+ leads the ≤250M class on KAGL, Polyvore and Atlas** — +10.9% over MODA on KAGL,
40
+ +8.7% on Polyvore, both significant under a paired bootstrap (10,000 resamples).
41
+
42
+ The recipe is worth +2.5% to +12.8% over the bare encoder on every benchmark, and costs
43
+ nothing at query time: the views are fused into a single vector before indexing.
44
+
45
+ **Where this model is weak, stated plainly.** Pro Lite is tuned for short catalogue titles.
46
+ On long natural-language descriptions it trails FashionSigLIP substantially — DeepFashion
47
+ In-Shop queries average 75 words, and Pro Lite+ scores 0.1026 there against MODA's 0.1642.
48
+ If your queries are descriptions rather than titles, use
49
+ [MODA Duo](https://huggingface.co/HopitAI/moda-duo), which routes per query.
50
+
51
+ ## Serving cost
52
+
53
+ ```
54
+ stored vectors per item : 1
55
+ ANN queries per search : 1
56
+ image forwards at index : 3x offline, paid once
57
+ text forwards per query : 2x negligible beside the ANN probe
58
+ ```
59
+
60
+ The recipe is a rule for *what you encode*, not a model change. Views are combined into one
61
+ unit vector before indexing, so nearest-neighbour search costs exactly what the bare encoder
62
+ costs — same index, same probe, no extra routes and no re-ranking.
63
+
64
+ ## Use
65
+
66
+ ```bash
67
+ pip install open_clip_torch pillow numpy hnswlib
68
+ python serving_ann.py --demo
69
+ ```
70
+
71
+ ```python
72
+ from serving_ann import load, encode_images, encode_queries, build_index, search
73
+
74
+ enc = load() # open_clip, this repo's weights
75
+ docs = encode_images(catalogue, enc) # (n, 768) float32, one vector per item
76
+ index = build_index(docs) # hnswlib, cosine via inner product
77
+ qry = encode_queries(["black leather ankle boots"], enc)
78
+ ids, scores = search(index, qry, k=10)
79
+ ```
80
+
81
+ Bare encoder, if you would rather not use the recipe:
82
+
83
+ ```python
84
+ import open_clip, torch
85
+
86
+ model, _, preprocess = open_clip.create_model_and_transforms("hf-hub:HopitAI/moda-pro-lite")
87
+ tokenizer = open_clip.get_tokenizer("hf-hub:HopitAI/moda-pro-lite")
88
+ model.eval()
89
+
90
+ with torch.no_grad():
91
+ image = torch.nn.functional.normalize(model.encode_image(preprocess(img).unsqueeze(0)), dim=-1)
92
+ text = torch.nn.functional.normalize(model.encode_text(tokenizer(["black leather ankle boots"])), dim=-1)
93
+ score = (text @ image.T).item()
94
+ ```
95
+
96
+ 768-d embeddings, cosine similarity, one vector per item. Index them in any vector database.
97
+
98
+ ## The recipe
99
+
100
+ ```
101
+ document = normalize(official + 0.25 * square_pad + 0.25 * foreground_pad)
102
+ query = normalize(raw + 0.25 * "a photo of {query}")
103
+ ```
104
+
105
+ `serving_ann.py` implements it. Zero added parameters, one stored vector.
106
+
107
+ ## Evaluation
108
+
109
+ All figures are full corpus, all ground-truth queries, MAP@10 under one evaluator
110
+ (`pytrec_eval map_cut.10`), float32. Per-query results and confidence intervals are in the
111
+ [repository](https://github.com/hopit-ai/Moda).
112
+
113
+ ## Related
114
+
115
+ - [MODA Pro Lite](https://huggingface.co/HopitAI/moda-pro-lite) — the bare encoder these weights come from.
116
+ - [MODA Duo](https://huggingface.co/HopitAI/moda-duo) — routes each query to Pro Lite+ or MODA by its shape; beats both on a mixed workload.
117
+ - [MODA](https://huggingface.co/HopitAI/moda-fashionsiglip-multiview-203m) — FashionSigLIP with a serving recipe. Stronger on long descriptions.
118
+ - [MODA-SigLIP-Distilled](https://huggingface.co/HopitAI/moda-fashion-distilled) — image-to-image retrieval.
config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "MODA Pro Lite+",
3
+ "artifact_type": "retrieval_system",
4
+ "base_model": "HopitAI/moda-pro-lite",
5
+ "base_model_parameters": 213159938,
6
+ "additional_learned_parameters": 0,
7
+ "embedding_dimension": 768,
8
+ "gallery": {
9
+ "views": {
10
+ "official": 1.0,
11
+ "pad": 0.25,
12
+ "foreground_pad": 0.25
13
+ },
14
+ "normalize": true,
15
+ "stored_vectors_per_item": 1
16
+ },
17
+ "query": {
18
+ "prompts": {
19
+ "raw": 1.0,
20
+ "photo": 0.25
21
+ },
22
+ "normalize": true
23
+ },
24
+ "prompt_templates": {
25
+ "raw": "{query}",
26
+ "photo": "a photo of {query}"
27
+ },
28
+ "serving": {
29
+ "stored_vectors_per_item": 1,
30
+ "ann_queries_per_search": 1,
31
+ "image_forwards_per_item_at_index_time": 3,
32
+ "text_forwards_per_query": 2,
33
+ "rerank": false
34
+ },
35
+ "evaluation": {
36
+ "protocol": "full corpus, all ground-truth queries, MAP@10, pytrec_eval map_cut.10, paired bootstrap 10000",
37
+ "receipt": "results/multiview_target_eval_fp32/"
38
+ }
39
+ }
serving_ann.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MODA Pro Lite+ (moda-pro-lite with its calibrated serving harness) -- retrieval with ANN, end to end.
2
+
3
+ The harness below was selected on held-out development data only (OpenVTON + GLAMI); no target benchmark was touched during selection.
4
+ It adds ZERO parameters and stores ONE vector per item.
5
+
6
+ Serving cost
7
+ ------------
8
+ stored vectors per item : 1
9
+ ANN queries per search : 1
10
+ image forwards at index : 3x (paid once, offline)
11
+ text forwards per query : 2x (cheap next to the ANN probe)
12
+
13
+ The harness is a recipe for WHAT YOU ENCODE, not a model change: the views are
14
+ combined into a single unit vector before indexing, so nearest-neighbour search
15
+ costs exactly what it costs for the bare model. No extra routes, no re-ranking.
16
+
17
+ pip install open_clip_torch pillow numpy hnswlib
18
+
19
+ Example
20
+ -------
21
+ python serving_ann.py --demo
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import math
27
+ from typing import Sequence
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn.functional as F
32
+ from PIL import Image
33
+
34
+ MODEL = "hf-hub:HopitAI/moda-pro-lite"
35
+ IMAGE_MIX = {'official': 1.0, 'pad': 0.25, 'foreground_pad': 0.25} # view -> weight, combined then L2-normalised
36
+ PROMPT_MIX = {'raw': 1.0, 'photo': 0.25} # prompt -> weight, combined then L2-normalised
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 catalog 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
+ # -------------------------------------------------------------- encoding ----
88
+ def load(device: str = "cpu"):
89
+ import open_clip
90
+ model, _, preprocess = open_clip.create_model_and_transforms(MODEL)
91
+ model.eval().to(device)
92
+ for p in model.parameters():
93
+ p.requires_grad = False
94
+ return model, preprocess, open_clip.get_tokenizer(MODEL), device
95
+
96
+
97
+ @torch.inference_mode()
98
+ def encode_images(images: Sequence[Image.Image], enc, batch_size: int = 32) -> np.ndarray:
99
+ """-> (n, 768) float32, unit norm. ONE vector per item."""
100
+ model, preprocess, _, device = enc
101
+ parts = {v: [] for v in IMAGE_MIX}
102
+ for s in range(0, len(images), batch_size):
103
+ chunk = images[s:s + batch_size]
104
+ for view in IMAGE_MIX:
105
+ px = torch.stack([preprocess(VIEWS[view](im)) for im in chunk]).to(device)
106
+ parts[view].append(F.normalize(model.encode_image(px).float(), dim=-1).cpu())
107
+ fused = sum(w * torch.cat(parts[v]) for v, w in IMAGE_MIX.items())
108
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
109
+
110
+
111
+ @torch.inference_mode()
112
+ def encode_queries(texts: Sequence[str], enc, batch_size: int = 128) -> np.ndarray:
113
+ """-> (n, 768) float32, unit norm. ONE vector per query."""
114
+ model, _, tokenizer, device = enc
115
+ parts = {p: [] for p in PROMPT_MIX}
116
+ for s in range(0, len(texts), batch_size):
117
+ chunk = texts[s:s + batch_size]
118
+ for prompt in PROMPT_MIX:
119
+ rendered = [PROMPT_TEMPLATES[prompt].format(query=t) for t in chunk]
120
+ tok = tokenizer(rendered).to(device)
121
+ parts[prompt].append(F.normalize(model.encode_text(tok).float(), dim=-1).cpu())
122
+ fused = sum(w * torch.cat(parts[p]) for p, w in PROMPT_MIX.items())
123
+ return F.normalize(fused, dim=-1).numpy().astype("float32")
124
+
125
+
126
+ # ------------------------------------------------------------------ ANN ----
127
+ def build_index(vectors: np.ndarray, m: int = 32, ef_construction: int = 200):
128
+ """Cosine similarity over unit vectors is inner product, so use space='ip'."""
129
+ import hnswlib
130
+ index = hnswlib.Index(space="ip", dim=vectors.shape[1])
131
+ index.init_index(max_elements=len(vectors), ef_construction=ef_construction, M=m)
132
+ index.add_items(vectors, np.arange(len(vectors)))
133
+ return index
134
+
135
+
136
+ def search(index, queries: np.ndarray, k: int = 10, ef: int = 64):
137
+ index.set_ef(max(ef, k))
138
+ ids, distances = index.knn_query(queries, k=k)
139
+ return ids, 1.0 - distances # ip distance -> cosine similarity
140
+
141
+
142
+ def search_exact(vectors: np.ndarray, queries: np.ndarray, k: int = 10):
143
+ """Ground truth, for checking ANN recall on your own corpus."""
144
+ sims = queries @ vectors.T
145
+ ids = np.argsort(-sims, axis=1)[:, :k]
146
+ return ids, np.take_along_axis(sims, ids, axis=1)
147
+
148
+
149
+ def _demo() -> None:
150
+ enc = load()
151
+ corpus = [Image.new("RGB", (w, h), c) for w, h, c in
152
+ [(224, 300, "white"), (300, 224, "black"), (256, 256, "navy"),
153
+ (400, 200, "beige"), (200, 400, "maroon")]]
154
+ doc = encode_images(corpus, enc)
155
+ qry = encode_queries(["black leather ankle boots", "navy wool coat"], enc)
156
+ print(f"documents {doc.shape} queries {qry.shape} (one vector each)")
157
+ index = build_index(doc)
158
+ ann_ids, ann_scores = search(index, qry, k=3)
159
+ ex_ids, _ = search_exact(doc, qry, k=3)
160
+ agree = float(np.mean([len(set(a) & set(b)) / len(b) for a, b in zip(ann_ids, ex_ids)]))
161
+ print(f"ANN top-3 ids {ann_ids.tolist()}")
162
+ print(f"exact top-3 ids {ex_ids.tolist()}")
163
+ print(f"ANN/exact overlap@3: {agree:.3f} (1.000 expected on a corpus this small)")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ ap = argparse.ArgumentParser(description=__doc__)
168
+ ap.add_argument("--demo", action="store_true")
169
+ args = ap.parse_args()
170
+ if args.demo:
171
+ _demo()
172
+ else:
173
+ ap.print_help()