orient_express 2.2.1__tar.gz → 2.3.1__tar.gz

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.
Files changed (22) hide show
  1. {orient_express-2.2.1 → orient_express-2.3.1}/PKG-INFO +65 -1
  2. {orient_express-2.2.1 → orient_express-2.3.1}/README.md +63 -0
  3. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/__init__.py +20 -0
  4. orient_express-2.3.1/orient_express/predictors/vector_index.py +246 -0
  5. {orient_express-2.2.1 → orient_express-2.3.1}/pyproject.toml +2 -1
  6. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/__init__.py +0 -0
  7. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/deployment.py +0 -0
  8. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/model_wrapper.py +0 -0
  9. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/classification.py +0 -0
  10. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/feature_extraction.py +0 -0
  11. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/instance_segmentation.py +0 -0
  12. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/multi_label_classification.py +0 -0
  13. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/object_detection.py +0 -0
  14. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/predictor.py +0 -0
  15. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/predictors/semantic_segmentation.py +0 -0
  16. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/sklearn_pipeline.py +0 -0
  17. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/utils/colors.py +0 -0
  18. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/utils/gs.py +0 -0
  19. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/utils/image_processor.py +0 -0
  20. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/utils/paths.py +0 -0
  21. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/utils/retry.py +0 -0
  22. {orient_express-2.2.1 → orient_express-2.3.1}/orient_express/vertex.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orient_express
3
- Version: 2.2.1
3
+ Version: 2.3.1
4
4
  Summary: A library to simplify model deployment to Vertex AI
5
5
  Author: Alexey Zankevich
6
6
  Author-email: alex.zankevich@shiftsmart.com
@@ -21,6 +21,7 @@ Requires-Dist: pyyaml (>=6.0,<7.0)
21
21
  Requires-Dist: scikit-learn (>=1.5.0,<2.0.0)
22
22
  Requires-Dist: torch (>=2.5.0)
23
23
  Requires-Dist: torchvision (>=0.20.0)
24
+ Requires-Dist: tqdm (>=4.67.1)
24
25
  Requires-Dist: xgboost (>=2.0.0,<3.0.0)
25
26
  Description-Content-Type: text/markdown
26
27
 
