orient_express 2.3.1__tar.gz → 2.4.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.3.1 → orient_express-2.4.1}/PKG-INFO +211 -18
  2. {orient_express-2.3.1 → orient_express-2.4.1}/README.md +210 -17
  3. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/__init__.py +1 -0
  4. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/predictor.py +1 -1
  5. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/semantic_segmentation.py +15 -6
  6. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/vector_index.py +37 -24
  7. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/utils/image_processor.py +6 -0
  8. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/vertex.py +18 -11
  9. {orient_express-2.3.1 → orient_express-2.4.1}/pyproject.toml +4 -1
  10. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/__init__.py +0 -0
  11. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/deployment.py +0 -0
  12. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/model_wrapper.py +0 -0
  13. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/classification.py +0 -0
  14. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/feature_extraction.py +0 -0
  15. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/instance_segmentation.py +0 -0
  16. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/multi_label_classification.py +0 -0
  17. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/predictors/object_detection.py +0 -0
  18. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/sklearn_pipeline.py +0 -0
  19. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/utils/colors.py +0 -0
  20. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/utils/gs.py +0 -0
  21. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/utils/paths.py +0 -0
  22. {orient_express-2.3.1 → orient_express-2.4.1}/orient_express/utils/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orient_express
3
- Version: 2.3.1
3
+ Version: 2.4.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
@@ -46,11 +46,13 @@ pip install orient_express
46
46
  ```
47
47
 
48
48
  For local development:
49
+
49
50
  ```bash
50
51
  pip install -e .
51
52
  ```
52
53
 
53
54
  Or with Poetry:
55
+
54
56
  ```bash
55
57
  poetry install
56
58
  ```
@@ -102,8 +104,8 @@ vertex_model.deploy_to_endpoint(
102
104
 
103
105
  # remote prediction API depends on the endpoint container deployed with the model
104
106
  predictions = vertex_model.remote_predict(
105
- [{"image": "https://storage.googleapis.com/ssm-media-uploads/example.jpg"}],
106
- endpoint_name="my-classifier-endpoint"
107
+ "my-classifier-endpoint",
108
+ [{"image": "https://storage.googleapis.com/ssm-media-uploads/example.jpg"}],
107
109
  )
108
110
  ```
109
111
 
@@ -179,19 +181,20 @@ predictions = local_predictor.predict(X_test)
179
181
  ### Platform Support Matrix
180
182
 
181
183
  | Platform | Architecture | ONNX Runtime Package | CUDA Available |
182
- |----------|--------------|---------------------|----------------|
183
- | Linux | x86_64 | onnxruntime-gpu | Yes |
184
- | Linux | aarch64 | onnxruntime | No |
185
- | Windows | x64 (AMD64) | onnxruntime-gpu | Yes |
186
- | Windows | ARM64 | onnxruntime | No |
187
- | macOS | x86_64 | onnxruntime | No |
188
- | macOS | arm64 | onnxruntime | No |
184
+ | -------- | ------------ | -------------------- | -------------- |
185
+ | Linux | x86_64 | onnxruntime-gpu | Yes |
186
+ | Linux | aarch64 | onnxruntime | No |
187
+ | Windows | x64 (AMD64) | onnxruntime-gpu | Yes |
188
+ | Windows | ARM64 | onnxruntime | No |
189
+ | macOS | x86_64 | onnxruntime | No |
190
+ | macOS | arm64 | onnxruntime | No |
189
191
 
190
192
  The appropriate package is installed automatically based on your platform.
191
193
 
192
194
  ### Selecting CPU vs CUDA Execution
193
195
 
194
196
  When loading a predictor, use the `device` parameter to specify the execution provider:
197
+
195
198
  ```python
196
199
  from orient_express.predictors import ObjectDetectionPredictor
197
200
 
@@ -203,6 +206,7 @@ predictor = ObjectDetectionPredictor("/path/to/model", classes, device="cuda")
203
206
  ```
204
207
 
205
208
  When using a Vertex AI model:
209
+
206
210
  ```python
207
211
  # CPU inference
208
212
  predictor = model.get_local_predictor(device="cpu")
@@ -540,11 +544,8 @@ index = build_vector_index(
540
544
  num_workers=8, # parallel image loading
541
545
  )
542
546
 
543
- # Aggregate to one centroid per label
544
- centroid_index = index.aggregate()
545
-
546
547
  # Save and load
547
- centroid_index.dump("/path/to/artifact_dir")
548
+ index.dump("/path/to/artifact_dir")
548
549
 
549
550
  from orient_express.predictors import get_predictor
550
551
  loaded_index = get_predictor("/path/to/artifact_dir")
@@ -560,16 +561,23 @@ batch_results = loaded_index.search_batch(query_matrix, k=5)
560
561
 
561
562
  #### Multi-label support
562
563
 
563
- Vectors can have multiple labels. This is useful when a single visual cluster maps to multiple SKUs:
564
+ Vectors can have composite labels (use tuples). This is useful when a single visual cluster maps to multiple things:
564
565
 
565
566
  ```python
