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.
@@ -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
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .ruff_cache/
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"