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.
- {orient_express-2.2.0 → orient_express-2.3.0}/PKG-INFO +66 -3
- {orient_express-2.2.0 → orient_express-2.3.0}/README.md +63 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/__init__.py +20 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/instance_segmentation.py +40 -18
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/object_detection.py +7 -2
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/semantic_segmentation.py +8 -5
- orient_express-2.3.0/orient_express/predictors/vector_index.py +236 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/pyproject.toml +3 -8
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/__init__.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/deployment.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/model_wrapper.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/classification.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/feature_extraction.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/multi_label_classification.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/predictor.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/sklearn_pipeline.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/colors.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/gs.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/image_processor.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/paths.py +0 -0
- {orient_express-2.2.0 → orient_express-2.3.0}/orient_express/utils/retry.py +0 -0
- {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.
|
|
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
|
|
23
|
-
Requires-Dist: torchvision (>=0.20.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
|
{orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/instance_segmentation.py
RENAMED
|
@@ -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,
|
|
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,
|
|
148
|
+
mask_overlay, mask_opacity, opencv_image, 1 - mask_opacity, 0
|
|
143
149
|
)
|
|
144
150
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
196
|
+
FONT,
|
|
175
197
|
font_scale,
|
|
176
198
|
(255, 255, 255),
|
|
177
|
-
|
|
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,
|
|
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(
|
|
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)
|
{orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/semantic_segmentation.py
RENAMED
|
@@ -76,15 +76,18 @@ class SemanticSegmentationPredictor(ImagePredictor):
|
|
|
76
76
|
)
|
|
77
77
|
return outputs
|
|
78
78
|
|
|
79
|
-
def get_annotated_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
|
-
|
|
87
|
+
mask_overlay[mask == class_id] = fill_color[:3]
|
|
87
88
|
|
|
88
|
-
annotated_image = cv2.addWeighted(
|
|
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.
|
|
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 =
|
|
15
|
-
torchvision =
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{orient_express-2.2.0 → orient_express-2.3.0}/orient_express/predictors/feature_extraction.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|