566
567
  index = VectorIndex(
567
568
  vectors=feature_matrix,
568
- labels=[["sku_101", "sku_102"], ["sku_103"], ["sku_104", "sku_105"]],
569
+ labels=[("sku_101", "sku_102"), ("sku_103",)],
569
570
  )
571
+ ```
570
572
 
571
- # aggregate() computes one centroid per unique label
572
- aggregated = index.aggregate() # 5 vectors, one per SKU
573
+ #### Per-label aggregation
574
+
575
+ Vector indices in which labels are not unique can be aggregated so that each label has a single centroid.
576
+ If `per_label=True` and the labels are composite (tuples), then the labels will be unpacked and aggregated separately.
577
+
578
+ ```python
579
+ aggregated = index.aggregate(per_label=True) # 3 vectors, one per label element ["sku_101", "sku_102", "sku_103"]
580
+ aggregated = index.aggregate(per_label=False) # 2 vectors, one per composite label [("sku_101", "sku_102"), ("sku_103")]
573
581
  ```
574
582
 
575
583
  #### Output Structure
@@ -585,6 +593,191 @@ class SearchResult:
585
593
 
586
594
  ---
587
595
 
596
+ ## Deployed Endpoint APIs
597
+
598
+ When you upload a model with orient-express and deploy it to a Vertex AI endpoint, the actual HTTP API exposed by the endpoint is determined by the serving container image — not by your Python predictor code. Orient-express ships two such images:
599
+
600
+ - `image-onnx` — serves the built-in ONNX predictor types (classification, detection, segmentation).
601
+ - `xgboost-scikit-learn` — serves any joblib-loadable model (sklearn pipelines, xgboost, etc.).
602
+
603
+ This section documents the request/response shape each image's endpoint exposes once deployed.
604
+
605
+ ### How the docker images connect to GCP endpoints
606
+
607
+ The deployment flow is:
608
+
609
+ 1. **Train + export.** You build a predictor locally (e.g. `ClassificationPredictor("model.onnx", classes)`) or train a sklearn/xgboost model.
610
+ 2. **Upload.** `upload_model` / `upload_model_joblib` pushes the artifacts to GCS under `gs://<bucket>/models/<model_name>/<version>/` and registers a Vertex AI Model with `serving_container_image_uri` pointing at one of orient-express's images.
611
+ 3. **Deploy.** `vertex_model.deploy_to_endpoint(...)` (or the Vertex console) attaches the registered model to a Vertex AI Endpoint. Vertex starts the container with `AIP_STORAGE_URI` set to the GCS path from step 2, plus `MODEL_NAME` set to the model's display name.
612
+ 4. **Serve.** The container downloads the artifacts on startup, instantiates the right predictor via metadata, and listens on `/v1/models/<MODEL_NAME>:predict`.
613
+ 5. **Call.** Clients POST to `https://<region>-aiplatform.googleapis.com/v1/projects/<project>/locations/<region>/endpoints/<endpoint_id>:predict` with a Bearer token. Vertex routes the request into the container and returns the JSON response.
614
+
615
+ Every endpoint accepts the same envelope:
616
+
617
+ ```json
618
+ {
619
+ "instances": [...],
620
+ "parameters": {...}
621
+ }
622
+ ```
623
+
624
+ What goes in `instances` / `parameters`, and what comes back in `predictions`, is per-image and per-predictor — covered below.
625
+
626
+ ### ONNX Image Endpoint
627
+
628
+ Image: `us-west1-docker.pkg.dev/shiftsmart-api/orient-express/image-onnx:<tag>`
629
+
630
+ Common request shape across all ONNX predictor types:
631
+
632
+ ```json
633
+ {
634
+ "instances": [
635
+ {"image": "<http(s) URL | gs:// URI | base64 | data URI>"}
636
+ ],
637
+ "parameters": {
638
+ "confidence": 0.5
639
+ }
640
+ }
641
+ ```
642
+
643
+ Common response envelope:
644
+
645
+ ```json
646
+ {
647
+ "predictions": [
648
+ {"status": "success", ...predictor-specific fields...}
649
+ ]
650
+ }
651
+ ```
652
+
653
+ `status` values: `"success"`, `"failed to download image"`, or `"failed to get debug image"`. Malformed payloads return top-level `{"error": "Failed to decode input"}` instead of `predictions`.
654
+
655
+ The predictor-specific fields differ by model type — covered in the subsections below.
656
+
657
+ #### Classification
658
+
659
+ For models uploaded as `ClassificationPredictor`. `parameters.confidence` is **not** honored (the predictor always returns the top class).
660
+
661
+ Per-image response:
662
+
663
+ ```json
664
+ {
665
+ "status": "success",
666
+ "class": "cat",
667
+ "score": 0.95,
668
+ "class_scores": {"cat": 0.95, "dog": 0.03, "bird": 0.02}
669
+ }
670
+ ```
671
+
672
+ No `debug_image` for classification (nothing meaningful to draw).
673
+
674
+ #### Multi-label classification
675
+
676
+ For models uploaded as `MultiLabelClassificationPredictor`. `parameters.confidence` is the per-class threshold for inclusion in `classes` (default `0.5`).
677
+
678
+ Per-image response:
679
+
680
+ ```json
681
+ {
682
+ "status": "success",
683
+ "predictions": {
684
+ "classes": ["contains_cat", "contains_bird"],
685
+ "class_scores": {"contains_cat": 0.95, "contains_dog": 0.03, "contains_bird": 0.82}
686
+ },
687
+ "debug_image": null
688
+ }
689
+ ```
690
+
691
+ `debug_image` is always `null` for multi-label.
692
+
693
+ #### Object detection
694
+
695
+ For models uploaded as `BoundingBoxPredictor`. `parameters.confidence` filters detections below the threshold (default `0.5`).
696
+
697
+ Per-image response:
698
+
699
+ ```json
700
+ {
701
+ "status": "success",
702
+ "predictions": [
703
+ {"class": "person", "score": 0.92, "bbox": {"x1": 100.5, "y1": 50.2, "x2": 300.8, "y2": 400.1}}
704
+ ],
705
+ "debug_image": "<base64 JPEG with boxes overlaid>"
706
+ }
707
+ ```
708
+
709
+ `bbox` coordinates are in pixels of the original (EXIF-corrected) image. `predictions` is an empty list when nothing clears the confidence threshold.
710
+
711
+ #### Instance segmentation
712
+
713
+ For models uploaded as `InstanceSegmentationPredictor`. `parameters.confidence` filters detections (default `0.5`).
714
+
715
+ Per-image response:
716
+
717
+ ```json
718
+ {
719
+ "status": "success",
720
+ "predictions": [
721
+ {"class": "person", "score": 0.89, "bbox": {"x1": 100.5, "y1": 50.2, "x2": 300.8, "y2": 400.1}}
722
+ ],
723
+ "debug_image": "<base64 JPEG with masks overlaid>"
724
+ }
725
+ ```
726
+
727
+ Per-instance mask arrays are **not** included in the response by default (too large). The annotated mask overlay is baked into `debug_image`.
728
+
729
+ #### Semantic segmentation
730
+
731
+ For models uploaded as `SemanticSegmentationPredictor`. `parameters.confidence` is the per-pixel threshold above which a class is considered "valid" (default `0.5`).
732
+
733
+ Per-image response:
734
+
735
+ ```json
736
+ {
737
+ "status": "success",
738
+ "predictions": {
739
+ "class_mask": "<base64 PNG, uint8, per-pixel class id>",
740
+ "valid_mask": "<base64 PNG, uint8, 0=below threshold, 1=above>"
741
+ },
742
+ "debug_image": "<base64 JPEG with color-coded overlay>"
743
+ }
744
+ ```
745
+
746
+ `class_mask` always paints every pixel with the argmax winner. `valid_mask` tells you which pixels actually cleared the confidence threshold — AND them together client-side to get the "real" segmentation.
747
+
748
+ ### XGBoost / scikit-learn Endpoint
749
+
750
+ Image: `us-west1-docker.pkg.dev/shiftsmart-api/orient-express/xgboost-scikit-learn:<tag>`
751
+
752
+ For models uploaded via `upload_model_joblib` — sklearn pipelines, xgboost models, or anything `joblib.load`-able with a `.predict(DataFrame)` method.
753
+
754
+ Request shape — `instances` is a list of dicts, one row per input:
755
+
756
+ ```json
757
+ {
758
+ "instances": [
759
+ {"pclass": 1, "sex": "female", "age": 29, "fare": 100.0, "embarked": "S"},
760
+ {"pclass": 3, "sex": "male", "age": 35, "fare": 8.05, "embarked": "S"}
761
+ ]
762
+ }
763
+ ```
764
+
765
+ The server constructs `pd.DataFrame(instances)` and calls `model.predict(df)` on it. The columns your pipeline expects must be present in each instance dict.
766
+
767
+ Response shape — one prediction per input row:
768
+
769
+ ```json
770
+ {
771
+ "predictions": [0, 1]
772
+ }
773
+ ```
774
+
775
+ Each element is whatever your `.predict()` returns — a class label for classifiers, a numeric value for regressors, an array for multi-output models.
776
+
777
+ `parameters` is ignored — there's no per-request configuration for this image.
778
+
779
+ ---
780
+
588
781
  ## Color Schemes
