visynx 0.1.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.
- visynx-0.1.0/.github/workflows/publish.yml +41 -0
- visynx-0.1.0/.gitignore +6 -0
- visynx-0.1.0/LICENSE +21 -0
- visynx-0.1.0/PKG-INFO +208 -0
- visynx-0.1.0/README.md +155 -0
- visynx-0.1.0/pyproject.toml +53 -0
- visynx-0.1.0/src/visynx/__init__.py +42 -0
- visynx-0.1.0/src/visynx/client.py +213 -0
- visynx-0.1.0/src/visynx/model.py +148 -0
- visynx-0.1.0/src/visynx/py.typed +0 -0
- visynx-0.1.0/src/visynx/results.py +182 -0
- visynx-0.1.0/tests/test_visynx.py +266 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Test & Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
# 3.9 is the floor declared in pyproject; 3.13 is the ceiling we claim.
|
|
15
|
+
python-version: ["3.9", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- run: pip install -e ".[dev]"
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: pytest -q
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
# Only on a version tag, and only if the matrix is green.
|
|
27
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
28
|
+
needs: test
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: pypi
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write # PyPI Trusted Publishing — no API token in secrets
|
|
33
|
+
contents: read # a permissions block resets all other scopes to none; checkout needs this
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
- uses: actions/setup-python@v5
|
|
37
|
+
with:
|
|
38
|
+
python-version: "3.12"
|
|
39
|
+
- run: pip install build
|
|
40
|
+
- run: python -m build
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
visynx-0.1.0/.gitignore
ADDED
visynx-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VisionForge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
visynx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: visynx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run your VisionForge computer-vision models from Python.
|
|
5
|
+
Project-URL: Homepage, https://visionforge.evo-ss.com
|
|
6
|
+
Project-URL: Documentation, https://visionforge.evo-ss.com/docs/python-quickstart
|
|
7
|
+
Project-URL: Source, https://github.com/manasjohri/Labelling_Package
|
|
8
|
+
Project-URL: Issues, https://github.com/manasjohri/Labelling_Package/issues
|
|
9
|
+
Author: VisionForge
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 VisionForge
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: computer-vision,detectron2,inference,machine-learning,object-detection,rf-detr,segmentation,visionforge,yolo
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: Intended Audience :: Science/Research
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
44
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
45
|
+
Classifier: Typing :: Typed
|
|
46
|
+
Requires-Python: >=3.9
|
|
47
|
+
Requires-Dist: pillow>=9.0
|
|
48
|
+
Requires-Dist: requests>=2.25
|
|
49
|
+
Provides-Extra: dev
|
|
50
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# visynx
|
|
55
|
+
|
|
56
|
+
Run your [VisionForge](https://visionforge.evo-ss.com) computer-vision models from Python.
|
|
57
|
+
|
|
58
|
+
Train a model in VisionForge, grab an API key, and you are three lines from predictions —
|
|
59
|
+
detection, segmentation, pose, classification, and semantic segmentation, all through one call.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install visynx
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from visynx import Visynx
|
|
69
|
+
|
|
70
|
+
vf = Visynx(api_key="vf_...") # or set VISYNX_API_KEY
|
|
71
|
+
model = vf.model("shelf-scanner/3") # your project name and version
|
|
72
|
+
|
|
73
|
+
result = model.predict("shelf.jpg", confidence=0.4)
|
|
74
|
+
|
|
75
|
+
for d in result:
|
|
76
|
+
print(d.class_name, round(d.confidence, 2), d.box)
|
|
77
|
+
|
|
78
|
+
result.save("annotated.jpg")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
bottle 0.94 [412.0, 118.5, 501.2, 388.0]
|
|
83
|
+
bottle 0.91 [502.6, 121.0, 590.4, 390.7]
|
|
84
|
+
can 0.78 [604.1, 210.3, 664.9, 372.2]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`visynx` pulls in only `requests` and `Pillow`. No OpenCV, no numpy, no torch —
|
|
88
|
+
the models run on VisionForge's GPUs, not yours.
|
|
89
|
+
|
|
90
|
+
## Find your models
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
for m in vf.models():
|
|
94
|
+
print(m.slug, m.provider, m.display_name)
|
|
95
|
+
# shelf-scanner/3 yolo Shelf Scanner v3
|
|
96
|
+
# defect-seg/1 rfdetr Defect Seg v1
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`"shelf-scanner/3"` is the model's slug — your project name and its training
|
|
100
|
+
version, the same name you see in the app. A raw `"yolo/<job_id>"` also works
|
|
101
|
+
and skips the lookup.
|
|
102
|
+
|
|
103
|
+
## What you get back
|
|
104
|
+
|
|
105
|
+
`predict()` returns a `Result`: iterate it for detections, or reach for the raw
|
|
106
|
+
API payload with `.json()`.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
result = model.predict("shelf.jpg")
|
|
110
|
+
|
|
111
|
+
len(result) # 3
|
|
112
|
+
result.detections[0] # Detection('bottle', 0.94)
|
|
113
|
+
result.json() # the raw API response, untouched
|
|
114
|
+
|
|
115
|
+
result.plot() # a PIL image with boxes drawn on
|
|
116
|
+
result.save("out.jpg") # ...written to disk
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Each `Detection` carries `class_id`, `class_name`, `confidence`, and `box`
|
|
120
|
+
(`[x1, y1, x2, y2]` in absolute pixels). Segmentation models add `polygons`;
|
|
121
|
+
pose models add `keypoints`.
|
|
122
|
+
|
|
123
|
+
## Anything image-shaped works
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
model.predict("shelf.jpg") # a path
|
|
127
|
+
model.predict("frames/") # a directory -> list of Results
|
|
128
|
+
model.predict(open("shelf.jpg", "rb")) # an open file
|
|
129
|
+
model.predict(pil_image) # a PIL image
|
|
130
|
+
model.predict(numpy_array) # a numpy array (from OpenCV, say)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Beyond boxes
|
|
134
|
+
|
|
135
|
+
Two flags the hosted GPUs do for you, with nothing extra to install. Each runs a
|
|
136
|
+
second model, so each costs extra credits:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# Turn every detected box into a precise instance polygon with SAM 2
|
|
140
|
+
result = model.predict("shelf.jpg", segment=True)
|
|
141
|
+
result.detections[0].polygons
|
|
142
|
+
|
|
143
|
+
# Add a monocular depth map, and a relative distance per detection
|
|
144
|
+
result = model.predict("shelf.jpg", depth=True)
|
|
145
|
+
result.detections[0].depth_median # 0-1, higher is closer
|
|
146
|
+
result.depth_image.save("depth.png")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Semantic-segmentation models return a dense mask instead of boxes:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
result = model.predict("road.jpg")
|
|
153
|
+
result.semantic["legend"] # [{'class_id': 1, 'class_name': 'road', ...}]
|
|
154
|
+
result.save("mask.jpg") # the mask, blended over your image
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Get an API key
|
|
158
|
+
|
|
159
|
+
In VisionForge, go to **Settings → API Keys**, create a key, and copy the secret —
|
|
160
|
+
it is shown once. Keys start with `vf_`, never expire, and can be locked to
|
|
161
|
+
specific IPs or CIDR ranges. Keep it out of source control:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
vf = Visynx() # reads VISYNX_API_KEY
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A plain `predict()` call spends 1 Vision Credit; `segment=True` and `depth=True`
|
|
168
|
+
each add a surcharge for the extra model they run. You are charged only after the
|
|
169
|
+
inference succeeds — a failed call is free — and a call retried after a `429` is
|
|
170
|
+
billed once, not twice.
|
|
171
|
+
|
|
172
|
+
Some endpoints are deliberately not reachable with an API key: batch prediction
|
|
173
|
+
over a whole dataset, live-camera inference, workflow runs, and the AI-labelling
|
|
174
|
+
tools (SAM/DINO/box-prompt). Those run from the VisionForge app with a signed-in
|
|
175
|
+
session. Calling them with a key returns `PermissionDeniedError`.
|
|
176
|
+
|
|
177
|
+
## Errors
|
|
178
|
+
|
|
179
|
+
Every failure is a typed exception, and it quotes the response's `X-Request-ID`
|
|
180
|
+
when the server sent one — include it if you contact support. (Authentication
|
|
181
|
+
failures are rejected before that header is assigned, so `401`s carry none.)
|
|
182
|
+
|
|
183
|
+
| Exception | Means | Do |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `AuthenticationError` | key invalid or revoked | create a new key |
|
|
186
|
+
| `PermissionDeniedError` | email unverified, or the key's IP allowlist rejected you | verify your email / check the allowlist |
|
|
187
|
+
| `InsufficientCreditsError` | out of Vision Credits | top up |
|
|
188
|
+
| `ModelNotFoundError` | no such model, or not yours | check `vf.models()` |
|
|
189
|
+
| `PayloadTooLargeError` | image over 50 MB | resize before sending |
|
|
190
|
+
| `RateLimitError` | rate limited, retries exhausted | back off |
|
|
191
|
+
| `MaintenanceError` | platform in maintenance | retry later |
|
|
192
|
+
|
|
193
|
+
`429`s are retried automatically, honouring the server's `Retry-After`.
|
|
194
|
+
All of these subclass `VisynxError`, so one `except VisynxError` catches everything.
|
|
195
|
+
|
|
196
|
+
## Self-hosting
|
|
197
|
+
|
|
198
|
+
Point the client at your own deployment:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
vf = Visynx(api_key="vf_...", api_url="https://visionforge.internal/api")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
or set `VISYNX_API_URL`.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
visynx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# visynx
|
|
2
|
+
|
|
3
|
+
Run your [VisionForge](https://visionforge.evo-ss.com) computer-vision models from Python.
|
|
4
|
+
|
|
5
|
+
Train a model in VisionForge, grab an API key, and you are three lines from predictions —
|
|
6
|
+
detection, segmentation, pose, classification, and semantic segmentation, all through one call.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install visynx
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from visynx import Visynx
|
|
16
|
+
|
|
17
|
+
vf = Visynx(api_key="vf_...") # or set VISYNX_API_KEY
|
|
18
|
+
model = vf.model("shelf-scanner/3") # your project name and version
|
|
19
|
+
|
|
20
|
+
result = model.predict("shelf.jpg", confidence=0.4)
|
|
21
|
+
|
|
22
|
+
for d in result:
|
|
23
|
+
print(d.class_name, round(d.confidence, 2), d.box)
|
|
24
|
+
|
|
25
|
+
result.save("annotated.jpg")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
bottle 0.94 [412.0, 118.5, 501.2, 388.0]
|
|
30
|
+
bottle 0.91 [502.6, 121.0, 590.4, 390.7]
|
|
31
|
+
can 0.78 [604.1, 210.3, 664.9, 372.2]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`visynx` pulls in only `requests` and `Pillow`. No OpenCV, no numpy, no torch —
|
|
35
|
+
the models run on VisionForge's GPUs, not yours.
|
|
36
|
+
|
|
37
|
+
## Find your models
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
for m in vf.models():
|
|
41
|
+
print(m.slug, m.provider, m.display_name)
|
|
42
|
+
# shelf-scanner/3 yolo Shelf Scanner v3
|
|
43
|
+
# defect-seg/1 rfdetr Defect Seg v1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`"shelf-scanner/3"` is the model's slug — your project name and its training
|
|
47
|
+
version, the same name you see in the app. A raw `"yolo/<job_id>"` also works
|
|
48
|
+
and skips the lookup.
|
|
49
|
+
|
|
50
|
+
## What you get back
|
|
51
|
+
|
|
52
|
+
`predict()` returns a `Result`: iterate it for detections, or reach for the raw
|
|
53
|
+
API payload with `.json()`.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
result = model.predict("shelf.jpg")
|
|
57
|
+
|
|
58
|
+
len(result) # 3
|
|
59
|
+
result.detections[0] # Detection('bottle', 0.94)
|
|
60
|
+
result.json() # the raw API response, untouched
|
|
61
|
+
|
|
62
|
+
result.plot() # a PIL image with boxes drawn on
|
|
63
|
+
result.save("out.jpg") # ...written to disk
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Each `Detection` carries `class_id`, `class_name`, `confidence`, and `box`
|
|
67
|
+
(`[x1, y1, x2, y2]` in absolute pixels). Segmentation models add `polygons`;
|
|
68
|
+
pose models add `keypoints`.
|
|
69
|
+
|
|
70
|
+
## Anything image-shaped works
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
model.predict("shelf.jpg") # a path
|
|
74
|
+
model.predict("frames/") # a directory -> list of Results
|
|
75
|
+
model.predict(open("shelf.jpg", "rb")) # an open file
|
|
76
|
+
model.predict(pil_image) # a PIL image
|
|
77
|
+
model.predict(numpy_array) # a numpy array (from OpenCV, say)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Beyond boxes
|
|
81
|
+
|
|
82
|
+
Two flags the hosted GPUs do for you, with nothing extra to install. Each runs a
|
|
83
|
+
second model, so each costs extra credits:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Turn every detected box into a precise instance polygon with SAM 2
|
|
87
|
+
result = model.predict("shelf.jpg", segment=True)
|
|
88
|
+
result.detections[0].polygons
|
|
89
|
+
|
|
90
|
+
# Add a monocular depth map, and a relative distance per detection
|
|
91
|
+
result = model.predict("shelf.jpg", depth=True)
|
|
92
|
+
result.detections[0].depth_median # 0-1, higher is closer
|
|
93
|
+
result.depth_image.save("depth.png")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Semantic-segmentation models return a dense mask instead of boxes:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
result = model.predict("road.jpg")
|
|
100
|
+
result.semantic["legend"] # [{'class_id': 1, 'class_name': 'road', ...}]
|
|
101
|
+
result.save("mask.jpg") # the mask, blended over your image
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Get an API key
|
|
105
|
+
|
|
106
|
+
In VisionForge, go to **Settings → API Keys**, create a key, and copy the secret —
|
|
107
|
+
it is shown once. Keys start with `vf_`, never expire, and can be locked to
|
|
108
|
+
specific IPs or CIDR ranges. Keep it out of source control:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
vf = Visynx() # reads VISYNX_API_KEY
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A plain `predict()` call spends 1 Vision Credit; `segment=True` and `depth=True`
|
|
115
|
+
each add a surcharge for the extra model they run. You are charged only after the
|
|
116
|
+
inference succeeds — a failed call is free — and a call retried after a `429` is
|
|
117
|
+
billed once, not twice.
|
|
118
|
+
|
|
119
|
+
Some endpoints are deliberately not reachable with an API key: batch prediction
|
|
120
|
+
over a whole dataset, live-camera inference, workflow runs, and the AI-labelling
|
|
121
|
+
tools (SAM/DINO/box-prompt). Those run from the VisionForge app with a signed-in
|
|
122
|
+
session. Calling them with a key returns `PermissionDeniedError`.
|
|
123
|
+
|
|
124
|
+
## Errors
|
|
125
|
+
|
|
126
|
+
Every failure is a typed exception, and it quotes the response's `X-Request-ID`
|
|
127
|
+
when the server sent one — include it if you contact support. (Authentication
|
|
128
|
+
failures are rejected before that header is assigned, so `401`s carry none.)
|
|
129
|
+
|
|
130
|
+
| Exception | Means | Do |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `AuthenticationError` | key invalid or revoked | create a new key |
|
|
133
|
+
| `PermissionDeniedError` | email unverified, or the key's IP allowlist rejected you | verify your email / check the allowlist |
|
|
134
|
+
| `InsufficientCreditsError` | out of Vision Credits | top up |
|
|
135
|
+
| `ModelNotFoundError` | no such model, or not yours | check `vf.models()` |
|
|
136
|
+
| `PayloadTooLargeError` | image over 50 MB | resize before sending |
|
|
137
|
+
| `RateLimitError` | rate limited, retries exhausted | back off |
|
|
138
|
+
| `MaintenanceError` | platform in maintenance | retry later |
|
|
139
|
+
|
|
140
|
+
`429`s are retried automatically, honouring the server's `Retry-After`.
|
|
141
|
+
All of these subclass `VisynxError`, so one `except VisynxError` catches everything.
|
|
142
|
+
|
|
143
|
+
## Self-hosting
|
|
144
|
+
|
|
145
|
+
Point the client at your own deployment:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
vf = Visynx(api_key="vf_...", api_url="https://visionforge.internal/api")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
or set `VISYNX_API_URL`.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "visynx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run your VisionForge computer-vision models from Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "VisionForge" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"computer-vision", "object-detection", "segmentation", "inference",
|
|
15
|
+
"yolo", "rf-detr", "detectron2", "visionforge", "machine-learning",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"requests>=2.25",
|
|
34
|
+
"Pillow>=9.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://visionforge.evo-ss.com"
|
|
39
|
+
Documentation = "https://visionforge.evo-ss.com/docs/python-quickstart"
|
|
40
|
+
Source = "https://github.com/manasjohri/Labelling_Package"
|
|
41
|
+
Issues = "https://github.com/manasjohri/Labelling_Package/issues"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["pytest>=7", "ruff>=0.5"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/visynx"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 110
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""visynx — run your VisionForge models from Python.
|
|
2
|
+
|
|
3
|
+
from visynx import Visynx
|
|
4
|
+
|
|
5
|
+
vf = Visynx(api_key="vf_...")
|
|
6
|
+
model = vf.model("shelf-scanner/3")
|
|
7
|
+
|
|
8
|
+
result = model.predict("shelf.jpg", confidence=0.4)
|
|
9
|
+
print(result.json())
|
|
10
|
+
result.save("annotated.jpg")
|
|
11
|
+
"""
|
|
12
|
+
from .client import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
InsufficientCreditsError,
|
|
15
|
+
MaintenanceError,
|
|
16
|
+
ModelNotFoundError,
|
|
17
|
+
PayloadTooLargeError,
|
|
18
|
+
PermissionDeniedError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
Visynx,
|
|
21
|
+
VisynxError,
|
|
22
|
+
)
|
|
23
|
+
from .model import Model
|
|
24
|
+
from .results import Detection, Result
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"Detection",
|
|
31
|
+
"InsufficientCreditsError",
|
|
32
|
+
"MaintenanceError",
|
|
33
|
+
"Model",
|
|
34
|
+
"ModelNotFoundError",
|
|
35
|
+
"PayloadTooLargeError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"Result",
|
|
39
|
+
"Visynx",
|
|
40
|
+
"VisynxError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""HTTP client, auth, and error mapping for the VisionForge API.
|
|
2
|
+
|
|
3
|
+
One ``requests.Session`` carries the bearer token for every call. The retry and
|
|
4
|
+
error semantics here are not invented — they mirror what the VisionForge API
|
|
5
|
+
already documents (see the platform's "Rate Limits & Error Handling" guide):
|
|
6
|
+
429 is retried honouring ``Retry-After``; 402/401/403 are terminal and mean
|
|
7
|
+
distinct, actionable things; every error quotes the ``X-Request-ID`` header the
|
|
8
|
+
server stamps on every response, because that is what support needs to find the
|
|
9
|
+
matching server log.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
DEFAULT_API_URL = "https://visionforge.evo-ss.com/api"
|
|
21
|
+
|
|
22
|
+
#: Server-side cap on an inference upload (``MAX_INFERENCE_UPLOAD_BYTES``).
|
|
23
|
+
#: Checked client-side so a doomed 50 MB upload fails instantly instead of
|
|
24
|
+
#: after the transfer.
|
|
25
|
+
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
#: Training backends a model can be served by. Used to tell a raw
|
|
28
|
+
#: ``provider/job_id`` reference from a friendly ``project/version`` slug.
|
|
29
|
+
PROVIDERS = ("yolo", "detectron2", "rfdetr", "smp")
|
|
30
|
+
|
|
31
|
+
MAX_ATTEMPTS = 4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VisynxError(Exception):
|
|
35
|
+
"""Base class for every error raised by visynx."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(VisynxError):
|
|
39
|
+
"""401 — the API key is missing, invalid, or revoked."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PermissionDeniedError(VisynxError):
|
|
43
|
+
"""403 — email unverified, account deactivated, or the key's IP allowlist rejected you."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModelNotFoundError(VisynxError):
|
|
47
|
+
"""404 — no such model, or it isn't yours."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InsufficientCreditsError(VisynxError):
|
|
51
|
+
"""402 — out of Vision Credits. Not a transient error; top up."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PayloadTooLargeError(VisynxError):
|
|
55
|
+
"""413 — the image exceeds the inference upload limit."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitError(VisynxError):
|
|
59
|
+
"""429 — rate limited, and the retries were exhausted."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MaintenanceError(VisynxError):
|
|
63
|
+
"""503 — the platform is in maintenance mode."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_STATUS_ERRORS = {
|
|
67
|
+
401: (AuthenticationError, "API key is invalid or has been revoked"),
|
|
68
|
+
402: (InsufficientCreditsError, "out of Vision Credits — top up to continue"),
|
|
69
|
+
403: (
|
|
70
|
+
PermissionDeniedError,
|
|
71
|
+
"forbidden — verify your email, or check this key's IP allowlist",
|
|
72
|
+
),
|
|
73
|
+
404: (ModelNotFoundError, "not found"),
|
|
74
|
+
413: (PayloadTooLargeError, "upload exceeds the size limit"),
|
|
75
|
+
429: (RateLimitError, "rate limited"),
|
|
76
|
+
503: (MaintenanceError, "platform is in maintenance mode"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalise(slug: str | None) -> str:
|
|
81
|
+
"""Match slugs forgivingly — a user types whatever separator they saw last."""
|
|
82
|
+
return (slug or "").strip().strip("/").lower().replace("_", "-")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _retry_after(response) -> float:
|
|
86
|
+
"""Seconds to wait per the server's ``Retry-After``, clamped to something sane."""
|
|
87
|
+
try:
|
|
88
|
+
return max(0.0, min(60.0, float(response.headers.get("Retry-After", 5))))
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
return 5.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _raise_for(response) -> None:
|
|
94
|
+
"""Translate a failed response into the matching typed VisynxError."""
|
|
95
|
+
try:
|
|
96
|
+
detail = (response.json() or {}).get("detail")
|
|
97
|
+
except ValueError:
|
|
98
|
+
detail = None
|
|
99
|
+
detail = str(detail) if detail else (response.text or "").strip()[:300]
|
|
100
|
+
|
|
101
|
+
error_cls, summary = _STATUS_ERRORS.get(
|
|
102
|
+
response.status_code, (VisynxError, f"HTTP {response.status_code}")
|
|
103
|
+
)
|
|
104
|
+
message = f"{summary}: {detail}" if detail else summary
|
|
105
|
+
request_id = response.headers.get("X-Request-ID")
|
|
106
|
+
if request_id:
|
|
107
|
+
message = f"{message} (request id: {request_id})"
|
|
108
|
+
raise error_cls(message)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Visynx:
|
|
112
|
+
"""Entry point to the VisionForge inference API.
|
|
113
|
+
|
|
114
|
+
>>> vf = Visynx(api_key="vf_...") # or set VISYNX_API_KEY
|
|
115
|
+
>>> model = vf.model("shelf-scanner/3")
|
|
116
|
+
>>> result = model.predict("shelf.jpg", confidence=0.4)
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
api_key: str | None = None,
|
|
122
|
+
api_url: str | None = None,
|
|
123
|
+
timeout: float = 60.0,
|
|
124
|
+
) -> None:
|
|
125
|
+
api_key = api_key or os.environ.get("VISYNX_API_KEY")
|
|
126
|
+
if not api_key:
|
|
127
|
+
raise AuthenticationError(
|
|
128
|
+
"No API key. Pass Visynx(api_key='vf_...') or set the "
|
|
129
|
+
"VISYNX_API_KEY environment variable. Create a key in "
|
|
130
|
+
"VisionForge under Settings -> API Keys."
|
|
131
|
+
)
|
|
132
|
+
self.api_url = (api_url or os.environ.get("VISYNX_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
133
|
+
self.timeout = timeout
|
|
134
|
+
self._session = requests.Session()
|
|
135
|
+
self._session.headers["Authorization"] = f"Bearer {api_key}"
|
|
136
|
+
self._models_cache: list | None = None
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
return f"Visynx(api_url={self.api_url!r})"
|
|
140
|
+
|
|
141
|
+
# -- transport ----------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
144
|
+
"""Call the API and return the decoded JSON body, or raise a VisynxError.
|
|
145
|
+
|
|
146
|
+
Retries only 429, and only because every caller passes replayable bytes
|
|
147
|
+
rather than a file handle — a consumed stream could not be re-sent.
|
|
148
|
+
"""
|
|
149
|
+
url = f"{self.api_url}{path}"
|
|
150
|
+
# One id for the whole call, reused across retries: the server treats a
|
|
151
|
+
# repeated (request id, image) pair as the same billable inference, so a
|
|
152
|
+
# retry can never be charged twice.
|
|
153
|
+
headers = {"X-Request-ID": uuid.uuid4().hex, **kwargs.pop("headers", {})}
|
|
154
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
155
|
+
response = self._session.request(
|
|
156
|
+
method, url, timeout=self.timeout, headers=headers, **kwargs
|
|
157
|
+
)
|
|
158
|
+
if response.status_code == 429 and attempt < MAX_ATTEMPTS - 1:
|
|
159
|
+
time.sleep(_retry_after(response))
|
|
160
|
+
continue
|
|
161
|
+
if not response.ok:
|
|
162
|
+
_raise_for(response)
|
|
163
|
+
return response.json()
|
|
164
|
+
raise RateLimitError(f"rate limited after {MAX_ATTEMPTS} attempts")
|
|
165
|
+
|
|
166
|
+
# -- models -------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def models(self, refresh: bool = False) -> list:
|
|
169
|
+
"""Every trained model on this account that is ready to run.
|
|
170
|
+
|
|
171
|
+
Cached on the instance; pass ``refresh=True`` after training something new.
|
|
172
|
+
"""
|
|
173
|
+
from .model import Model
|
|
174
|
+
|
|
175
|
+
if self._models_cache is None or refresh:
|
|
176
|
+
jobs = self._request("GET", "/train/jobs").get("jobs") or []
|
|
177
|
+
self._models_cache = [
|
|
178
|
+
Model._from_job(self, job)
|
|
179
|
+
for job in jobs
|
|
180
|
+
if job.get("status") == "completed" and job.get("artifact_available")
|
|
181
|
+
]
|
|
182
|
+
return list(self._models_cache)
|
|
183
|
+
|
|
184
|
+
def model(self, ref: str):
|
|
185
|
+
"""Resolve a model by ``"project/version"`` slug or raw ``"provider/job_id"``.
|
|
186
|
+
|
|
187
|
+
The slug is what the platform shows you and what you should write in code;
|
|
188
|
+
the raw form is accepted so scripts holding a job id keep working (and it
|
|
189
|
+
skips the model-list round trip entirely).
|
|
190
|
+
"""
|
|
191
|
+
from .model import Model
|
|
192
|
+
|
|
193
|
+
if isinstance(ref, Model):
|
|
194
|
+
return ref
|
|
195
|
+
ref = str(ref).strip().strip("/")
|
|
196
|
+
provider, _, tail = ref.partition("/")
|
|
197
|
+
# "yolo/<uuid>" is a raw reference; "yolo/3" is a slug for a project that
|
|
198
|
+
# happens to be named "yolo" — a version is always a bare integer.
|
|
199
|
+
if provider in PROVIDERS and tail and not tail.isdigit():
|
|
200
|
+
return Model(client=self, provider=provider, job_id=tail)
|
|
201
|
+
|
|
202
|
+
wanted = _normalise(ref)
|
|
203
|
+
matches = [m for m in self.models() if _normalise(m.slug) == wanted]
|
|
204
|
+
if len(matches) == 1:
|
|
205
|
+
return matches[0]
|
|
206
|
+
if not matches:
|
|
207
|
+
known = ", ".join(sorted(m.slug for m in self.models() if m.slug)) or "none"
|
|
208
|
+
raise ModelNotFoundError(f"No model {ref!r}. Available: {known}")
|
|
209
|
+
candidates = ", ".join(f"{m.provider}/{m.job_id}" for m in matches)
|
|
210
|
+
raise ModelNotFoundError(
|
|
211
|
+
f"{ref!r} is ambiguous — several projects share that name. "
|
|
212
|
+
f"Use one of: {candidates}"
|
|
213
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""A trained model you can run images through."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import MAX_UPLOAD_BYTES, PayloadTooLargeError, VisynxError
|
|
10
|
+
from .results import Result
|
|
11
|
+
|
|
12
|
+
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _encode(image) -> bytes:
|
|
16
|
+
"""PIL image -> JPEG bytes."""
|
|
17
|
+
buffer = io.BytesIO()
|
|
18
|
+
image.convert("RGB").save(buffer, format="JPEG", quality=95)
|
|
19
|
+
return buffer.getvalue()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _to_bytes(source: Any) -> tuple:
|
|
23
|
+
"""Normalise anything image-shaped to ``(filename, bytes)``.
|
|
24
|
+
|
|
25
|
+
Accepts a path, raw bytes, an open file, a PIL image, or any object exposing
|
|
26
|
+
the array interface (a numpy array, without visynx ever importing numpy).
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(source, (bytes, bytearray)):
|
|
29
|
+
return "image.jpg", bytes(source)
|
|
30
|
+
if isinstance(source, (str, Path)):
|
|
31
|
+
path = Path(source)
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
raise VisynxError(f"No such image: {path}")
|
|
34
|
+
return path.name, path.read_bytes()
|
|
35
|
+
if hasattr(source, "read"):
|
|
36
|
+
data = source.read()
|
|
37
|
+
if isinstance(data, str):
|
|
38
|
+
raise VisynxError("File opened in text mode — open it with 'rb'")
|
|
39
|
+
return getattr(source, "name", "image.jpg"), data
|
|
40
|
+
if hasattr(source, "save") and hasattr(source, "mode"): # PIL.Image
|
|
41
|
+
return "image.jpg", _encode(source)
|
|
42
|
+
if hasattr(source, "__array_interface__"): # numpy array, duck-typed
|
|
43
|
+
from PIL import Image
|
|
44
|
+
|
|
45
|
+
return "image.jpg", _encode(Image.fromarray(source))
|
|
46
|
+
raise VisynxError(
|
|
47
|
+
f"Cannot read an image from {type(source).__name__}. Pass a path, bytes, "
|
|
48
|
+
"a file opened in binary mode, a PIL image, or a numpy array."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Model:
|
|
54
|
+
"""A trained model. Get one from :meth:`Visynx.model`, not by constructing it."""
|
|
55
|
+
|
|
56
|
+
provider: str
|
|
57
|
+
job_id: str
|
|
58
|
+
slug: str | None = None
|
|
59
|
+
display_name: str | None = None
|
|
60
|
+
version: int | None = None
|
|
61
|
+
dataset_id: int | None = None
|
|
62
|
+
created_at: str | None = None
|
|
63
|
+
client: Any = field(default=None, repr=False, compare=False)
|
|
64
|
+
_labels: list | None = field(default=None, repr=False, compare=False)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _from_job(cls, client, job: dict) -> Model:
|
|
68
|
+
return cls(
|
|
69
|
+
provider=job.get("provider"),
|
|
70
|
+
job_id=job.get("job_id"),
|
|
71
|
+
slug=job.get("slug"),
|
|
72
|
+
display_name=job.get("display_name"),
|
|
73
|
+
version=job.get("training_version"),
|
|
74
|
+
dataset_id=job.get("dataset_id"),
|
|
75
|
+
created_at=job.get("created_at"),
|
|
76
|
+
client=client,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"Model({self.slug or f'{self.provider}/{self.job_id}'!r}, provider={self.provider!r})"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def labels(self) -> list:
|
|
84
|
+
"""Class names the model predicts, in class-id order.
|
|
85
|
+
|
|
86
|
+
Read from the weights themselves, so they stay correct even if the source
|
|
87
|
+
dataset's classes were renamed after training.
|
|
88
|
+
"""
|
|
89
|
+
if self._labels is None:
|
|
90
|
+
payload = self.client._request("GET", f"/train/model-labels/{self.job_id}")
|
|
91
|
+
self._labels = payload.get("labels") or []
|
|
92
|
+
return self._labels
|
|
93
|
+
|
|
94
|
+
def predict(
|
|
95
|
+
self,
|
|
96
|
+
source: Any,
|
|
97
|
+
*,
|
|
98
|
+
confidence: float = 0.0,
|
|
99
|
+
segment: bool = False,
|
|
100
|
+
depth: bool = False,
|
|
101
|
+
) -> Result | list:
|
|
102
|
+
"""Run the model on an image.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
source: image path, directory of images, bytes, open binary file,
|
|
106
|
+
PIL image, or numpy array.
|
|
107
|
+
confidence: drop detections scoring below this (0-1).
|
|
108
|
+
segment: refine each detected box into an instance polygon with SAM2.
|
|
109
|
+
depth: also return a monocular depth map for the image.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A :class:`Result`, or a list of them when ``source`` is a directory.
|
|
113
|
+
|
|
114
|
+
A plain call spends 1 Vision Credit. ``segment`` and ``depth`` each run a
|
|
115
|
+
second model on the GPU and cost extra. You are only charged once the
|
|
116
|
+
inference succeeds, and a retried call is billed once.
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(source, (str, Path)) and Path(source).is_dir():
|
|
119
|
+
images = sorted(
|
|
120
|
+
p for p in Path(source).iterdir() if p.suffix.lower() in IMAGE_SUFFIXES
|
|
121
|
+
)
|
|
122
|
+
return [
|
|
123
|
+
self.predict(p, confidence=confidence, segment=segment, depth=depth)
|
|
124
|
+
for p in images
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
filename, data = _to_bytes(source)
|
|
128
|
+
if not data:
|
|
129
|
+
raise VisynxError("Image is empty")
|
|
130
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
131
|
+
raise PayloadTooLargeError(
|
|
132
|
+
f"{filename} is {len(data) / 1e6:.1f} MB; the inference limit is "
|
|
133
|
+
f"{MAX_UPLOAD_BYTES // 1024 // 1024} MB. Resize it before sending."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
params = {}
|
|
137
|
+
if segment:
|
|
138
|
+
params["segment"] = "true"
|
|
139
|
+
if depth:
|
|
140
|
+
params["depth"] = "true"
|
|
141
|
+
|
|
142
|
+
payload = self.client._request(
|
|
143
|
+
"POST",
|
|
144
|
+
f"/train/jobs/{self.provider}/{self.job_id}/infer",
|
|
145
|
+
files={"file": (filename, data)},
|
|
146
|
+
params=params,
|
|
147
|
+
)
|
|
148
|
+
return Result(payload, image_bytes=data, confidence=confidence)
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Prediction results, and drawing them on the image.
|
|
2
|
+
|
|
3
|
+
Field names deliberately mirror the VisionForge API one-for-one — matching the
|
|
4
|
+
wire format is the feature, so anything you learn from ``result.json()`` applies
|
|
5
|
+
to the typed objects and vice versa.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import io
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
#: Deterministic per-class colours, so the same class is the same colour across
|
|
14
|
+
#: every image in a run.
|
|
15
|
+
PALETTE = (
|
|
16
|
+
"#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#a855f7", "#06b6d4",
|
|
17
|
+
"#ec4899", "#84cc16", "#f97316", "#6366f1", "#14b8a6", "#eab308",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _colour(class_id: int | None) -> str:
|
|
22
|
+
return PALETTE[(class_id or 0) % len(PALETTE)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Detection:
|
|
27
|
+
"""One predicted object. ``box`` is ``[x1, y1, x2, y2]`` in absolute pixels.
|
|
28
|
+
|
|
29
|
+
Classification models return detections with no ``box``; segmentation models
|
|
30
|
+
add ``polygons`` (flat ``[x1, y1, x2, y2, ...]`` per instance); pose models
|
|
31
|
+
add ``keypoints`` (``[[x, y, visibility], ...]``).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
class_id: int | None = None
|
|
35
|
+
class_name: str | None = None
|
|
36
|
+
confidence: float = 0.0
|
|
37
|
+
box: list | None = None
|
|
38
|
+
polygons: list = field(default_factory=list)
|
|
39
|
+
keypoints: list = field(default_factory=list)
|
|
40
|
+
#: Relative depth (0-1, higher is closer). Only set when ``depth=True``.
|
|
41
|
+
depth_median: float | None = None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _from_json(cls, data: dict) -> Detection:
|
|
45
|
+
return cls(
|
|
46
|
+
class_id=data.get("class_id"),
|
|
47
|
+
class_name=data.get("class_name"),
|
|
48
|
+
confidence=float(data.get("confidence") or 0.0),
|
|
49
|
+
box=data.get("box"),
|
|
50
|
+
polygons=data.get("polygons") or [],
|
|
51
|
+
keypoints=data.get("keypoints") or [],
|
|
52
|
+
depth_median=data.get("depth_median"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"Detection({self.class_name!r}, {self.confidence:.2f})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _decode_png(b64: str):
|
|
60
|
+
from PIL import Image
|
|
61
|
+
|
|
62
|
+
return Image.open(io.BytesIO(base64.b64decode(b64)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Result:
|
|
66
|
+
"""What one image came back as.
|
|
67
|
+
|
|
68
|
+
Iterate it for detections, call :meth:`json` for the raw payload, or
|
|
69
|
+
:meth:`plot` / :meth:`save` to see it.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, payload: dict, image_bytes: bytes | None = None, confidence: float = 0.0):
|
|
73
|
+
self._payload = payload
|
|
74
|
+
self._image_bytes = image_bytes
|
|
75
|
+
self.job_id = payload.get("job_id")
|
|
76
|
+
self.detections = [
|
|
77
|
+
Detection._from_json(d)
|
|
78
|
+
for d in (payload.get("detections") or [])
|
|
79
|
+
if float(d.get("confidence") or 0.0) >= confidence
|
|
80
|
+
]
|
|
81
|
+
#: Dense per-pixel overlay from a semantic-segmentation (smp) model.
|
|
82
|
+
#: ``{width, height, overlay_png_b64, legend}``; ``detections`` is empty.
|
|
83
|
+
self.semantic: dict | None = payload.get("semantic")
|
|
84
|
+
#: Monocular depth map, when ``depth=True`` was passed.
|
|
85
|
+
self.depth: dict | None = payload.get("depth")
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
return len(self.detections)
|
|
89
|
+
|
|
90
|
+
def __iter__(self):
|
|
91
|
+
return iter(self.detections)
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
if self.semantic:
|
|
95
|
+
classes = len(self.semantic.get("legend") or [])
|
|
96
|
+
return f"Result(semantic, {classes} classes)"
|
|
97
|
+
return f"Result({len(self.detections)} detections)"
|
|
98
|
+
|
|
99
|
+
def json(self) -> dict:
|
|
100
|
+
"""The raw API response, unmodified."""
|
|
101
|
+
return self._payload
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def image(self):
|
|
105
|
+
"""The image that was sent, as a PIL image."""
|
|
106
|
+
if not self._image_bytes:
|
|
107
|
+
raise ValueError("This result carries no source image")
|
|
108
|
+
from PIL import Image
|
|
109
|
+
|
|
110
|
+
return Image.open(io.BytesIO(self._image_bytes))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def depth_image(self):
|
|
114
|
+
"""The depth map as a colourised PIL image, or None if depth wasn't requested."""
|
|
115
|
+
if not self.depth or not self.depth.get("overlay_png_b64"):
|
|
116
|
+
return None
|
|
117
|
+
return _decode_png(self.depth["overlay_png_b64"]).convert("RGB")
|
|
118
|
+
|
|
119
|
+
def plot(self):
|
|
120
|
+
"""Draw the predictions onto the source image and return a PIL image."""
|
|
121
|
+
from PIL import Image, ImageDraw
|
|
122
|
+
|
|
123
|
+
canvas = self.image.convert("RGBA")
|
|
124
|
+
|
|
125
|
+
# Semantic models return a transparent RGBA mask to blend, not boxes.
|
|
126
|
+
if self.semantic and self.semantic.get("overlay_png_b64"):
|
|
127
|
+
overlay = _decode_png(self.semantic["overlay_png_b64"]).convert("RGBA")
|
|
128
|
+
if overlay.size != canvas.size:
|
|
129
|
+
overlay = overlay.resize(canvas.size, Image.NEAREST)
|
|
130
|
+
canvas = Image.alpha_composite(canvas, overlay)
|
|
131
|
+
|
|
132
|
+
draw = ImageDraw.Draw(canvas)
|
|
133
|
+
width = max(2, canvas.width // 400)
|
|
134
|
+
font = _font(canvas.width)
|
|
135
|
+
|
|
136
|
+
# Classification models have no geometry — stack the scores in a corner.
|
|
137
|
+
boxless = [d for d in self.detections if not d.box and not d.polygons]
|
|
138
|
+
for index, det in enumerate(boxless):
|
|
139
|
+
_label(draw, (width * 2, width * 2 + index * (width * 9)),
|
|
140
|
+
f"{det.class_name} {det.confidence:.2f}", _colour(det.class_id), font)
|
|
141
|
+
|
|
142
|
+
for det in self.detections:
|
|
143
|
+
colour = _colour(det.class_id)
|
|
144
|
+
for polygon in det.polygons:
|
|
145
|
+
if len(polygon) >= 6:
|
|
146
|
+
points = list(zip(polygon[0::2], polygon[1::2]))
|
|
147
|
+
draw.line(points + points[:1], fill=colour, width=width)
|
|
148
|
+
if det.box and len(det.box) == 4:
|
|
149
|
+
x1, y1, x2, y2 = (float(v) for v in det.box)
|
|
150
|
+
draw.rectangle((x1, y1, x2, y2), outline=colour, width=width)
|
|
151
|
+
_label(draw, (x1, y1), f"{det.class_name} {det.confidence:.2f}", colour, font)
|
|
152
|
+
for point in det.keypoints:
|
|
153
|
+
if len(point) >= 2:
|
|
154
|
+
x, y = float(point[0]), float(point[1])
|
|
155
|
+
draw.ellipse((x - width * 2, y - width * 2, x + width * 2, y + width * 2), fill=colour)
|
|
156
|
+
|
|
157
|
+
return canvas.convert("RGB")
|
|
158
|
+
|
|
159
|
+
def save(self, path) -> str:
|
|
160
|
+
"""Write :meth:`plot`'s annotated image to ``path``. Returns the path."""
|
|
161
|
+
self.plot().save(path)
|
|
162
|
+
return str(path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _font(image_width: int):
|
|
166
|
+
from PIL import ImageFont
|
|
167
|
+
|
|
168
|
+
size = max(12, image_width // 60)
|
|
169
|
+
try: # Pillow >= 10.1 can scale the built-in font
|
|
170
|
+
return ImageFont.load_default(size=size)
|
|
171
|
+
except TypeError:
|
|
172
|
+
return ImageFont.load_default()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _label(draw, anchor, text: str, colour: str, font) -> None:
|
|
176
|
+
"""A filled chip with the class name, kept inside the image."""
|
|
177
|
+
x, y = anchor
|
|
178
|
+
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
|
|
179
|
+
w, h = right - left, bottom - top
|
|
180
|
+
y = max(0, y - h - 4)
|
|
181
|
+
draw.rectangle((x, y, x + w + 6, y + h + 6), fill=colour)
|
|
182
|
+
draw.text((x + 3, y + 2), text, fill="white", font=font)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""No network. A fake session stands in for the API and returns canned payloads."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from visynx import (
|
|
9
|
+
InsufficientCreditsError,
|
|
10
|
+
ModelNotFoundError,
|
|
11
|
+
PayloadTooLargeError,
|
|
12
|
+
Visynx,
|
|
13
|
+
VisynxError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeResponse:
|
|
18
|
+
def __init__(self, status_code=200, payload=None, headers=None):
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.ok = status_code < 400
|
|
21
|
+
self.headers = headers or {}
|
|
22
|
+
self._payload = payload if payload is not None else {}
|
|
23
|
+
self.text = json.dumps(self._payload)
|
|
24
|
+
|
|
25
|
+
def json(self):
|
|
26
|
+
return self._payload
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeSession:
|
|
30
|
+
"""Replays a queue of responses and records what was asked for."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *responses):
|
|
33
|
+
self.headers = {}
|
|
34
|
+
self.queue = list(responses)
|
|
35
|
+
self.calls = []
|
|
36
|
+
|
|
37
|
+
def request(self, method, url, **kwargs):
|
|
38
|
+
self.calls.append((method, url, kwargs))
|
|
39
|
+
return self.queue.pop(0) if len(self.queue) > 1 else self.queue[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def client(*responses):
|
|
43
|
+
vf = Visynx(api_key="vf_test", api_url="https://example.test/api")
|
|
44
|
+
vf._session = FakeSession(*responses)
|
|
45
|
+
return vf
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def job(slug, job_id, provider="yolo", **extra):
|
|
49
|
+
return {
|
|
50
|
+
"slug": slug, "job_id": job_id, "provider": provider,
|
|
51
|
+
"status": "completed", "artifact_available": True, **extra,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def image_bytes(size=(64, 48), colour="navy"):
|
|
56
|
+
buffer = io.BytesIO()
|
|
57
|
+
Image.new("RGB", size, colour).save(buffer, format="JPEG")
|
|
58
|
+
return buffer.getvalue()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DETECTIONS = {
|
|
62
|
+
"success": True,
|
|
63
|
+
"job_id": "abc",
|
|
64
|
+
"detections": [
|
|
65
|
+
{"class_id": 0, "class_name": "bottle", "confidence": 0.91, "box": [5, 5, 30, 40]},
|
|
66
|
+
{"class_id": 1, "class_name": "can", "confidence": 0.12, "box": [35, 5, 60, 40]},
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- auth ---------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def test_missing_key_fails_loudly(monkeypatch):
|
|
74
|
+
monkeypatch.delenv("VISYNX_API_KEY", raising=False)
|
|
75
|
+
with pytest.raises(Exception, match="VISYNX_API_KEY"):
|
|
76
|
+
Visynx()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_key_from_environment(monkeypatch):
|
|
80
|
+
monkeypatch.setenv("VISYNX_API_KEY", "vf_from_env")
|
|
81
|
+
assert Visynx()._session.headers["Authorization"] == "Bearer vf_from_env"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -- model resolution ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_slug_resolves_to_provider_and_job_id():
|
|
87
|
+
vf = client(FakeResponse(payload={"jobs": [job("hungary/1", "j1"), job("shelf-scanner/3", "j2")]}))
|
|
88
|
+
model = vf.model("shelf-scanner/3")
|
|
89
|
+
assert (model.provider, model.job_id) == ("yolo", "j2")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("typed", ["shelf_scanner/3", "Shelf-Scanner/3", " shelf-scanner/3 "])
|
|
93
|
+
def test_slug_matching_forgives_separator_and_case(typed):
|
|
94
|
+
vf = client(FakeResponse(payload={"jobs": [job("shelf-scanner/3", "j2")]}))
|
|
95
|
+
assert vf.model(typed).job_id == "j2"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_raw_reference_skips_the_lookup():
|
|
99
|
+
vf = client() # empty queue: any HTTP call would raise IndexError
|
|
100
|
+
model = vf.model("rfdetr/8f2a-uuid")
|
|
101
|
+
assert (model.provider, model.job_id) == ("rfdetr", "8f2a-uuid")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_ambiguous_slug_names_the_candidates():
|
|
105
|
+
vf = client(FakeResponse(payload={"jobs": [job("shelf/3", "j1"), job("shelf/3", "j2", "rfdetr")]}))
|
|
106
|
+
with pytest.raises(ModelNotFoundError, match="ambiguous"):
|
|
107
|
+
vf.model("shelf/3")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_unknown_slug_lists_what_exists():
|
|
111
|
+
vf = client(FakeResponse(payload={"jobs": [job("hungary/1", "j1")]}))
|
|
112
|
+
with pytest.raises(ModelNotFoundError, match="hungary/1"):
|
|
113
|
+
vf.model("nope/9")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_only_runnable_models_are_listed():
|
|
117
|
+
vf = client(FakeResponse(payload={"jobs": [
|
|
118
|
+
job("done/1", "j1"),
|
|
119
|
+
job("running/2", "j2", status="training"),
|
|
120
|
+
job("noweights/3", "j3", artifact_available=False),
|
|
121
|
+
]}))
|
|
122
|
+
assert [m.slug for m in vf.models()] == ["done/1"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- errors -------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def test_429_is_retried_with_the_servers_backoff(monkeypatch):
|
|
128
|
+
slept = []
|
|
129
|
+
monkeypatch.setattr("visynx.client.time.sleep", slept.append)
|
|
130
|
+
vf = client(
|
|
131
|
+
FakeResponse(429, headers={"Retry-After": "7"}),
|
|
132
|
+
FakeResponse(payload={"jobs": []}),
|
|
133
|
+
)
|
|
134
|
+
assert vf.models() == []
|
|
135
|
+
assert slept == [7.0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_one_request_id_is_reused_across_retries(monkeypatch):
|
|
139
|
+
"""The server bills a (request id, image) pair once, so the retry of a 429
|
|
140
|
+
must carry the SAME id — otherwise a retry could be charged twice."""
|
|
141
|
+
monkeypatch.setattr("visynx.client.time.sleep", lambda _: None)
|
|
142
|
+
vf = client(FakeResponse(429, headers={"Retry-After": "1"}), FakeResponse(payload={"jobs": []}))
|
|
143
|
+
vf.models()
|
|
144
|
+
ids = [kwargs["headers"]["X-Request-ID"] for _, _, kwargs in vf._session.calls]
|
|
145
|
+
assert len(ids) == 2 and ids[0] == ids[1]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_separate_calls_get_separate_request_ids():
|
|
149
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
150
|
+
model = vf.model("yolo/abc")
|
|
151
|
+
model.predict(image_bytes())
|
|
152
|
+
model.predict(image_bytes())
|
|
153
|
+
ids = {kwargs["headers"]["X-Request-ID"] for _, _, kwargs in vf._session.calls}
|
|
154
|
+
assert len(ids) == 2, "distinct calls must not dedupe into one charge"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_402_is_terminal_and_quotes_the_request_id():
|
|
158
|
+
vf = client(FakeResponse(402, {"detail": "Not enough credits"}, {"X-Request-ID": "req-99"}))
|
|
159
|
+
with pytest.raises(InsufficientCreditsError, match="req-99"):
|
|
160
|
+
vf.models()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_403_explains_both_causes():
|
|
164
|
+
vf = client(FakeResponse(403, {"detail": "IP not allowed"}))
|
|
165
|
+
with pytest.raises(VisynxError, match="IP allowlist"):
|
|
166
|
+
vf.models()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# -- predict ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def test_confidence_filters_low_scores():
|
|
172
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
173
|
+
result = vf.model("yolo/abc").predict(image_bytes(), confidence=0.5)
|
|
174
|
+
assert [d.class_name for d in result] == ["bottle"]
|
|
175
|
+
assert len(result.json()["detections"]) == 2, "raw payload stays untouched"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_segment_and_depth_reach_the_server():
|
|
179
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
180
|
+
vf.model("yolo/abc").predict(image_bytes(), segment=True, depth=True)
|
|
181
|
+
_, url, kwargs = vf._session.calls[0]
|
|
182
|
+
assert url.endswith("/train/jobs/yolo/abc/infer")
|
|
183
|
+
assert kwargs["params"] == {"segment": "true", "depth": "true"}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_oversized_image_never_reaches_the_network():
|
|
187
|
+
vf = client()
|
|
188
|
+
with pytest.raises(PayloadTooLargeError, match="MB"):
|
|
189
|
+
vf.model("yolo/abc").predict(b"x" * (51 * 1024 * 1024))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_unreadable_source_is_rejected():
|
|
193
|
+
vf = client()
|
|
194
|
+
with pytest.raises(VisynxError, match="Cannot read an image"):
|
|
195
|
+
vf.model("yolo/abc").predict(42)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_directory_returns_one_result_per_image(tmp_path):
|
|
199
|
+
for name in ("a.jpg", "b.png", "notes.txt"):
|
|
200
|
+
(tmp_path / name).write_bytes(image_bytes() if name != "notes.txt" else b"ignore me")
|
|
201
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
202
|
+
results = vf.model("yolo/abc").predict(tmp_path)
|
|
203
|
+
assert len(results) == 2, "the .txt must be skipped"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_pil_image_is_encoded_and_sent():
|
|
207
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
208
|
+
vf.model("yolo/abc").predict(Image.new("RGB", (32, 32), "red"))
|
|
209
|
+
assert vf._session.calls[0][2]["files"]["file"][1][:2] == b"\xff\xd8", "sent JPEG bytes"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# -- results ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def test_semantic_models_expose_a_mask_not_detections():
|
|
215
|
+
overlay = io.BytesIO()
|
|
216
|
+
Image.new("RGBA", (64, 48), (255, 0, 0, 140)).save(overlay, format="PNG")
|
|
217
|
+
import base64
|
|
218
|
+
|
|
219
|
+
payload = {"success": True, "job_id": "s1", "semantic": {
|
|
220
|
+
"width": 64, "height": 48,
|
|
221
|
+
"overlay_png_b64": base64.b64encode(overlay.getvalue()).decode(),
|
|
222
|
+
"legend": [{"class_id": 1, "class_name": "road", "color": "rgb(255,0,0)"}],
|
|
223
|
+
}}
|
|
224
|
+
vf = client(FakeResponse(payload=payload))
|
|
225
|
+
result = vf.model("smp/s1").predict(image_bytes())
|
|
226
|
+
assert result.detections == []
|
|
227
|
+
assert result.semantic["legend"][0]["class_name"] == "road"
|
|
228
|
+
assert result.plot().size == (64, 48), "the mask composites over the source"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_plot_draws_and_save_writes_a_real_image(tmp_path):
|
|
232
|
+
vf = client(FakeResponse(payload=DETECTIONS))
|
|
233
|
+
result = vf.model("yolo/abc").predict(image_bytes(colour="black"))
|
|
234
|
+
out = tmp_path / "out.jpg"
|
|
235
|
+
result.save(out)
|
|
236
|
+
written = Image.open(out)
|
|
237
|
+
assert written.size == (64, 48)
|
|
238
|
+
assert len(set(written.convert("RGB").tobytes())) > 1, "something was drawn on the black image"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_polygons_and_keypoints_plot_without_error():
|
|
242
|
+
payload = {"job_id": "p", "detections": [{
|
|
243
|
+
"class_id": 0, "class_name": "person", "confidence": 0.8, "box": [2, 2, 60, 44],
|
|
244
|
+
"polygons": [[4, 4, 50, 4, 50, 40, 4, 40]],
|
|
245
|
+
"keypoints": [[10, 10, 1], [20, 20, 1]],
|
|
246
|
+
}]}
|
|
247
|
+
vf = client(FakeResponse(payload=payload))
|
|
248
|
+
assert vf.model("yolo/abc").predict(image_bytes()).plot().size == (64, 48)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_classification_results_have_no_box():
|
|
252
|
+
payload = {"job_id": "c", "detections": [
|
|
253
|
+
{"class_id": 3, "class_name": "ripe", "confidence": 0.77},
|
|
254
|
+
]}
|
|
255
|
+
vf = client(FakeResponse(payload=payload))
|
|
256
|
+
result = vf.model("yolo/abc").predict(image_bytes())
|
|
257
|
+
assert result.detections[0].box is None
|
|
258
|
+
assert result.plot().size == (64, 48)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_labels_are_fetched_lazily_and_cached():
|
|
262
|
+
vf = client(FakeResponse(payload={"labels": ["bottle", "can"]}))
|
|
263
|
+
model = vf.model("yolo/abc")
|
|
264
|
+
assert model.labels == ["bottle", "can"]
|
|
265
|
+
assert model.labels == ["bottle", "can"]
|
|
266
|
+
assert len(vf._session.calls) == 1, "second access must not re-request"
|