orient_express 2.2.0__tar.gz → 2.3.0__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.0 → orient_express-2.3.0}/PKG-INFO +66 -3
  2. {orient_express-2.2.0 → orient_express-2.3.0}/README.md +63 -0
  3. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/__init__.py +20 -0
  4. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/instance_segmentation.py +40 -18
  5. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/object_detection.py +7 -2
  6. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/semantic_segmentation.py +8 -5
  7. orient_express-2.3.0/orient_express/predictors/vector_index.py +236 -0
  8. {orient_express-2.2.0 → orient_express-2.3.0}/pyproject.toml +3 -8
  9. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/__init__.py +0 -0
  10. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/deployment.py +0 -0
  11. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/model_wrapper.py +0 -0
  12. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/classification.py +0 -0
  13. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/feature_extraction.py +0 -0
  14. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/multi_label_classification.py +0 -0
  15. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/predictor.py +0 -0
  16. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/sklearn_pipeline.py +0 -0
  17. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/colors.py +0 -0
  18. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/gs.py +0 -0
  19. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/image_processor.py +0 -0
  20. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/paths.py +0 -0
  21. {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/retry.py +0 -0
  22. {orient_express-2.2.0 → orient_express-2.3.0}/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.0
3
+ Version: 2.3.0
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
@@ -19,8 +19,8 @@ Requires-Dist: opencv-python-headless (>=4.11.0,<5.0.0)
19
19
  Requires-Dist: pandas (>=2.0,<3.0)
20
20
  Requires-Dist: pyyaml (>=6.0,<7.0)
21
21
  Requires-Dist: scikit-learn (>=1.5.0,<2.0.0)
22
- Requires-Dist: torch (>=2.5.0,<3.0.0)
23
- Requires-Dist: torchvision (>=0.20.0,<0.21.0)
22
+ Requires-Dist: torch (>=2.5.0)
23
+ Requires-Dist: torchvision (>=0.20.0)
24
24
  Requires-Dist: xgboost (>=2.0.0,<3.0.0)
25
25
  Description-Content-Type: text/markdown
26
26
 
@@ -519,6 +519,69 @@ annotated_image = predictor.get_annotated_image(image, predictions[0].class_mask
519
519
 
520
520
  </details>
521
521
 
522
+ ### VectorIndex
523
+
524
+ <details>
525
+ <summary>Click to expand</summary>
526
+
527
+ 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.
528
+
529
+ #### Usage
530
+
531
+ ```python
532
+ from orient_express.predictors import VectorIndex, build_vector_index
533
+
534
+ # Build from crops and labels using a feature extractor
535
+ index = build_vector_index(
536
+ crops=crop_images, # list of PIL Images or file paths
537
+ labels=cluster_ids, # one label per crop
538
+ feature_extractor=fe, # FeatureExtractionPredictor
539
+ num_workers=8, # parallel image loading
540
+ )
541
+
542
+ # Aggregate to one centroid per label
543
+ centroid_index = index.aggregate()
544
+
545
+ # Save and load
546
+ centroid_index.dump("/path/to/artifact_dir")
547
+
548
+ from orient_express.predictors import get_predictor
549
+ loaded_index = get_predictor("/path/to/artifact_dir")
550
+
551
+ # Search
552
+ results = loaded_index.search(query_vector, k=5)
553
+ for result in results:
554
+ print(result.labels, result.score)
555
+
556
+ # Batch search
557
+ batch_results = loaded_index.search_batch(query_matrix, k=5)
558
+ ```
559
+
560
+ #### Multi-label support
561
+
562
+ Vectors can have multiple labels. This is useful when a single visual cluster maps to multiple SKUs:
563
+
564
+ ```python
565
+ index = VectorIndex(
566
+ vectors=feature_matrix,
567
+ labels=[["sku_101", "sku_102"], ["sku_103"], ["sku_104", "sku_105"]],
568
+ )
569
+
570
+ # aggregate() computes one centroid per unique label
571
+ aggregated = index.aggregate() # 5 vectors, one per SKU
572
+ ```
573
+
574
+ #### Output Structure
575
+
576
+ ```python
577
+ @dataclass
578
+ class SearchResult:
579
+ labels: list # All labels for the matched vector
580
+ score: float # Cosine similarity score
581
+ ```
582
+
583
+ </details>
584
+
522
585
  ---
523
586
 
524
587
  ## 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
@@ -10,6 +10,8 @@ import torch.nn.functional as F
10
10
  from .predictor import OnnxSessionWrapper, ImagePredictor
11
11
  from ..utils.image_processor import pil_to_opencv, opencv_to_pil
12
12
 
13
+ FONT = cv2.FONT_HERSHEY_SIMPLEX
14
+
13
15
 
14
16
  @dataclass
15
17
  class InstanceSegmentationPrediction:
@@ -128,7 +130,12 @@ class InstanceSegmentationPredictor(ImagePredictor):
128
130
  return outputs
129
131
 
130
132
  def get_annotated_image(
131
- self, image: Image.Image, predictions: list[InstanceSegmentationPrediction]
133
+ self,
134
+ image: Image.Image,
135
+ predictions: list[InstanceSegmentationPrediction],
136
+ mask_opacity: float = 0.3,
137
+ draw_indices: bool = True,
138
+ font_scale: float | None = None,
132
139
  ) -> Image.Image:
133
140
  opencv_image = pil_to_opencv(image)
134
141
  mask_overlay = opencv_image.copy()
@@ -137,15 +144,20 @@ class InstanceSegmentationPredictor(ImagePredictor):
137
144
  fill_color = self.color_scheme.get(pred.clss, (120, 120, 120))
138
145
  mask_overlay[pred.mask] = fill_color[:3]
139
146
 
140
- alpha = 0.5
141
147
  annotated_image = cv2.addWeighted(
142
- mask_overlay, alpha, opencv_image, 1 - alpha, 0
148
+ mask_overlay, mask_opacity, opencv_image, 1 - mask_opacity, 0
143
149
  )
144
150
 
145
- class_counts = defaultdict(int)
146
- for pred in predictions:
147
- class_counts[pred.clss] += 1
148
- self.draw_mask_index(annotated_image, pred, class_counts[pred.clss])
151
+ if draw_indices:
152
+ class_counts = defaultdict(int)
153
+ for pred in predictions:
154
+ class_counts[pred.clss] += 1
155
+ self.draw_mask_index(
156
+ annotated_image,
157
+ pred,
158
+ class_counts[pred.clss],
159
+ font_scale,
160
+ )
149
161
 
150
162
  return opencv_to_pil(annotated_image)
151
163
 
@@ -154,25 +166,35 @@ class InstanceSegmentationPredictor(ImagePredictor):
154
166
  opencv_image: np.ndarray,
155
167
  prediction: InstanceSegmentationPrediction,
156
168
  index: int,
169
+ font_scale: float | None = None,
157
170
  ):
158
- # Calculate the centroid of the bbox
159
- centroid_x = int((prediction.bbox[0] + prediction.bbox[2]) / 2)
160
- centroid_y = int((prediction.bbox[1] + prediction.bbox[3]) / 2)
161
- # Put the object count in the center of the bbox
162
- font_scale = 2
163
- font_thickness = 4
164
171
  text = str(index)
165
- text_size = cv2.getTextSize(
166
- text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, font_thickness
167
- )[0]
172
+
173
+ bbox = prediction.bbox
174
+ bbox_width = bbox[2] - bbox[0]
175
+ bbox_height = bbox[3] - bbox[1]
176
+
177
+ if font_scale is None:
178
+ target_size = min(bbox_width, bbox_height) * 0.3
179
+ (base_w, base_h), _ = cv2.getTextSize(text, FONT, 1, 1)
180
+ font_scale = target_size / max(base_w, base_h)
181
+
182
+ assert font_scale is not None
183
+ thickness = max(int(font_scale * 2), 1)
184
+
185
+ text_size = cv2.getTextSize(text, FONT, font_scale, thickness)[0]
186
+
187
+ centroid_x = int((bbox[0] + bbox[2]) / 2)
188
+ centroid_y = int((bbox[1] + bbox[3]) / 2)
168
189
  text_x = int(centroid_x - text_size[0] / 2)
169
190
  text_y = int(centroid_y + text_size[1] / 2)
191
+
170
192
  cv2.putText(
171
193
  opencv_image,
172
194
  text,
173
195
  (text_x, text_y),
174
- cv2.FONT_HERSHEY_SIMPLEX,
196
+ FONT,
175
197
  font_scale,
176
198
  (255, 255, 255),
177
- font_thickness,
199
+ thickness,
178
200
  )
@@ -117,7 +117,10 @@ class BoundingBoxPredictor(ImagePredictor):
117
117
  return outputs
118
118
 
119
119
  def get_annotated_image(
120
- self, image: Image.Image, predictions: list[BoundingBoxPrediction]
120
+ self,
121
+ image: Image.Image,
122
+ predictions: list[BoundingBoxPrediction],
123
+ line_width: int = 8,
121
124
  ):
122
125
  opencv_image = pil_to_opencv(image)
123
126
 
@@ -125,6 +128,8 @@ class BoundingBoxPredictor(ImagePredictor):
125
128
  color = self.color_scheme.get(pred.clss, (255, 255, 255))
126
129
  x1, y1, x2, y2 = pred.bbox
127
130
  x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
128
- opencv_image = cv2.rectangle(opencv_image, (x1, y1), (x2, y2), color, 2)
131
+ opencv_image = cv2.rectangle(
132
+ opencv_image, (x1, y1), (x2, y2), color, line_width
133
+ )
129
134
 
130
135
  return opencv_to_pil(opencv_image)
@@ -76,15 +76,18 @@ class SemanticSegmentationPredictor(ImagePredictor):
76
76
  )