589
782
 
590
783
  For predictors that support annotation (`BoundingBoxPredictor`, `InstanceSegmentationPredictor`, `SemanticSegmentationPredictor`), you can set a custom color scheme:
@@ -19,11 +19,13 @@ pip install orient_express
19
19
  ```
20
20
 
21
21
  For local development:
22
+
22
23
  ```bash
23
24
  pip install -e .
24
25
  ```
25
26
 
26
27
  Or with Poetry:
28
+
27
29
  ```bash
28
30
  poetry install
29
31
  ```
@@ -75,8 +77,8 @@ vertex_model.deploy_to_endpoint(
75
77
 
76
78
  # remote prediction API depends on the endpoint container deployed with the model
77
79
  predictions = vertex_model.remote_predict(
78
- [{"image": "https://storage.googleapis.com/ssm-media-uploads/example.jpg"}],
79
- endpoint_name="my-classifier-endpoint"
80
+ "my-classifier-endpoint",
81
+ [{"image": "https://storage.googleapis.com/ssm-media-uploads/example.jpg"}],
80
82
  )
81
83
  ```
82
84
 
@@ -152,19 +154,20 @@ predictions = local_predictor.predict(X_test)
152
154
  ### Platform Support Matrix
153
155
 
154
156
  | Platform | Architecture | ONNX Runtime Package | CUDA Available |
