visynx 0.1.0__py3-none-any.whl
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/__init__.py +42 -0
- visynx/client.py +213 -0
- visynx/model.py +148 -0
- visynx/py.typed +0 -0
- visynx/results.py +182 -0
- visynx-0.1.0.dist-info/METADATA +208 -0
- visynx-0.1.0.dist-info/RECORD +9 -0
- visynx-0.1.0.dist-info/WHEEL +4 -0
- visynx-0.1.0.dist-info/licenses/LICENSE +21 -0
visynx/__init__.py
ADDED
|
@@ -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
|
+
]
|
visynx/client.py
ADDED
|
@@ -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
|
+
)
|
visynx/model.py
ADDED
|
@@ -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)
|
visynx/py.typed
ADDED
|
File without changes
|
visynx/results.py
ADDED
|
@@ -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,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
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
visynx/__init__.py,sha256=OekP2u7XLrzwVBCjODZrrttZGvgCRoEYos8_uN3MlwA,893
|
|
2
|
+
visynx/client.py,sha256=5wgcltVaSu57k0uR-HsFH4uYwC330Q0Gsk1_16LB95Y,8122
|
|
3
|
+
visynx/model.py,sha256=cWDXZU-X5jH8WBMQYJ1MpAVM4BFu-G27EnbKxWJsgck,5335
|
|
4
|
+
visynx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
visynx/results.py,sha256=pi3Ukv-p5NWaFIucCUnY0rtsbdraNpxRXLELs3N-4Mc,6803
|
|
6
|
+
visynx-0.1.0.dist-info/METADATA,sha256=GkE8C37wxJ6zoZpWPCPEYJmlatDLBnJjKoxjY4yEIII,7823
|
|
7
|
+
visynx-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
visynx-0.1.0.dist-info/licenses/LICENSE,sha256=Vs6853kI20vijlKlB1Jo17u6X_mNPjwdc3xPtvB2zxI,1068
|
|
9
|
+
visynx-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|