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 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.
@@ -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,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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ tinydevice = tinyedge.cli:main
@@ -0,0 +1 @@
1
+ requests>=2.31
@@ -0,0 +1 @@
1
+ tinyedge