155
- |----------|--------------|---------------------|----------------|
156
- | Linux | x86_64 | onnxruntime-gpu | Yes |
157
- | Linux | aarch64 | onnxruntime | No |
158
- | Windows | x64 (AMD64) | onnxruntime-gpu | Yes |
159
- | Windows | ARM64 | onnxruntime | No |
160
- | macOS | x86_64 | onnxruntime | No |
161
- | macOS | arm64 | onnxruntime | No |
157
+ | -------- | ------------ | -------------------- | -------------- |
158
+ | Linux | x86_64 | onnxruntime-gpu | Yes |
159
+ | Linux | aarch64 | onnxruntime | No |
160
+ | Windows | x64 (AMD64) | onnxruntime-gpu | Yes |
161
+ | Windows | ARM64 | onnxruntime | No |
162
+ | macOS | x86_64 | onnxruntime | No |
163
+ | macOS | arm64 | onnxruntime | No |
162
164
 
163
165
  The appropriate package is installed automatically based on your platform.
164
166
 
165
167
  ### Selecting CPU vs CUDA Execution
166
168
 
167
169
  When loading a predictor, use the `device` parameter to specify the execution provider:
170
+
168
171
  ```python
169
172
  from orient_express.predictors import ObjectDetectionPredictor
170
173
 
@@ -176,6 +179,7 @@ predictor = ObjectDetectionPredictor("/path/to/model", classes, device="cuda")
176
179
  ```
177
180
 
178
181
  When using a Vertex AI model:
182
+
179
183
  ```python
180
184
  # CPU inference
181
185
  predictor = model.get_local_predictor(device="cpu")
@@ -513,11 +517,8 @@ index = build_vector_index(
513
517
  num_workers=8, # parallel image loading
514
518
  )
515
519
 
516
- # Aggregate to one centroid per label
517
- centroid_index = index.aggregate()
518
-
519
520
  # Save and load
520
- centroid_index.dump("/path/to/artifact_dir")
521
+ index.dump("/path/to/artifact_dir")
521
522
 
522
523
  from orient_express.predictors import get_predictor
523
524
  loaded_index = get_predictor("/path/to/artifact_dir")
@@ -533,16 +534,23 @@ batch_results = loaded_index.search_batch(query_matrix, k=5)
533
534
 
534
535
  #### Multi-label support
535
536
 
536
- Vectors can have multiple labels. This is useful when a single visual cluster maps to multiple SKUs:
537
+ Vectors can have composite labels (use tuples). This is useful when a single visual cluster maps to multiple things:
537
538
 
538
539
  ```python
539
540
  index = VectorIndex(
540
541
  vectors=feature_matrix,
541
- labels=[["sku_101", "sku_102"], ["sku_103"], ["sku_104", "sku_105"]],
542
+ labels=[("sku_101", "sku_102"), ("sku_103",)],
542
543
  )
