tinyedge 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.
- tinyedge-0.1.0/LICENSE +21 -0
- tinyedge-0.1.0/PKG-INFO +117 -0
- tinyedge-0.1.0/README.md +77 -0
- tinyedge-0.1.0/pyproject.toml +32 -0
- tinyedge-0.1.0/setup.cfg +4 -0
- tinyedge-0.1.0/tinyedge/__init__.py +12 -0
- tinyedge-0.1.0/tinyedge/cli.py +108 -0
- tinyedge-0.1.0/tinyedge/client.py +322 -0
- tinyedge-0.1.0/tinyedge.egg-info/PKG-INFO +117 -0
- tinyedge-0.1.0/tinyedge.egg-info/SOURCES.txt +12 -0
- tinyedge-0.1.0/tinyedge.egg-info/dependency_links.txt +1 -0
- tinyedge-0.1.0/tinyedge.egg-info/entry_points.txt +2 -0
- tinyedge-0.1.0/tinyedge.egg-info/requires.txt +1 -0
- tinyedge-0.1.0/tinyedge.egg-info/top_level.txt +1 -0
tinyedge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lienert De Maeyer / TinyEdge
|
|
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.
|
tinyedge-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinyedge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Benchmark your models (latency + accuracy) on real edge devices — SDK + tinydevice CLI
|
|
5
|
+
Author: Lienert De Maeyer
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Lienert De Maeyer / TinyEdge
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://tinyedge.ai
|
|
29
|
+
Project-URL: Documentation, https://tinyedge.ai
|
|
30
|
+
Keywords: edge,benchmark,onnx,inference,embedded,latency,accuracy
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: requests>=2.31
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# tinyedge — the Python SDK
|
|
42
|
+
|
|
43
|
+
Benchmark your models on **real edge devices** from inside any Python pipeline, notebook
|
|
44
|
+
or CI job. PyTorch (or any framework) stays on your machine — the SDK converts what you
|
|
45
|
+
give it into the platform's wire formats (ONNX + labeled image archive) and real hardware
|
|
46
|
+
does the measuring.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install git+https://github.com/lienertdemaeyer/tinyedge-agent#subdirectory=sdk
|
|
50
|
+
export TINYEDGE_API_KEY=tinyedge_sk_… # TinyEdge console → New benchmark
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## In a PyTorch pipeline
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import tinyedge
|
|
57
|
+
|
|
58
|
+
client = tinyedge.TinyEdge()
|
|
59
|
+
|
|
60
|
+
result = client.benchmark(
|
|
61
|
+
model, # a live nn.Module (auto-exported to ONNX) or "model.onnx"
|
|
62
|
+
devices=["oppo-a74"], # or ["jetson-orin-nano:tensorrt", "raspberry-pi-5"]
|
|
63
|
+
dataset=raw_test_set, # torch Dataset of (image, label) — UNtransformed,
|
|
64
|
+
# or a folder ("./testset"), or an archive (".zip/.tar.gz")
|
|
65
|
+
precision="fp32",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(result) # <BenchmarkResult oppo-a74 completed p50=56.6ms top1=83.3%>
|
|
69
|
+
print(result.latency_ms_p50, result.accuracy_top1)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Notes that make this work well:
|
|
73
|
+
|
|
74
|
+
- **Pass the dataset without transforms.** Preprocessing (resize/crop/normalize) is part of
|
|
75
|
+
the standardized job spec and runs on-device — that's what makes accuracy comparable
|
|
76
|
+
across devices instead of depending on whatever transform happened to run on your laptop.
|
|
77
|
+
- **Labels are class indices.** Folder names / integer labels map directly to your model's
|
|
78
|
+
output indices (`207/` = output neuron 207).
|
|
79
|
+
- A custom `example_input` controls the ONNX export shape:
|
|
80
|
+
`client.benchmark(model, ..., example_input=torch.randn(1, 3, 320, 320))`.
|
|
81
|
+
|
|
82
|
+
## As a CI gate (pytest)
|
|
83
|
+
|
|
84
|
+
Fail the build when the model regresses **on the hardware you ship on**:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# test_edge_performance.py
|
|
88
|
+
import tinyedge
|
|
89
|
+
|
|
90
|
+
def test_detector_meets_edge_budget():
|
|
91
|
+
client = tinyedge.TinyEdge()
|
|
92
|
+
result = client.benchmark("artifacts/model.onnx",
|
|
93
|
+
devices=["jetson-orin-nano"],
|
|
94
|
+
dataset="testdata/eval_set.tar.gz")
|
|
95
|
+
result.assert_latency(max_ms=50)
|
|
96
|
+
result.assert_accuracy(min_top1=0.80)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`assert_*` raise `AssertionError` with a readable message, so any test runner (pytest,
|
|
100
|
+
unittest, GitHub Actions) reports it natively.
|
|
101
|
+
|
|
102
|
+
## Async style
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
jobs = client.benchmark(model, devices=["oppo-a74", "raspberry-pi-5"], wait=False)
|
|
106
|
+
# … do other work …
|
|
107
|
+
for j in jobs:
|
|
108
|
+
print(client.get(j.id))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## What runs where
|
|
112
|
+
|
|
113
|
+
| Your machine (SDK) | TinyEdge platform | The device (agent) |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| torch → ONNX export | stores artifacts, queues job | downloads ONNX + images |
|
|
116
|
+
| dataset → image archive | tracks pending/running | runs inference, computes accuracy |
|
|
117
|
+
| poll / asserts | stores the report | uploads metrics only |
|
tinyedge-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# tinyedge — the Python SDK
|
|
2
|
+
|
|
3
|
+
Benchmark your models on **real edge devices** from inside any Python pipeline, notebook
|
|
4
|
+
or CI job. PyTorch (or any framework) stays on your machine — the SDK converts what you
|
|
5
|
+
give it into the platform's wire formats (ONNX + labeled image archive) and real hardware
|
|
6
|
+
does the measuring.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install git+https://github.com/lienertdemaeyer/tinyedge-agent#subdirectory=sdk
|
|
10
|
+
export TINYEDGE_API_KEY=tinyedge_sk_… # TinyEdge console → New benchmark
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## In a PyTorch pipeline
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import tinyedge
|
|
17
|
+
|
|
18
|
+
client = tinyedge.TinyEdge()
|
|
19
|
+
|
|
20
|
+
result = client.benchmark(
|
|
21
|
+
model, # a live nn.Module (auto-exported to ONNX) or "model.onnx"
|
|
22
|
+
devices=["oppo-a74"], # or ["jetson-orin-nano:tensorrt", "raspberry-pi-5"]
|
|
23
|
+
dataset=raw_test_set, # torch Dataset of (image, label) — UNtransformed,
|
|
24
|
+
# or a folder ("./testset"), or an archive (".zip/.tar.gz")
|
|
25
|
+
precision="fp32",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
print(result) # <BenchmarkResult oppo-a74 completed p50=56.6ms top1=83.3%>
|
|
29
|
+
print(result.latency_ms_p50, result.accuracy_top1)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Notes that make this work well:
|
|
33
|
+
|
|
34
|
+
- **Pass the dataset without transforms.** Preprocessing (resize/crop/normalize) is part of
|
|
35
|
+
the standardized job spec and runs on-device — that's what makes accuracy comparable
|
|
36
|
+
across devices instead of depending on whatever transform happened to run on your laptop.
|
|
37
|
+
- **Labels are class indices.** Folder names / integer labels map directly to your model's
|
|
38
|
+
output indices (`207/` = output neuron 207).
|
|
39
|
+
- A custom `example_input` controls the ONNX export shape:
|
|
40
|
+
`client.benchmark(model, ..., example_input=torch.randn(1, 3, 320, 320))`.
|
|
41
|
+
|
|
42
|
+
## As a CI gate (pytest)
|
|
43
|
+
|
|
44
|
+
Fail the build when the model regresses **on the hardware you ship on**:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
# test_edge_performance.py
|
|
48
|
+
import tinyedge
|
|
49
|
+
|
|
50
|
+
def test_detector_meets_edge_budget():
|
|
51
|
+
client = tinyedge.TinyEdge()
|
|
52
|
+
result = client.benchmark("artifacts/model.onnx",
|
|
53
|
+
devices=["jetson-orin-nano"],
|
|
54
|
+
dataset="testdata/eval_set.tar.gz")
|
|
55
|
+
result.assert_latency(max_ms=50)
|
|
56
|
+
result.assert_accuracy(min_top1=0.80)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`assert_*` raise `AssertionError` with a readable message, so any test runner (pytest,
|
|
60
|
+
unittest, GitHub Actions) reports it natively.
|
|
61
|
+
|
|
62
|
+
## Async style
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
jobs = client.benchmark(model, devices=["oppo-a74", "raspberry-pi-5"], wait=False)
|
|
66
|
+
# … do other work …
|
|
67
|
+
for j in jobs:
|
|
68
|
+
print(client.get(j.id))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## What runs where
|
|
72
|
+
|
|
73
|
+
| Your machine (SDK) | TinyEdge platform | The device (agent) |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| torch → ONNX export | stores artifacts, queues job | downloads ONNX + images |
|
|
76
|
+
| dataset → image archive | tracks pending/running | runs inference, computes accuracy |
|
|
77
|
+
| poll / asserts | stores the report | uploads metrics only |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tinyedge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Benchmark your models (latency + accuracy) on real edge devices — SDK + tinydevice CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Lienert De Maeyer" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["requests>=2.31"]
|
|
14
|
+
# PyTorch integration is optional and lazy — `pip install torch` only if you pass
|
|
15
|
+
# nn.Modules / torch Datasets. Plain ONNX paths + image folders need nothing extra.
|
|
16
|
+
keywords = ["edge", "benchmark", "onnx", "inference", "embedded", "latency", "accuracy"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://tinyedge.ai"
|
|
26
|
+
Documentation = "https://tinyedge.ai"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
tinydevice = "tinyedge.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
include = ["tinyedge*"]
|
tinyedge-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""TinyEdge SDK — run your models on real edge devices from any Python pipeline.
|
|
2
|
+
|
|
3
|
+
import tinyedge
|
|
4
|
+
client = tinyedge.TinyEdge() # TINYEDGE_API_KEY from env
|
|
5
|
+
result = client.benchmark(model, devices=["oppo-a74"], dataset="./testset")
|
|
6
|
+
result.assert_accuracy(min_top1=0.8)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .client import BenchmarkResult, TinyEdge
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = ["TinyEdge", "BenchmarkResult"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""tinydevice — the TinyEdge CLI, shipped inside the `tinyedge` package.
|
|
2
|
+
|
|
3
|
+
export TINYEDGE_API_KEY=tinyedge_sk_…
|
|
4
|
+
tinydevice run model.onnx --device oppo-a74 --dataset ./testset.zip --watch
|
|
5
|
+
tinydevice list
|
|
6
|
+
tinydevice status <job-id>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import BenchmarkResult, TinyEdge
|
|
15
|
+
|
|
16
|
+
ICON = {"pending": "⏳", "running": "⚙ ", "completed": "✓ ", "failed": "✗ "}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print(b: BenchmarkResult) -> None:
|
|
20
|
+
line = f"{ICON.get(b.status, '· ')} {b.id[:8]} {b.status:<9} {b.device}"
|
|
21
|
+
if b.runtime:
|
|
22
|
+
line += f" · {b.runtime}"
|
|
23
|
+
if b.status == "completed":
|
|
24
|
+
line += f" — p50 {b.latency_ms_p50} ms · {b.throughput_per_s}/s"
|
|
25
|
+
if b.accuracy_top1 is not None:
|
|
26
|
+
line += f" · top-1 {b.accuracy_top1 * 100:.1f}%"
|
|
27
|
+
if b.status == "failed" and b.error:
|
|
28
|
+
line += f" — {b.error}"
|
|
29
|
+
print(line)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client(args) -> TinyEdge:
|
|
33
|
+
return TinyEdge(api_key=args.key or None, api_base=args.api or None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_run(args) -> None:
|
|
37
|
+
client = _client(args)
|
|
38
|
+
results = client.benchmark(
|
|
39
|
+
model=args.model,
|
|
40
|
+
model_id=args.model_id,
|
|
41
|
+
devices=args.device,
|
|
42
|
+
dataset=args.dataset,
|
|
43
|
+
dataset_id=args.dataset_id,
|
|
44
|
+
runtime=args.runtime,
|
|
45
|
+
precision=args.precision,
|
|
46
|
+
batch_size=args.batch,
|
|
47
|
+
wait=args.watch,
|
|
48
|
+
)
|
|
49
|
+
for b in results if isinstance(results, list) else [results]:
|
|
50
|
+
_print(b)
|
|
51
|
+
if not args.watch:
|
|
52
|
+
print("Track at https://tinyedge.ai/dashboard/benchmarks")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_status(args) -> None:
|
|
56
|
+
b = _client(args).get(args.job_id)
|
|
57
|
+
if args.json:
|
|
58
|
+
json.dump(b.raw, sys.stdout, indent=2)
|
|
59
|
+
print()
|
|
60
|
+
else:
|
|
61
|
+
_print(b)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_list(args) -> None:
|
|
65
|
+
for b in _client(args).list_benchmarks(limit=args.limit):
|
|
66
|
+
_print(b)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
ap = argparse.ArgumentParser(prog="tinydevice", description=__doc__)
|
|
71
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
72
|
+
ap.add_argument("--api", default=None, help="API base (default https://tinyedge.ai/api)")
|
|
73
|
+
ap.add_argument("--key", default=None, help="API key (or env TINYEDGE_API_KEY)")
|
|
74
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
75
|
+
|
|
76
|
+
run = sub.add_parser("run", help="upload + queue a benchmark on real devices")
|
|
77
|
+
run.add_argument("model", nargs="?", help="path to model file (or use --model-id)")
|
|
78
|
+
run.add_argument("--model-id", default=None, help="id of an already-uploaded model")
|
|
79
|
+
run.add_argument("--device", action="append", required=True,
|
|
80
|
+
help="device id, repeatable; ':runtime' suffix ok (oppo-a74:onnxruntime)")
|
|
81
|
+
run.add_argument("--runtime", default=None)
|
|
82
|
+
run.add_argument("--precision", default="fp32", choices=["fp32", "fp16", "int8", "int4"])
|
|
83
|
+
run.add_argument("--batch", type=int, default=1)
|
|
84
|
+
run.add_argument("--dataset", default=None,
|
|
85
|
+
help="labeled test set: folder or .zip/.tar.gz (for on-device accuracy)")
|
|
86
|
+
run.add_argument("--dataset-id", default=None)
|
|
87
|
+
run.add_argument("--watch", action="store_true", help="wait and print results")
|
|
88
|
+
run.set_defaults(fn=cmd_run)
|
|
89
|
+
|
|
90
|
+
st = sub.add_parser("status", help="show one job")
|
|
91
|
+
st.add_argument("job_id")
|
|
92
|
+
st.add_argument("--json", action="store_true")
|
|
93
|
+
st.set_defaults(fn=cmd_status)
|
|
94
|
+
|
|
95
|
+
ls = sub.add_parser("list", help="list recent jobs")
|
|
96
|
+
ls.add_argument("--limit", type=int, default=20)
|
|
97
|
+
ls.set_defaults(fn=cmd_list)
|
|
98
|
+
|
|
99
|
+
args = ap.parse_args()
|
|
100
|
+
try:
|
|
101
|
+
args.fn(args)
|
|
102
|
+
except Exception as e: # noqa: BLE001 — clean CLI errors, no tracebacks
|
|
103
|
+
print(f"error: {e}", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""TinyEdge client: model/dataset intake from Python objects → real-device jobs.
|
|
2
|
+
|
|
3
|
+
Design rule: PyTorch (or any framework) stays on the developer's machine. The SDK
|
|
4
|
+
converts whatever it's given into the platform's wire formats — ONNX file + labeled
|
|
5
|
+
imagefolder archive — and the device only ever sees those.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
DEFAULT_API = "https://tinyedge.ai/api"
|
|
20
|
+
ARCHIVE_EXTS = (".zip", ".tar", ".tar.gz", ".tgz", ".gz")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TinyEdgeError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BenchmarkResult:
|
|
29
|
+
"""One device run. Raw platform record in `.raw`."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
device: str
|
|
33
|
+
runtime: str | None
|
|
34
|
+
status: str # pending | running | completed | failed
|
|
35
|
+
latency_ms_p50: float | None = None
|
|
36
|
+
throughput_per_s: float | None = None
|
|
37
|
+
accuracy_top1: float | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
raw: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
# ---- CI helpers: fail the build when the model regresses on real hardware ----
|
|
42
|
+
def assert_completed(self) -> "BenchmarkResult":
|
|
43
|
+
if self.status != "completed":
|
|
44
|
+
raise AssertionError(f"[tinyedge] job {self.id[:8]} on {self.device} is "
|
|
45
|
+
f"{self.status}: {self.error or 'no result'}")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def assert_latency(self, max_ms: float) -> "BenchmarkResult":
|
|
49
|
+
self.assert_completed()
|
|
50
|
+
if self.latency_ms_p50 is None or self.latency_ms_p50 > max_ms:
|
|
51
|
+
raise AssertionError(
|
|
52
|
+
f"[tinyedge] {self.device}: p50 latency {self.latency_ms_p50} ms "
|
|
53
|
+
f"exceeds budget {max_ms} ms"
|
|
54
|
+
)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def assert_accuracy(self, min_top1: float) -> "BenchmarkResult":
|
|
58
|
+
self.assert_completed()
|
|
59
|
+
if self.accuracy_top1 is None:
|
|
60
|
+
raise AssertionError(
|
|
61
|
+
f"[tinyedge] {self.device}: no accuracy measured — attach a dataset"
|
|
62
|
+
)
|
|
63
|
+
if self.accuracy_top1 < min_top1:
|
|
64
|
+
raise AssertionError(
|
|
65
|
+
f"[tinyedge] {self.device}: on-device top-1 {self.accuracy_top1:.3f} "
|
|
66
|
+
f"below required {min_top1:.3f}"
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str: # readable in notebooks/logs
|
|
71
|
+
bits = [f"{self.device}", self.status]
|
|
72
|
+
if self.latency_ms_p50 is not None:
|
|
73
|
+
bits.append(f"p50={self.latency_ms_p50}ms")
|
|
74
|
+
if self.accuracy_top1 is not None:
|
|
75
|
+
bits.append(f"top1={self.accuracy_top1:.1%}")
|
|
76
|
+
if self.error:
|
|
77
|
+
bits.append(f"error={self.error!r}")
|
|
78
|
+
return f"<BenchmarkResult {' '.join(bits)}>"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_record(b: dict) -> BenchmarkResult:
|
|
82
|
+
return BenchmarkResult(
|
|
83
|
+
id=b["id"],
|
|
84
|
+
device=b["device"],
|
|
85
|
+
runtime=b.get("runtime"),
|
|
86
|
+
status=b["status"],
|
|
87
|
+
latency_ms_p50=b.get("latencyMs") or None,
|
|
88
|
+
throughput_per_s=b.get("throughput") or None,
|
|
89
|
+
accuracy_top1=b.get("accuracy"),
|
|
90
|
+
error=b.get("error"),
|
|
91
|
+
raw=b,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TinyEdge:
|
|
96
|
+
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
|
97
|
+
self.api_key = api_key or os.environ.get("TINYEDGE_API_KEY")
|
|
98
|
+
if not self.api_key:
|
|
99
|
+
raise TinyEdgeError(
|
|
100
|
+
"No API key. Pass TinyEdge(api_key=…) or set TINYEDGE_API_KEY "
|
|
101
|
+
"(find yours in the TinyEdge console → New benchmark)."
|
|
102
|
+
)
|
|
103
|
+
self.base = (api_base or os.environ.get("TINYEDGE_API", DEFAULT_API)).rstrip("/")
|
|
104
|
+
self._s = requests.Session()
|
|
105
|
+
self._s.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
106
|
+
|
|
107
|
+
# ---- HTTP ----------------------------------------------------------------
|
|
108
|
+
def _check(self, r: requests.Response) -> dict:
|
|
109
|
+
if r.status_code >= 400:
|
|
110
|
+
try:
|
|
111
|
+
msg = r.json().get("error", r.text)
|
|
112
|
+
except Exception: # noqa: BLE001
|
|
113
|
+
msg = r.text
|
|
114
|
+
raise TinyEdgeError(f"API {r.status_code}: {msg}")
|
|
115
|
+
return r.json()
|
|
116
|
+
|
|
117
|
+
def _upload(self, kind: str, field_name: str, path: Path) -> dict:
|
|
118
|
+
with open(path, "rb") as f:
|
|
119
|
+
r = self._s.post(
|
|
120
|
+
f"{self.base}/{kind}/upload",
|
|
121
|
+
files={field_name: (path.name, f)},
|
|
122
|
+
timeout=900,
|
|
123
|
+
)
|
|
124
|
+
return self._check(r)[field_name]
|
|
125
|
+
|
|
126
|
+
# ---- Model intake ----------------------------------------------------------
|
|
127
|
+
def upload_model(self, model, example_input=None, name: str = "model.onnx") -> str:
|
|
128
|
+
"""Accepts a path to a model file, or a live torch.nn.Module (exported to
|
|
129
|
+
ONNX automatically). Returns the platform model id."""
|
|
130
|
+
if isinstance(model, (str, Path)):
|
|
131
|
+
p = Path(model).expanduser()
|
|
132
|
+
if not p.exists():
|
|
133
|
+
raise TinyEdgeError(f"Model file not found: {p}")
|
|
134
|
+
return self._upload("models", "model", p)["id"]
|
|
135
|
+
|
|
136
|
+
# Live PyTorch module → ONNX (torch stays on YOUR machine, never the device).
|
|
137
|
+
try:
|
|
138
|
+
import torch
|
|
139
|
+
except ImportError:
|
|
140
|
+
raise TinyEdgeError(
|
|
141
|
+
f"Can't handle model of type {type(model).__name__}: pass a file path, "
|
|
142
|
+
"or install torch to pass an nn.Module."
|
|
143
|
+
) from None
|
|
144
|
+
if not isinstance(model, torch.nn.Module):
|
|
145
|
+
raise TinyEdgeError(f"Unsupported model type: {type(model).__name__}")
|
|
146
|
+
|
|
147
|
+
if example_input is None:
|
|
148
|
+
example_input = torch.randn(1, 3, 224, 224)
|
|
149
|
+
tmp = Path(tempfile.mkdtemp(prefix="tinyedge-")) / name
|
|
150
|
+
model = model.eval()
|
|
151
|
+
torch.onnx.export(
|
|
152
|
+
model,
|
|
153
|
+
example_input,
|
|
154
|
+
str(tmp),
|
|
155
|
+
input_names=["input"],
|
|
156
|
+
output_names=["output"],
|
|
157
|
+
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
|
|
158
|
+
)
|
|
159
|
+
return self._upload("models", "model", tmp)["id"]
|
|
160
|
+
|
|
161
|
+
# ---- Dataset intake --------------------------------------------------------
|
|
162
|
+
def upload_dataset(self, dataset, limit: int = 500) -> str:
|
|
163
|
+
"""Accepts an archive path, an imagefolder directory (auto-archived), or an
|
|
164
|
+
iterable / torch Dataset of (image, label) pairs (materialized; labels become
|
|
165
|
+
class-index folders). Returns the platform dataset id."""
|
|
166
|
+
if isinstance(dataset, (str, Path)):
|
|
167
|
+
p = Path(dataset).expanduser()
|
|
168
|
+
if p.is_file() and p.name.endswith(ARCHIVE_EXTS):
|
|
169
|
+
return self._upload("datasets", "dataset", p)["id"]
|
|
170
|
+
if p.is_dir():
|
|
171
|
+
return self._upload("datasets", "dataset", self._archive_dir(p))["id"]
|
|
172
|
+
raise TinyEdgeError(f"Dataset not found (need folder or archive): {p}")
|
|
173
|
+
|
|
174
|
+
# Iterable of (image, int label) — e.g. a torch Dataset WITHOUT transforms.
|
|
175
|
+
return self._upload("datasets", "dataset", self._materialize(dataset, limit))["id"]
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _archive_dir(folder: Path) -> Path:
|
|
179
|
+
out = Path(tempfile.mkdtemp(prefix="tinyedge-")) / f"{folder.name}.tar.gz"
|
|
180
|
+
with tarfile.open(out, "w:gz") as tar:
|
|
181
|
+
for child in sorted(folder.iterdir()):
|
|
182
|
+
tar.add(child, arcname=child.name)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _materialize(items, limit: int) -> Path:
|
|
187
|
+
"""Write (image, label) pairs to <label>/<i>.png folders and archive them.
|
|
188
|
+
Pass images UN-transformed — on-device preprocessing is standardized by the
|
|
189
|
+
job spec, which is what makes accuracy comparable across devices."""
|
|
190
|
+
try:
|
|
191
|
+
from PIL import Image
|
|
192
|
+
except ImportError:
|
|
193
|
+
raise TinyEdgeError("Materializing a dataset requires pillow (pip install pillow).") from None
|
|
194
|
+
|
|
195
|
+
root = Path(tempfile.mkdtemp(prefix="tinyedge-")) / "dataset"
|
|
196
|
+
n = 0
|
|
197
|
+
for img, label in items:
|
|
198
|
+
if n >= limit:
|
|
199
|
+
break
|
|
200
|
+
pil = _to_pil(img, Image)
|
|
201
|
+
d = root / str(int(label))
|
|
202
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
pil.save(d / f"{n:05d}.png")
|
|
204
|
+
n += 1
|
|
205
|
+
if n == 0:
|
|
206
|
+
raise TinyEdgeError("Dataset yielded no (image, label) samples.")
|
|
207
|
+
return TinyEdge._archive_dir(root)
|
|
208
|
+
|
|
209
|
+
# ---- The one-call API --------------------------------------------------------
|
|
210
|
+
def benchmark(
|
|
211
|
+
self,
|
|
212
|
+
model=None,
|
|
213
|
+
devices: list[str] | str = (),
|
|
214
|
+
dataset=None,
|
|
215
|
+
runtime: str | None = None,
|
|
216
|
+
precision: str = "fp32",
|
|
217
|
+
batch_size: int = 1,
|
|
218
|
+
example_input=None,
|
|
219
|
+
wait: bool = True,
|
|
220
|
+
timeout: float = 900,
|
|
221
|
+
poll_interval: float = 3,
|
|
222
|
+
dataset_limit: int = 500,
|
|
223
|
+
model_id: str | None = None,
|
|
224
|
+
dataset_id: str | None = None,
|
|
225
|
+
):
|
|
226
|
+
"""Upload model (+ dataset), queue one job per device, optionally wait.
|
|
227
|
+
|
|
228
|
+
devices: ["oppo-a74", "jetson-orin-nano:tensorrt"] — ':runtime' overrides.
|
|
229
|
+
Pass model_id/dataset_id to reuse already-uploaded artifacts.
|
|
230
|
+
Returns a BenchmarkResult (single device) or list of them.
|
|
231
|
+
"""
|
|
232
|
+
single = isinstance(devices, str)
|
|
233
|
+
device_list = [devices] if single else list(devices)
|
|
234
|
+
if not device_list:
|
|
235
|
+
raise TinyEdgeError("Pass at least one device, e.g. devices=['oppo-a74']")
|
|
236
|
+
|
|
237
|
+
if model_id is None:
|
|
238
|
+
if model is None:
|
|
239
|
+
raise TinyEdgeError("Pass a model (path / nn.Module) or model_id=…")
|
|
240
|
+
model_id = self.upload_model(model, example_input=example_input)
|
|
241
|
+
if dataset_id is None and dataset is not None:
|
|
242
|
+
dataset_id = self.upload_dataset(dataset, limit=dataset_limit)
|
|
243
|
+
|
|
244
|
+
jobs = []
|
|
245
|
+
for spec in device_list:
|
|
246
|
+
dev, _, rt = spec.partition(":")
|
|
247
|
+
payload = {
|
|
248
|
+
"mode": "device",
|
|
249
|
+
"device": dev,
|
|
250
|
+
"runtime": rt or runtime,
|
|
251
|
+
"precision": precision,
|
|
252
|
+
"batchSize": batch_size,
|
|
253
|
+
"modelId": model_id,
|
|
254
|
+
"datasetId": dataset_id,
|
|
255
|
+
}
|
|
256
|
+
r = self._check(self._s.post(f"{self.base}/benchmarks", json=payload, timeout=60))
|
|
257
|
+
jobs.append(_from_record(r["benchmark"]))
|
|
258
|
+
|
|
259
|
+
if wait:
|
|
260
|
+
jobs = self._wait(jobs, timeout=timeout, poll_interval=poll_interval)
|
|
261
|
+
return jobs[0] if single else jobs
|
|
262
|
+
|
|
263
|
+
def get(self, job_id: str) -> BenchmarkResult:
|
|
264
|
+
r = self._check(self._s.get(f"{self.base}/benchmarks/{job_id}", timeout=30))
|
|
265
|
+
return _from_record(r["benchmark"])
|
|
266
|
+
|
|
267
|
+
def list_benchmarks(self, limit: int = 20) -> list[BenchmarkResult]:
|
|
268
|
+
r = self._check(self._s.get(f"{self.base}/benchmarks", timeout=30))
|
|
269
|
+
return [_from_record(b) for b in r["benchmarks"][:limit]]
|
|
270
|
+
|
|
271
|
+
def _wait(self, jobs, timeout: float, poll_interval: float):
|
|
272
|
+
deadline = time.time() + timeout
|
|
273
|
+
done: dict[str, BenchmarkResult] = {}
|
|
274
|
+
while len(done) < len(jobs):
|
|
275
|
+
if time.time() > deadline:
|
|
276
|
+
raise TinyEdgeError(
|
|
277
|
+
f"Timed out after {timeout}s waiting for "
|
|
278
|
+
f"{[j.id[:8] for j in jobs if j.id not in done]} — is the device's "
|
|
279
|
+
"agent running? (tinyedge-agent daemon --device <id>)"
|
|
280
|
+
)
|
|
281
|
+
for j in jobs:
|
|
282
|
+
if j.id in done:
|
|
283
|
+
continue
|
|
284
|
+
cur = self.get(j.id)
|
|
285
|
+
if cur.status in ("completed", "failed"):
|
|
286
|
+
done[j.id] = cur
|
|
287
|
+
if len(done) < len(jobs):
|
|
288
|
+
time.sleep(poll_interval)
|
|
289
|
+
return [done[j.id] for j in jobs]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _to_pil(img, Image):
|
|
293
|
+
"""Best-effort conversion of common pipeline image types to PIL."""
|
|
294
|
+
if isinstance(img, Image.Image):
|
|
295
|
+
return img.convert("RGB")
|
|
296
|
+
if isinstance(img, (str, Path)):
|
|
297
|
+
return Image.open(img).convert("RGB")
|
|
298
|
+
# numpy HWC uint8 / torch CHW float tensor
|
|
299
|
+
try:
|
|
300
|
+
import numpy as np
|
|
301
|
+
|
|
302
|
+
if hasattr(img, "detach"): # torch tensor
|
|
303
|
+
t = img.detach().cpu()
|
|
304
|
+
arr = t.numpy()
|
|
305
|
+
if arr.ndim == 3 and arr.shape[0] in (1, 3): # CHW → HWC
|
|
306
|
+
arr = arr.transpose(1, 2, 0)
|
|
307
|
+
if arr.dtype != np.uint8:
|
|
308
|
+
arr = (arr.clip(0, 1) * 255).astype(np.uint8)
|
|
309
|
+
if arr.shape[-1] == 1:
|
|
310
|
+
arr = arr.repeat(3, axis=-1)
|
|
311
|
+
return Image.fromarray(arr).convert("RGB")
|
|
312
|
+
if isinstance(img, np.ndarray):
|
|
313
|
+
arr = img
|
|
314
|
+
if arr.dtype != np.uint8:
|
|
315
|
+
arr = (arr.clip(0, 1) * 255).astype(np.uint8)
|
|
316
|
+
return Image.fromarray(arr).convert("RGB")
|
|
317
|
+
except ImportError:
|
|
318
|
+
pass
|
|
319
|
+
raise TinyEdgeError(
|
|
320
|
+
f"Can't convert image of type {type(img).__name__} — yield PIL images, "
|
|
321
|
+
"file paths, numpy arrays or torch tensors."
|
|
322
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinyedge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Benchmark your models (latency + accuracy) on real edge devices — SDK + tinydevice CLI
|
|
5
|
+
Author: Lienert De Maeyer
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Lienert De Maeyer / TinyEdge
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://tinyedge.ai
|
|
29
|
+
Project-URL: Documentation, https://tinyedge.ai
|
|
30
|
+
Keywords: edge,benchmark,onnx,inference,embedded,latency,accuracy
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: requests>=2.31
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# tinyedge — the Python SDK
|
|
42
|
+
|
|
43
|
+
Benchmark your models on **real edge devices** from inside any Python pipeline, notebook
|
|
44
|
+
or CI job. PyTorch (or any framework) stays on your machine — the SDK converts what you
|
|
45
|
+
give it into the platform's wire formats (ONNX + labeled image archive) and real hardware
|
|
46
|
+
does the measuring.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install git+https://github.com/lienertdemaeyer/tinyedge-agent#subdirectory=sdk
|
|
50
|
+
export TINYEDGE_API_KEY=tinyedge_sk_… # TinyEdge console → New benchmark
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## In a PyTorch pipeline
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import tinyedge
|
|
57
|
+
|
|
58
|
+
client = tinyedge.TinyEdge()
|
|
59
|
+
|
|
60
|
+
result = client.benchmark(
|
|
61
|
+
model, # a live nn.Module (auto-exported to ONNX) or "model.onnx"
|
|
62
|
+
devices=["oppo-a74"], # or ["jetson-orin-nano:tensorrt", "raspberry-pi-5"]
|
|
63
|
+
dataset=raw_test_set, # torch Dataset of (image, label) — UNtransformed,
|
|
64
|
+
# or a folder ("./testset"), or an archive (".zip/.tar.gz")
|
|
65
|
+
precision="fp32",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(result) # <BenchmarkResult oppo-a74 completed p50=56.6ms top1=83.3%>
|
|
69
|
+
print(result.latency_ms_p50, result.accuracy_top1)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Notes that make this work well:
|
|
73
|
+
|
|
74
|
+
- **Pass the dataset without transforms.** Preprocessing (resize/crop/normalize) is part of
|
|
75
|
+
the standardized job spec and runs on-device — that's what makes accuracy comparable
|
|
76
|
+
across devices instead of depending on whatever transform happened to run on your laptop.
|
|
77
|
+
- **Labels are class indices.** Folder names / integer labels map directly to your model's
|
|
78
|
+
output indices (`207/` = output neuron 207).
|
|
79
|
+
- A custom `example_input` controls the ONNX export shape:
|
|
80
|
+
`client.benchmark(model, ..., example_input=torch.randn(1, 3, 320, 320))`.
|
|
81
|
+
|
|
82
|
+
## As a CI gate (pytest)
|
|
83
|
+
|
|
84
|
+
Fail the build when the model regresses **on the hardware you ship on**:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# test_edge_performance.py
|
|
88
|
+
import tinyedge
|
|
89
|
+
|
|
90
|
+
def test_detector_meets_edge_budget():
|
|
91
|
+
client = tinyedge.TinyEdge()
|
|
92
|
+
result = client.benchmark("artifacts/model.onnx",
|
|
93
|
+
devices=["jetson-orin-nano"],
|
|
94
|
+
dataset="testdata/eval_set.tar.gz")
|
|
95
|
+
result.assert_latency(max_ms=50)
|
|
96
|
+
result.assert_accuracy(min_top1=0.80)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`assert_*` raise `AssertionError` with a readable message, so any test runner (pytest,
|
|
100
|
+
unittest, GitHub Actions) reports it natively.
|
|
101
|
+
|
|
102
|
+
## Async style
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
jobs = client.benchmark(model, devices=["oppo-a74", "raspberry-pi-5"], wait=False)
|
|
106
|
+
# … do other work …
|
|
107
|
+
for j in jobs:
|
|
108
|
+
print(client.get(j.id))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## What runs where
|
|
112
|
+
|
|
113
|
+
| Your machine (SDK) | TinyEdge platform | The device (agent) |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| torch → ONNX export | stores artifacts, queues job | downloads ONNX + images |
|
|
116
|
+
| dataset → image archive | tracks pending/running | runs inference, computes accuracy |
|
|
117
|
+
| poll / asserts | stores the report | uploads metrics only |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tinyedge/__init__.py
|
|
5
|
+
tinyedge/cli.py
|
|
6
|
+
tinyedge/client.py
|
|
7
|
+
tinyedge.egg-info/PKG-INFO
|
|
8
|
+
tinyedge.egg-info/SOURCES.txt
|
|
9
|
+
tinyedge.egg-info/dependency_links.txt
|
|
10
|
+
tinyedge.egg-info/entry_points.txt
|
|
11
|
+
tinyedge.egg-info/requires.txt
|
|
12
|
+
tinyedge.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.31
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinyedge
|