77
77
  return outputs
78
78
 
79
- def get_annotated_image(self, image: Image.Image, mask: np.array) -> Image.Image:
79
+ def get_annotated_image(
80
+ self, image: Image.Image, mask: np.array, mask_opacity: float = 0.3
81
+ ) -> Image.Image:
80
82
  opencv_image = pil_to_opencv(image)
81
-
82
- mask_rgb = np.zeros((*mask.shape, 3), dtype=np.uint8)
83
+ mask_overlay = opencv_image.copy()
83
84
 
84
85
  for class_id, class_name in self.classes.items():
85
86
  fill_color = self.color_scheme.get(class_name, (120, 120, 120))
86
- mask_rgb[mask == class_id] = fill_color[:3]
87
+ mask_overlay[mask == class_id] = fill_color[:3]
87
88
 
88
- annotated_image = cv2.addWeighted(opencv_image, 0.7, mask_rgb, 0.3, 0)
89
+ annotated_image = cv2.addWeighted(
90
+ mask_overlay, mask_opacity, opencv_image, 1 - mask_opacity, 0
91
+ )
89
92
 
90
93
  return opencv_to_pil(annotated_image)
@@ -0,0 +1,236 @@
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) -> "VectorIndex":
90
+ """
91
+ Aggregate the vectors into a single centroid per label.
92
+ """
93
+ label_to_indices: dict = {}
94
+ for i, label_group in enumerate(self.labels):
95
+ for label in label_group:
96
+ if label not in label_to_indices:
97
+ label_to_indices[label] = []
98
+ label_to_indices[label].append(i)
99
+
100
+ unique_labels = sorted(label_to_indices.keys(), key=str)
101
+ centroids = np.empty(
102
+ (len(unique_labels), self.vectors.shape[1]), dtype=self.vectors.dtype
103
+ )
104
+
105
+ for i, label in enumerate(unique_labels):
106
+ indices = label_to_indices[label]
107
+ centroid = self.vectors[indices].mean(axis=0)
108
+ norm = np.linalg.norm(centroid)
109
+ if norm > 0:
110
+ centroid = centroid / norm
111
+ centroids[i] = centroid
112
+
113
+ new_labels = [[label] for label in unique_labels]
114
+ return VectorIndex(vectors=centroids, labels=new_labels)
115
+
116
+ def get_serving_container_image_uri(self) -> str:
117
+ warnings.warn(
118
+ "VectorIndex does not support serving via a container. Returning incompatible image URI."
119
+ )
120
+ return "us-west1-docker.pkg.dev/shiftsmartinc/orient-express/image-onnx:v2.1.2"
121
+
122
+ def get_serving_container_health_route(self, model_name) -> str:
123
+ return f"/v1/models/{model_name}"
124
+
125
+ def get_serving_container_predict_route(self, model_name) -> str:
126
+ return f"/v1/models/{model_name}:predict"
127
+
128
+ def dump(self, dir: str) -> list[str]:
129
+ artifact_path = os.path.join(dir, self.ARTIFACT_FILENAME)
130
+ np.savez(
131
+ artifact_path,
132
+ vectors=self.vectors,
133
+ labels_json=json.dumps(self.labels),
134
+ )
135
+
136
+ metadata = {
137
+ "model_type": self.model_type,
138
+ "model_file": self.ARTIFACT_FILENAME,
139
+ }
140
+ metadata_path = get_metadata_path(dir)
141
+ with open(metadata_path, "w") as f:
142
+ yaml.dump(metadata, f)
143
+
144
+ return [metadata_path, artifact_path]
145
+
146
+ @property
147
+ def dim(self) -> int:
148
+ return self.vectors.shape[1]
149
+
150
+ def __len__(self) -> int:
151
+ return self.vectors.shape[0]
152
+
153
+ def __repr__(self) -> str:
154
+ unique_labels = set()
155
+ for group in self.labels:
156
+ unique_labels.update(group)
157
+ return (
158
+ f"VectorIndex({len(self)} vectors, dim={self.dim}, "
159
+ f"{len(unique_labels)} unique labels)"
160
+ )
161
+
162
+
163
+ class _CropDataset:
164
+ def __init__(self, crops: list[Image.Image | str | CropSpec]):
165
+ self.crops = crops
166
+
167
+ def __len__(self):
168
+ return len(self.crops)
169
+
170
+ def __getitem__(self, idx):
171
+ crop = self.crops[idx]
172
+ if isinstance(crop, CropSpec):
173
+ img = Image.open(crop.path).convert("RGB")
174
+ return img.crop(crop.bbox)
175
+ if isinstance(crop, str):
176
+ return Image.open(crop).convert("RGB")
177
+ if isinstance(crop, Image.Image):
178
+ return crop
179
+ raise TypeError(
180
+ f"Each crop must be a PIL Image, a file path string, or a CropSpec, got {type(crop)}"
181
+ )
182
+
183
+
184
+ def _collate_pil(batch):
185
+ return list(batch)
186
+
187
+
188
+ def build_vector_index(
189
+ crops: list[Image.Image | str | CropSpec],
190
+ labels: list,
191
+ feature_extractor,
192
+ batch_size: int = 128,
193
+ normalize: bool = True,
194
+ multi_label: bool = False,
195
+ num_workers: int = 0,
196
+ ) -> VectorIndex:
197
+ """Build a VectorIndex by extracting features from crops.
198
+
199
+ Args:
200
+ crops: images to extract features from. Each element can be a PIL
201
+ Image (used directly), a file path string (loaded as a whole
202
+ image), or a CropSpec (loaded and cropped to the specified bbox).
203
+ labels: one label per crop. If multi_label is False, each element is a
204
+ single label. If multi_label is True, each element should be a list
205
+ of labels.
206
+ feature_extractor: anything with a .predict(list[Image]) method that
207
+ returns objects with a .feature attribute (e.g. FeatureExtractionPredictor).
208
+ batch_size: number of crops to process at once.
209
+ normalize: whether to L2-normalize the resulting feature vectors.
210
+ multi_label: if True, labels are already lists of labels per crop.
211
+ num_workers: number of DataLoader workers for parallel image loading.
212
+ """
213
+ if len(crops) != len(labels):
214
+ raise ValueError(
215
+ f"crops length ({len(crops)}) must match labels length ({len(labels)})"
216
+ )
217
+
218
+ if multi_label:
219
+ index_labels = [list(group) for group in labels]
220
+ else:
221
+ index_labels = [[label] for label in labels]
222
+
223
+ loader = DataLoader(
224
+ _CropDataset(crops),
225
+ batch_size=batch_size,
226
+ num_workers=num_workers,
227
+ collate_fn=_collate_pil,
228
+ )
229
+
230
+ all_features = []
231
+ for batch in tqdm(loader):
232
+ results = feature_extractor.predict(batch)
233
+ all_features.extend([r.feature for r in results])
234
+
235
+ vectors = np.vstack(all_features)
236
+ 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.0"
3
+ version = "2.3.0"
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"
@@ -11,8 +11,8 @@ google-cloud-aiplatform = "^1.0"
11
11
  google-cloud-storage = "^3.0"
12
12
  pandas = "^2.0"
13
13
  pyyaml = "^6.0"
14
- torch = {version = "^2.5.0", source = "pytorch-cpu"}
15
- torchvision = {version = "^0.20.0", source = "pytorch-cpu"}
14
+ torch = ">=2.5.0"
15
+ torchvision = ">=0.20.0"
16
16
  opencv-python-headless = "^4.11.0"
17
17
  Pillow = ">=10.0.1"
18
18
  gcsfs = "^2024.10.0"
@@ -30,8 +30,3 @@ python-json-logger = "^4.0.0"
30
30
  [build-system]
31
31
  requires = ["poetry-core"]
32
32
  build-backend = "poetry.core.masonry.api"
33
-
34
- [[tool.poetry.source]]
35
- name = "pytorch-cpu"
36
- url = "https://download.pytorch.org/whl/cpu"
37
- priority = "explicit"