544
+ ```
543
545
 
544
- # aggregate() computes one centroid per unique label
545
- aggregated = index.aggregate() # 5 vectors, one per SKU
546
+ #### Per-label aggregation
547
+
548
+ Vector indices in which labels are not unique can be aggregated so that each label has a single centroid.
549
+ If `per_label=True` and the labels are composite (tuples), then the labels will be unpacked and aggregated separately.
550
+
551
+ ```python
552
+ aggregated = index.aggregate(per_label=True) # 3 vectors, one per label element ["sku_101", "sku_102", "sku_103"]
553
+ aggregated = index.aggregate(per_label=False) # 2 vectors, one per composite label [("sku_101", "sku_102"), ("sku_103")]
546
554
  ```
547
555
 
548
556
  #### Output Structure
@@ -558,6 +566,191 @@ class SearchResult:
558
566
 
559
567
  ---
560
568
 
569
+ ## Deployed Endpoint APIs
570
+
571
+ When you upload a model with orient-express and deploy it to a Vertex AI endpoint, the actual HTTP API exposed by the endpoint is determined by the serving container image — not by your Python predictor code. Orient-express ships two such images:
572
+
573
+ - `image-onnx` — serves the built-in ONNX predictor types (classification, detection, segmentation).
574
+ - `xgboost-scikit-learn` — serves any joblib-loadable model (sklearn pipelines, xgboost, etc.).
575
+
576
+ This section documents the request/response shape each image's endpoint exposes once deployed.
577
+
578
+ ### How the docker images connect to GCP endpoints
579
+
580
+ The deployment flow is:
581
+
582
+ 1. **Train + export.** You build a predictor locally (e.g. `ClassificationPredictor("model.onnx", classes)`) or train a sklearn/xgboost model.
583
+ 2. **Upload.** `upload_model` / `upload_model_joblib` pushes the artifacts to GCS under `gs://<bucket>/models/<model_name>/<version>/` and registers a Vertex AI Model with `serving_container_image_uri` pointing at one of orient-express's images.
584
+ 3. **Deploy.** `vertex_model.deploy_to_endpoint(...)` (or the Vertex console) attaches the registered model to a Vertex AI Endpoint. Vertex starts the container with `AIP_STORAGE_URI` set to the GCS path from step 2, plus `MODEL_NAME` set to the model's display name.
585
+ 4. **Serve.** The container downloads the artifacts on startup, instantiates the right predictor via metadata, and listens on `/v1/models/<MODEL_NAME>:predict`.
586
+ 5. **Call.** Clients POST to `https://<region>-aiplatform.googleapis.com/v1/projects/<project>/locations/<region>/endpoints/<endpoint_id>:predict` with a Bearer token. Vertex routes the request into the container and returns the JSON response.
587
+
588
+ Every endpoint accepts the same envelope:
589
+
590
+ ```json
591
+ {
592
+ "instances": [...],
593
+ "parameters": {...}
594
+ }
595
+ ```
596
+
597
+ What goes in `instances` / `parameters`, and what comes back in `predictions`, is per-image and per-predictor — covered below.
598
+
599
+ ### ONNX Image Endpoint
600
+
601
+ Image: `us-west1-docker.pkg.dev/shiftsmart-api/orient-express/image-onnx:<tag>`
602
+
603
+ Common request shape across all ONNX predictor types:
604
+
605
+ ```json
606
+ {
607
+ "instances": [
608
+ {"image": "<http(s) URL | gs:// URI | base64 | data URI>"}
609
+ ],
610
+ "parameters": {
611
+ "confidence": 0.5
612
+ }
613
+ }
614
+ ```
615
+
616
+ Common response envelope:
617
+
618
+ ```json
619
+ {
620
+ "predictions": [
621
+ {"status": "success", ...predictor-specific fields...}
622
+ ]
623
+ }
624
+ ```
625
+
626
+ `status` values: `"success"`, `"failed to download image"`, or `"failed to get debug image"`. Malformed payloads return top-level `{"error": "Failed to decode input"}` instead of `predictions`.
627
+
628
+ The predictor-specific fields differ by model type — covered in the subsections below.
629
+
630
+ #### Classification
631
+
632
+ For models uploaded as `ClassificationPredictor`. `parameters.confidence` is **not** honored (the predictor always returns the top class).
633
+
634
+ Per-image response:
635
+
636
+ ```json
637
+ {
638
+ "status": "success",
639
+ "class": "cat",
640
+ "score": 0.95,
641
+ "class_scores": {"cat": 0.95, "dog": 0.03, "bird": 0.02}
642
+ }
643
+ ```
644
+
645
+ No `debug_image` for classification (nothing meaningful to draw).
646
+
647
+ #### Multi-label classification
648
+
649
+ For models uploaded as `MultiLabelClassificationPredictor`. `parameters.confidence` is the per-class threshold for inclusion in `classes` (default `0.5`).
650
+
651
+ Per-image response:
652
+
653
+ ```json
654
+ {
655
+ "status": "success",
656
+ "predictions": {
657
+ "classes": ["contains_cat", "contains_bird"],
658
+ "class_scores": {"contains_cat": 0.95, "contains_dog": 0.03, "contains_bird": 0.82}
659
+ },
660
+ "debug_image": null
661
+ }
662
+ ```
663
+
664
+ `debug_image` is always `null` for multi-label.
665
+
666
+ #### Object detection
667
+
668
+ For models uploaded as `BoundingBoxPredictor`. `parameters.confidence` filters detections below the threshold (default `0.5`).
669
+
670
+ Per-image response:
671
+
672
+ ```json
673
+ {
674
+ "status": "success",
675
+ "predictions": [
676
+ {"class": "person", "score": 0.92, "bbox": {"x1": 100.5, "y1": 50.2, "x2": 300.8, "y2": 400.1}}
677
+ ],
678
+ "debug_image": "<base64 JPEG with boxes overlaid>"
679
+ }
680
+ ```
681
+
682
+ `bbox` coordinates are in pixels of the original (EXIF-corrected) image. `predictions` is an empty list when nothing clears the confidence threshold.
683
+
684
+ #### Instance segmentation
685
+
686
+ For models uploaded as `InstanceSegmentationPredictor`. `parameters.confidence` filters detections (default `0.5`).
687
+
688
+ Per-image response:
689
+
690
+ ```json
691
+ {
692
+ "status": "success",
693
+ "predictions": [
694
+ {"class": "person", "score": 0.89, "bbox": {"x1": 100.5, "y1": 50.2, "x2": 300.8, "y2": 400.1}}
695
+ ],
696
+ "debug_image": "<base64 JPEG with masks overlaid>"
697
+ }
698
+ ```
699
+
700
+ Per-instance mask arrays are **not** included in the response by default (too large). The annotated mask overlay is baked into `debug_image`.
701
+
702
+ #### Semantic segmentation
703
+
704
+ For models uploaded as `SemanticSegmentationPredictor`. `parameters.confidence` is the per-pixel threshold above which a class is considered "valid" (default `0.5`).
705
+
706
+ Per-image response:
707
+
708
+ ```json
709
+ {
710
+ "status": "success",
711
+ "predictions": {
712
+ "class_mask": "<base64 PNG, uint8, per-pixel class id>",
713
+ "valid_mask": "<base64 PNG, uint8, 0=below threshold, 1=above>"
714
+ },
715
+ "debug_image": "<base64 JPEG with color-coded overlay>"
716
+ }
717
+ ```
718
+
719
+ `class_mask` always paints every pixel with the argmax winner. `valid_mask` tells you which pixels actually cleared the confidence threshold — AND them together client-side to get the "real" segmentation.
720
+
721
+ ### XGBoost / scikit-learn Endpoint
722
+
723
+ Image: `us-west1-docker.pkg.dev/shiftsmart-api/orient-express/xgboost-scikit-learn:<tag>`
724
+
725
+ For models uploaded via `upload_model_joblib` — sklearn pipelines, xgboost models, or anything `joblib.load`-able with a `.predict(DataFrame)` method.
726
+
727
+ Request shape — `instances` is a list of dicts, one row per input:
728
+
729
+ ```json
730
+ {
731
+ "instances": [
732
+ {"pclass": 1, "sex": "female", "age": 29, "fare": 100.0, "embarked": "S"},
733
+ {"pclass": 3, "sex": "male", "age": 35, "fare": 8.05, "embarked": "S"}
734
+ ]
735
+ }
736
+ ```
737
+
738
+ The server constructs `pd.DataFrame(instances)` and calls `model.predict(df)` on it. The columns your pipeline expects must be present in each instance dict.
739
+
740
+ Response shape — one prediction per input row:
741
+
742
+ ```json
743
+ {
744
+ "predictions": [0, 1]
745
+ }
746
+ ```
747
+
748
+ Each element is whatever your `.predict()` returns — a class label for classifiers, a numeric value for regressors, an array for multi-output models.
749
+
750
+ `parameters` is ignored — there's no per-request configuration for this image.
751
+
752
+ ---
753
+
561
754
  ## Color Schemes