@@ -519,6 +520,69 @@ annotated_image = predictor.get_annotated_image(image, predictions[0].class_mask
519
520
 
520
521
  </details>
521
522
 
523
+ ### VectorIndex
524
+
525
+ <details>
526
+ <summary>Click to expand</summary>
527
+
528
+ A cosine-similarity vector index for matching feature vectors to labels. Each vector in the index can have one or more labels. VectorIndex integrates with `get_predictor` for loading from saved artifacts, and can be built from scratch using a feature extractor.
529
+
530
+ #### Usage
531
+
532
+ ```python
533
+ from orient_express.predictors import VectorIndex, build_vector_index
534
+
535
+ # Build from crops and labels using a feature extractor
536
+ index = build_vector_index(
537
+ crops=crop_images, # list of PIL Images or file paths
538
+ labels=cluster_ids, # one label per crop
539
+ feature_extractor=fe, # FeatureExtractionPredictor
540
+ num_workers=8, # parallel image loading
541
+ )
542
+
543
+ # Aggregate to one centroid per label
544
+ centroid_index = index.aggregate()
545
+
546
+ # Save and load
547
+ centroid_index.dump("/path/to/artifact_dir")
548
+
549
+ from orient_express.predictors import get_predictor
550
+ loaded_index = get_predictor("/path/to/artifact_dir")
551
+
552
+ # Search
553
+ results = loaded_index.search(query_vector, k=5)
554
+ for result in results:
555
+ print(result.labels, result.score)
556
+
557
+ # Batch search
558
+ batch_results = loaded_index.search_batch(query_matrix, k=5)
559
+ ```
560
+
561
+ #### Multi-label support
562
+
563
+ Vectors can have multiple labels. This is useful when a single visual cluster maps to multiple SKUs:
564
+
565
+ ```python
566
+ index = VectorIndex(
567
+ vectors=feature_matrix,
568
+ labels=[["sku_101", "sku_102"], ["sku_103"], ["sku_104", "sku_105"]],
569
+ )
570
+
571
+ # aggregate() computes one centroid per unique label
572
+ aggregated = index.aggregate() # 5 vectors, one per SKU
573
+ ```
574
+
575
+ #### Output Structure
576
+
577
+ ```python
578
+ @dataclass
579
+ class SearchResult:
580
+ labels: list # All labels for the matched vector
581
+ score: float # Cosine similarity score
582
+ ```
583
+
584
+ </details>
585
+
522
586
  ---
523
587
 
524
588
  ## Color Schemes
@@ -493,6 +493,69 @@ annotated_image = predictor.get_annotated_image(image, predictions[0].class_mask
493
493
 
494
494
  </details>
495
495
 
496
+ ### VectorIndex
497
+
498
+ <details>
499
+ <summary>Click to expand</summary>
500
+
501
+ A cosine-similarity vector index for matching feature vectors to labels. Each vector in the index can have one or more labels. VectorIndex integrates with `get_predictor` for loading from saved artifacts, and can be built from scratch using a feature extractor.
502
+
503
+ #### Usage
504
+
505
+ ```python
506
+ from orient_express.predictors import VectorIndex, build_vector_index
507
+
508
+ # Build from crops and labels using a feature extractor
509
+ index = build_vector_index(
510
+ crops=crop_images, # list of PIL Images or file paths
511
+ labels=cluster_ids, # one label per crop
512
+ feature_extractor=fe, # FeatureExtractionPredictor
513
+ num_workers=8, # parallel image loading
514
+ )
515
+
516
+ # Aggregate to one centroid per label
517
+ centroid_index = index.aggregate()
518
+
519
+ # Save and load
520
+ centroid_index.dump("/path/to/artifact_dir")
521
+
522
+ from orient_express.predictors import get_predictor
523
+ loaded_index = get_predictor("/path/to/artifact_dir")
524
+
525
+ # Search
526
+ results = loaded_index.search(query_vector, k=5)
527
+ for result in results:
528
+ print(result.labels, result.score)
529
+
530
+ # Batch search
531
+ batch_results = loaded_index.search_batch(query_matrix, k=5)
532
+ ```
533
+
534
+ #### Multi-label support
535
+
536
+ Vectors can have multiple labels. This is useful when a single visual cluster maps to multiple SKUs:
537
+
538
+ ```python
539
+ index = VectorIndex(
540
+ vectors=feature_matrix,
541
+ labels=[["sku_101", "sku_102"], ["sku_103"], ["sku_104", "sku_105"]],
542
+ )
543
+
544
+ # aggregate() computes one centroid per unique label
545
+ aggregated = index.aggregate() # 5 vectors, one per SKU
546
+ ```
547
+
548
+ #### Output Structure
549
+
550
+ ```python
551
+ @dataclass
552
+ class SearchResult:
553
+ labels: list # All labels for the matched vector
554
+ score: float # Cosine similarity score
555
+ ```
556
+
557
+ </details>
558
+
496
559
  ---
497
560
 
498
561
  ## Color Schemes
@@ -1,8 +1,10 @@
1
1
  import os
2
2
  import logging
3
+ import json
3
4
 
4
5
  import joblib
5
6
  import yaml
7
+ import numpy as np
6
8
 
7
9
  from ..utils.paths import get_metadata_path
8
10
 
@@ -22,6 +24,7 @@ from .multi_label_classification import (
22
24
  MultiLabelClassificationPrediction,
23
25
  )
24
26
  from .feature_extraction import FeatureExtractionPredictor, FeaturePrediction
27
+ from .vector_index import VectorIndex, SearchResult, CropSpec, build_vector_index
25
28
 
26
29
 
27
30
  def get_predictor(dir: str, device: str = "cpu"):
@@ -65,6 +68,8 @@ def get_predictor(dir: str, device: str = "cpu"):
65
68
  )
66
69
  elif model_type == FeatureExtractionPredictor.model_type:
67
70
  return load_feature_extractor(dir, metadata, device)
71
+ elif model_type == VectorIndex.model_type:
72
+ return load_vector_index(dir, metadata)
68
73
  else:
69
74
  raise Exception(f"Unknown model_type '{model_type}'")
70
75
 
@@ -82,3 +87,18 @@ def load_image_predictor(
82
87
  def load_feature_extractor(dir: str, metadata: dict, device: str = "cpu"):
83
88
  onnx_path = os.path.join(dir, metadata["model_file"])
84
89
  return FeatureExtractionPredictor(onnx_path, device)
90
+
91
+
92
+ def load_vector_index(dir: str, metadata: dict | None = None):
93
+ if metadata is None:
94
+ metadata_path = get_metadata_path(dir)
95
+ with open(metadata_path) as f:
96
+ metadata = yaml.safe_load(f)
97
+ assert metadata is not None
98
+ artifact_path = os.path.join(dir, metadata["model_file"])
99
+ data = np.load(artifact_path, allow_pickle=False)
100
+ vectors = data["vectors"]
101
+ labels = json.loads(str(data["labels_json"]))
102
+ index = VectorIndex(vectors=vectors, labels=labels)
103
+ index.model_path = artifact_path
104
+ return index
@@ -0,0 +1,246 @@
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ import warnings
5
+
6
+ import yaml
7
+ import numpy as np
8
+ from PIL import Image
9
+ from torch.utils.data import DataLoader
10
+ from tqdm import tqdm
11
+
12
+ from .predictor import Predictor
13
+ from ..utils.paths import get_metadata_path
14
+
15
+
16
+ @dataclass
17
+ class SearchResult:
18
+ labels: list
19
+ score: float
20
+
21
+
22
+ @dataclass
23
+ class CropSpec:
24
+ """An image path with a bounding box to crop before feature extraction.
25
+
26
+ bbox is (x1, y1, x2, y2) in pixel coordinates, as expected by PIL.Image.crop.
27
+ """
28
+
29
+ path: str
30
+ bbox: tuple[int, int, int, int]
31
+
32
+
33
+ class VectorIndex(Predictor):
34
+ model_type = "vector-index"
35
+ ARTIFACT_FILENAME = "vectors.npz"
36
+
37
+ def __init__(
38
+ self,
39
+ vectors: np.ndarray,
40
+ labels: list[list],
41
+ normalize: bool = False,
42
+ ):
43
+ if vectors.ndim != 2:
44
+ raise ValueError(
45
+ f"vectors must be 2-dimensional (M, D), got shape {vectors.shape}"
46
+ )
47
+ if len(labels) != vectors.shape[0]:
48
+ raise ValueError(
49
+ f"labels length ({len(labels)}) must match number of vectors ({vectors.shape[0]})"
50
+ )
51
+ if normalize:
52
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
53
+ # Avoid division by zero for zero vectors
54
+ norms = np.where(norms == 0, 1, norms)
55
+ vectors = vectors / norms
56
+ self.vectors = vectors
57
+ self.labels = labels
58
+ self.model_path = None
59
+
60
+ def search(self, query: np.ndarray, k: int = 1) -> list[SearchResult]:
61
+ query = query.reshape(1, -1)
62
+ similarities = (query @ self.vectors.T).squeeze(0)
63
+ k = min(k, len(similarities))
64
+ top_indices = np.argpartition(similarities, -k)[-k:]
65
+ top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]
66
+
67
+ return [
68
+ SearchResult(labels=self.labels[idx], score=float(similarities[idx]))
69
+ for idx in top_indices
70
+ ]
71
+
72
+ def search_batch(self, queries: np.ndarray, k: int = 1) -> list[list[SearchResult]]:
73
+ similarities = queries @ self.vectors.T
74
+ k = min(k, similarities.shape[1])
75
+
76
+ all_results = []
77
+ for i in range(similarities.shape[0]):
78
+ row = similarities[i]
79
+ top_indices = np.argpartition(row, -k)[-k:]
80
+ top_indices = top_indices[np.argsort(row[top_indices])[::-1]]
81
+ all_results.append(
82
+ [
83
+ SearchResult(labels=self.labels[idx], score=float(row[idx]))
84
+ for idx in top_indices
85
+ ]
86
+ )
87
+ return all_results
88
+
89
+ def aggregate(self, per_label: bool = False) -> "VectorIndex":
90
+ """
91
+ Aggregate the vectors into a single centroid per unique label group.
92
+
93
+ Args:
94
+ per_label: if True, create one centroid per individual label
95
+ (splitting label groups apart). If False (default), create
96
+ one centroid per unique label group.
97
+ """
98
+ key_to_indices: dict = {}
99
+ for i, label_group in enumerate(self.labels):
100
+ if per_label:
101
+ keys = [label for label in label_group]
102
+ else:
103
+ keys = [tuple(sorted(label_group))]
104
+ for key in keys:
105
+ if key not in key_to_indices:
106
+ key_to_indices[key] = []
107
+ key_to_indices[key].append(i)
108
+
109
+ sorted_keys = sorted(key_to_indices.keys(), key=str)
110
+ centroids = np.empty(
111
+ (len(sorted_keys), self.vectors.shape[1]), dtype=self.vectors.dtype
112
+ )
113
+
114
+ new_labels = []
115
+ for i, key in enumerate(sorted_keys):
116
+ indices = key_to_indices[key]
117
+ centroid = self.vectors[indices].mean(axis=0)
118
+ norm = np.linalg.norm(centroid)
119
+ if norm > 0:
120
+ centroid = centroid / norm
121
+ centroids[i] = centroid
122
+ new_labels.append([key] if per_label else list(key))
123
+
124
+ return VectorIndex(vectors=centroids, labels=new_labels)
125
+
126
+ def get_serving_container_image_uri(self) -> str:
127
+ warnings.warn(
128
+ "VectorIndex does not support serving via a container. Returning incompatible image URI."
129
+ )
130
+ return "us-west1-docker.pkg.dev/shiftsmart-api/orient-express/image-onnx:v2.1.2"
131
+
132
+ def get_serving_container_health_route(self, model_name) -> str:
133
+ return f"/v1/models/{model_name}"
134
+
135
+ def get_serving_container_predict_route(self, model_name) -> str:
136
+ return f"/v1/models/{model_name}:predict"
137
+
138
+ def dump(self, dir: str) -> list[str]:
139
+ artifact_path = os.path.join(dir, self.ARTIFACT_FILENAME)
140
+ np.savez(
141
+ artifact_path,
142
+ vectors=self.vectors,
143
+ labels_json=json.dumps(self.labels),
144
+ )
145
+
146
+ metadata = {
147
+ "model_type": self.model_type,
148
+ "model_file": self.ARTIFACT_FILENAME,
149
+ }
150
+ metadata_path = get_metadata_path(dir)
151
+ with open(metadata_path, "w") as f:
152
+ yaml.dump(metadata, f)
153
+
154
+ return [metadata_path, artifact_path]
155
+
156
+ @property
157
+ def dim(self) -> int:
158
+ return self.vectors.shape[1]
159
+
160
+ def __len__(self) -> int:
161
+ return self.vectors.shape[0]
162
+
163
+ def __repr__(self) -> str:
164
+ unique_labels = set()
165
+ for group in self.labels:
166
+ unique_labels.update(group)
167
+ return (
168
+ f"VectorIndex({len(self)} vectors, dim={self.dim}, "
169
+ f"{len(unique_labels)} unique labels)"
170
+ )
171
+
172
+
173
+ class _CropDataset:
174
+ def __init__(self, crops: list[Image.Image | str | CropSpec]):
175
+ self.crops = crops
176
+
177
+ def __len__(self):
178
+ return len(self.crops)
179
+
180
+ def __getitem__(self, idx):
181
+ crop = self.crops[idx]
182
+ if isinstance(crop, CropSpec):
183
+ img = Image.open(crop.path).convert("RGB")
184
+ return img.crop(crop.bbox)
185
+ if isinstance(crop, str):
186
+ return Image.open(crop).convert("RGB")
187
+ if isinstance(crop, Image.Image):
188
+ return crop
189
+ raise TypeError(
190
+ f"Each crop must be a PIL Image, a file path string, or a CropSpec, got {type(crop)}"
191
+ )
192
+
193
+
194
+ def _collate_pil(batch):
195
+ return list(batch)
196
+
197
+
198
+ def build_vector_index(
199
+ crops: list[Image.Image | str | CropSpec],
200
+ labels: list,
201
+ feature_extractor,
202
+ batch_size: int = 128,
203
+ normalize: bool = True,
204
+ multi_label: bool = False,
205
+ num_workers: int = 0,
206
+ ) -> VectorIndex:
207
+ """Build a VectorIndex by extracting features from crops.
208
+
209
+ Args:
210
+ crops: images to extract features from. Each element can be a PIL
211
+ Image (used directly), a file path string (loaded as a whole
212
+ image), or a CropSpec (loaded and cropped to the specified bbox).
213
+ labels: one label per crop. If multi_label is False, each element is a
214
+ single label. If multi_label is True, each element should be a list
215
+ of labels.
216
+ feature_extractor: anything with a .predict(list[Image]) method that
217
+ returns objects with a .feature attribute (e.g. FeatureExtractionPredictor).
218
+ batch_size: number of crops to process at once.
219
+ normalize: whether to L2-normalize the resulting feature vectors.
220
+ multi_label: if True, labels are already lists of labels per crop.
221
+ num_workers: number of DataLoader workers for parallel image loading.
222
+ """
223
+ if len(crops) != len(labels):
224
+ raise ValueError(
225
+ f"crops length ({len(crops)}) must match labels length ({len(labels)})"
226
+ )
227
+
228
+ if multi_label:
229
+ index_labels = [list(group) for group in labels]
230
+ else:
231
+ index_labels = [[label] for label in labels]
232
+
233
+ loader = DataLoader(
234
+ _CropDataset(crops),
235
+ batch_size=batch_size,
236
+ num_workers=num_workers,
237
+ collate_fn=_collate_pil,
238
+ )
239
+
240
+ all_features = []
241
+ for batch in tqdm(loader):
242
+ results = feature_extractor.predict(batch)
243
+ all_features.extend([r.feature for r in results])
244
+
245
+ vectors = np.vstack(all_features)
246
+ return VectorIndex(vectors=vectors, labels=index_labels, normalize=normalize)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "orient_express"
3
- version = "2.2.1"
3
+ version = "2.3.1"
4
4
  description = "A library to simplify model deployment to Vertex AI"
5
5
  authors = ["Alexey Zankevich <alex.zankevich@shiftsmart.com>", "Ian Myers <ian.myers@shiftsmart.com>"]
6
6
  readme = "README.md"
@@ -18,6 +18,7 @@ Pillow = ">=10.0.1"
18
18
  gcsfs = "^2024.10.0"
19
19
  scikit-learn = "^1.5.0"
20
20
  xgboost = "^2.0.0"
21
+ tqdm = ">=4.67.1"
21
22
  onnxruntime-gpu = {version = "^1.20", markers = "sys_platform == 'linux' and platform_machine == 'x86_64' or sys_platform == 'win32' and platform_machine == 'AMD64'"}
22
23
  onnxruntime = {version = "^1.20", markers = "sys_platform == 'darwin' or platform_machine == 'aarch64' or platform_machine == 'arm64' or sys_platform == 'win32' and platform_machine == 'ARM64'"}
23
24