562
755
 
563
756
  For predictors that support annotation (`BoundingBoxPredictor`, `InstanceSegmentationPredictor`, `SemanticSegmentationPredictor`), you can set a custom color scheme:
@@ -99,6 +99,7 @@ def load_vector_index(dir: str, metadata: dict | None = None):
99
99
  data = np.load(artifact_path, allow_pickle=False)
100
100
  vectors = data["vectors"]
101
101
  labels = json.loads(str(data["labels_json"]))
102
+ labels = [tuple(label) if isinstance(label, list) else label for label in labels]
102
103
  index = VectorIndex(vectors=vectors, labels=labels)
103
104
  index.model_path = artifact_path
104
105
  return index
@@ -46,7 +46,7 @@ class ImagePredictor(Predictor):
46
46
  self.model_path = model_path
47
47
 
48
48
  def get_serving_container_image_uri(self):
49
- return "us-west1-docker.pkg.dev/shiftsmart-api/orient-express/image-onnx:v2.1.2"
49
+ return "us-west1-docker.pkg.dev/shiftsmart-api/orient-express/image-onnx:v2.4.1"
50
50
 
51
51
  def get_serving_container_health_route(self, model_name):
52
52
  return f"/v1/models/{model_name}"
@@ -7,17 +7,19 @@ import torch
7
7
  import torch.nn.functional as F
8
8
 
9
9
  from .predictor import OnnxSessionWrapper, ImagePredictor
10
- from ..utils.image_processor import pil_to_opencv, opencv_to_pil
10
+ from ..utils.image_processor import mask_to_base64, pil_to_opencv, opencv_to_pil
11
11
 
12
12
 
13
13
  @dataclass
14
14
  class SemanticSegmentationPrediction:
15
15
  class_mask: np.ndarray
16
+ valid_mask: np.ndarray
16
17
  conf_masks: np.ndarray
17
18
 
18
19
  def to_dict(self, include_conf_masks: bool = False):
19
20
  dict_repr = {
20
- "class_mask": self.class_mask,
21
+ "class_mask": mask_to_base64(self.class_mask.astype(np.uint8)),
22
+ "valid_mask": mask_to_base64(self.valid_mask.astype(np.uint8)),
21
23
  }
22
24
  if include_conf_masks:
23
25
  dict_repr["conf_masks"] = self.conf_masks.tolist()
@@ -60,31 +62,38 @@ class SemanticSegmentationPredictor(ImagePredictor):
60
62
  backend_model = OnnxSemanticSegmentation
61
63
 
62
64
  def predict(
63
- self, images: list[Image.Image]
65
+ self, images: list[Image.Image], confidence: float = 0.5
64
66
  ) -> list[SemanticSegmentationPrediction]:
65
67
  if not images:
66
68
  return []
67
69
  raw_outputs = self.model(images)
68
70
  outputs = []
69
71
  for masks in raw_outputs:
70
- class_mask = np.argmax(masks, axis=0)
72
+ class_mask = np.argmax(masks, axis=0).astype(np.uint8)
73
+ valid_mask = np.max(masks, axis=0) >= confidence
71
74
  outputs.append(
72
75
  SemanticSegmentationPrediction(
73
76
  class_mask=class_mask,
77
+ valid_mask=valid_mask,
74
78
  conf_masks=masks,
75
79
  )
76
80
  )
77
81
  return outputs
78
82
 
79
83
  def get_annotated_image(
80
- self, image: Image.Image, mask: np.array, mask_opacity: float = 0.3
84
+ self,
85
+ image: Image.Image,
86
+ prediction: SemanticSegmentationPrediction,
87
+ mask_opacity: float = 0.3,
81
88
  ) -> Image.Image:
82
89
  opencv_image = pil_to_opencv(image)
83
90
  mask_overlay = opencv_image.copy()
84
91
 
85
92
  for class_id, class_name in self.classes.items():
86
93
  fill_color = self.color_scheme.get(class_name, (120, 120, 120))
87
- mask_overlay[mask == class_id] = fill_color[:3]
94
+ mask_overlay[
95
+ (prediction.class_mask == class_id) & prediction.valid_mask
96
+ ] = fill_color[:3]
88
97
 
89
98
  annotated_image = cv2.addWeighted(
90
99
  mask_overlay, mask_opacity, opencv_image, 1 - mask_opacity, 0
@@ -2,6 +2,7 @@ import os
2
2
  import json
3
3
  from dataclasses import dataclass
4
4
  import warnings
5
+ from typing import Any
5
6
 
6
7
  import yaml
7
8
  import numpy as np
@@ -37,7 +38,7 @@ class VectorIndex(Predictor):
37
38
  def __init__(
38
39
  self,
39
40
  vectors: np.ndarray,
40
- labels: list[list],
41
+ labels: list[int | str | tuple],
41
42
  normalize: bool = False,
42
43
  ):
43
44
  if vectors.ndim != 2:
@@ -53,8 +54,16 @@ class VectorIndex(Predictor):
53
54
  # Avoid division by zero for zero vectors
54
55
  norms = np.where(norms == 0, 1, norms)
55
56
  vectors = vectors / norms
57
+
56
58
  self.vectors = vectors
57
59
  self.labels = labels
60
+ self.label_to_idx = {}
61
+
62
+ for i, label in enumerate(labels):
63
+ if label not in self.label_to_idx:
64
+ self.label_to_idx[label] = []
65
+ self.label_to_idx[label].append(i)
66
+
58
67
  self.model_path = None
59
68
 
60
69
  def search(self, query: np.ndarray, k: int = 1) -> list[SearchResult]:
@@ -86,21 +95,36 @@ class VectorIndex(Predictor):
86
95
  )
87
96
  return all_results
88
97
 
98
+ def get_by_idx(self, idx: int) -> np.ndarray:
99
+ return self.vectors[idx]
100
+
101
+ def get_by_idxs(self, idxs: list[int]) -> np.ndarray:
102
+ return self.vectors[idxs]
103
+
104
+ def get_by_label(self, label: Any) -> np.ndarray:
105
+ return self.vectors[self.label_to_idx[label]]
106
+
107
+ def get_by_labels(self, labels: list[Any]) -> np.ndarray:
108
+ idxs = []
109
+ for label in labels:
110
+ idxs.extend(self.label_to_idx[label])
111
+ return self.vectors[idxs]
112
+
89
113
  def aggregate(self, per_label: bool = False) -> "VectorIndex":
90
114
  """
91
115
  Aggregate the vectors into a single centroid per unique label group.
92
116
 
93
117
  Args:
94
118
  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.
119
+ (splitting label groups (tuples) apart). If False (default),
120
+ create one centroid per unique label group.
97
121
  """
98
122
  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]
123
+ for i, label in enumerate(self.labels):
124
+ if per_label and isinstance(label, tuple):
125
+ keys = label
102
126
  else:
103
- keys = [tuple(sorted(label_group))]
127
+ keys = [label]
104
128
  for key in keys:
105
129
  if key not in key_to_indices:
106
130
  key_to_indices[key] = []
@@ -119,7 +143,7 @@ class VectorIndex(Predictor):
119
143
  if norm > 0:
120
144
  centroid = centroid / norm
121
145
  centroids[i] = centroid
122
- new_labels.append([key] if per_label else list(key))
146
+ new_labels.append(key)
123
147
 
124
148
  return VectorIndex(vectors=centroids, labels=new_labels)
125
149
 
@@ -161,12 +185,9 @@ class VectorIndex(Predictor):
161
185
  return self.vectors.shape[0]
162
186
 
163
187
  def __repr__(self) -> str:
164
- unique_labels = set()
165
- for group in self.labels:
166
- unique_labels.update(group)
167
188
  return (
168
189
  f"VectorIndex({len(self)} vectors, dim={self.dim}, "
169
- f"{len(unique_labels)} unique labels)"
190
+ f"{len(set(self.labels))} unique labels)"
170
191
  )
171
192
 
172
193
 
@@ -197,11 +218,10 @@ def _collate_pil(batch):
197
218
 
198
219
  def build_vector_index(
199
220
  crops: list[Image.Image | str | CropSpec],
200
- labels: list,
221
+ labels: list[int | str | tuple],
201
222
  feature_extractor,
202
223
  batch_size: int = 128,
203
224
  normalize: bool = True,
204
- multi_label: bool = False,
205
225
  num_workers: int = 0,
206
226
  ) -> VectorIndex:
207
227
  """Build a VectorIndex by extracting features from crops.
@@ -210,14 +230,12 @@ def build_vector_index(
210
230
  crops: images to extract features from. Each element can be a PIL
211
231
  Image (used directly), a file path string (loaded as a whole
212
232
  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.
233
+ labels: one label per crop. labels can be any hashable type, includeing
234
+ iterables like tuples.
216
235
  feature_extractor: anything with a .predict(list[Image]) method that
217
236
  returns objects with a .feature attribute (e.g. FeatureExtractionPredictor).
218
237
  batch_size: number of crops to process at once.
219
238
  normalize: whether to L2-normalize the resulting feature vectors.
220
- multi_label: if True, labels are already lists of labels per crop.
221
239
  num_workers: number of DataLoader workers for parallel image loading.
222
240
  """
223
241
  if len(crops) != len(labels):
@@ -225,11 +243,6 @@ def build_vector_index(
225
243
  f"crops length ({len(crops)}) must match labels length ({len(labels)})"
226
244
  )
227
245
 
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
246
  loader = DataLoader(
234
247
  _CropDataset(crops),
235
248
  batch_size=batch_size,
@@ -243,4 +256,4 @@ def build_vector_index(
243
256
  all_features.extend([r.feature for r in results])
244
257
 
245
258
  vectors = np.vstack(all_features)
246
- return VectorIndex(vectors=vectors, labels=index_labels, normalize=normalize)
259
+ return VectorIndex(vectors=vectors, labels=labels, normalize=normalize)
@@ -74,6 +74,12 @@ def image_to_base64(image):
74
74
  return base64.b64encode(bytes_content).decode("utf-8")
75
75
 
76
76
 
77
+ def mask_to_base64(mask: np.ndarray) -> str:
78
+ buffered = BytesIO()
79
+ Image.fromarray(mask).save(buffered, format="PNG")
80
+ return base64.b64encode(buffered.getvalue()).decode("utf-8")
81
+
82
+
77
83
  def image_to_bytes(image):
78
84
  buffered = BytesIO()
79
85
  if image.mode == "RGBA":
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import tempfile
3
+ import warnings
3
4
 
4
5
  import yaml
5
6
  import joblib
@@ -287,7 +288,6 @@ def get_vertex_model(
287
288
  ):
288
289
  vertex_init(project_name, region)
289
290
  models = aiplatform.Model.list(filter=f"display_name={model_name}")
290
-
291
291
  if not models:
292
292
  if raise_exception:
293
293
  raise Exception(
@@ -295,19 +295,26 @@ def get_vertex_model(
295
295
  )
296
296
  else:
297
297
  return None
298
+ if len(models) > 1:
299
+ warnings.warn(
300
+ f"Multiple models found with name '{model_name}'. Using the latest one."
301
+ )
302
+
303
+ latest_model = sorted(models, key=lambda x: x.update_time, reverse=True)[0]
304
+ resource_name = latest_model.resource_name
298
305
 
299
306
  if version is None:
300
- latest_model = sorted(models, key=lambda x: x.update_time, reverse=True)[0]
301
307
  return VertexModel(
302
308
  latest_model, model_name, project_name, region, int(latest_model.version_id)
303
309
  )
304
310
 
305
- for model in models:
306
- if int(model.version_id) == version:
307
- return VertexModel(model, model_name, project_name, region, version)
308
-
309
- if raise_exception:
310
- raise Exception(
311
- f"Model '{model_name}' with version '{version}' not found in registry for project '{project_name}' region '{region}'"
312
- )
313
- return None
311
+ if version is not None:
312
+ try:
313
+ model = aiplatform.Model(model_name=resource_name, version=str(version))
314
+ except Exception:
315
+ if raise_exception:
316
+ raise Exception(
317
+ f"Failed to fetch model '{model_name}' with version '{version}' in registry for project '{project_name}' region '{region}'"
318
+ )
319
+ return None
320
+ return VertexModel(model, model_name, project_name, region, version)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "orient_express"
3
- version = "2.3.1"
3
+ version = "2.4.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"
@@ -25,6 +25,9 @@ onnxruntime = {version = "^1.20", markers = "sys_platform == 'darwin' or platfor
25
25
  [tool.poetry.group.dev.dependencies]
26
26
  black = "24.10.0"
27
27
  pytest = "8.3.3"
28
+ ipykernel = "^7.2.0"
29
+
30
+ [tool.poetry.group.server.dependencies]
28
31
  kserve = "^0.16.0"
29
32
  python-json-logger = "^4.0.0"
30
33