openrunner-sdk 0.2.0__tar.gz → 0.3.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.
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/PKG-INFO +231 -1
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/README.md +230 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/__init__.py +16 -2
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/media.py +177 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/run.py +42 -1
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/sender.py +11 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/pyproject.toml +1 -1
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_media.py +267 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/.gitignore +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/=6.0 +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/=8.1 +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/api_client.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/artifact.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/buffer.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/cache.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/cli.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/config.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/git_info.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/__init__.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/fastai.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/huggingface.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/keras.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/langchain.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/lightning.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/pytorch.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/sklearn.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/xgboost.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/launch.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/offline.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/query_api.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/settings.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/summary.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/sweep.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/system_metrics.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/trace.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/wandb_compat/__init__.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/wandb_compat/_shim.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/__init__.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/conftest.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_alert.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_aliases.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_api_client.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_artifact.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_buffer.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_cache.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_cli.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_config.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_finish.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_git_info.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_init.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_fastai.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_huggingface.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_keras.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_langchain.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_lightning.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_pytorch.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_sklearn.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_xgboost.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_launch.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_log.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_offline.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_offline_sync.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_query_api.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_resume.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_sender.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_summary.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_sweep.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_system_metrics.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_trace.py +0 -0
- {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_wandb_compat.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openrunner-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: OpenRunner SDK - W&B-compatible ML experiment tracking client
|
|
5
5
|
Project-URL: Homepage, https://github.com/jqueguiner/openrunner
|
|
6
6
|
Project-URL: Repository, https://github.com/jqueguiner/openrunner
|
|
@@ -238,6 +238,175 @@ print(run.config) # Config object
|
|
|
238
238
|
print(run.summary) # Summary object
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
+
### HTML
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
# Log raw HTML for rich reports, custom visualizations, or formatted output
|
|
245
|
+
openrunner.log({"report": openrunner.Html("<h1>Training Report</h1><p>Loss converged at epoch 42.</p>")})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Histograms
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
import numpy as np
|
|
252
|
+
|
|
253
|
+
weights = np.random.randn(10000)
|
|
254
|
+
openrunner.log({"weight_dist": openrunner.Histogram(weights, num_bins=50)})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Plotly Charts
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
import plotly.graph_objects as go
|
|
261
|
+
|
|
262
|
+
fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
|
|
263
|
+
openrunner.log({"interactive_plot": openrunner.Plotly(fig)})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Point Clouds
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
import numpy as np
|
|
270
|
+
|
|
271
|
+
points = np.random.randn(1000, 3)
|
|
272
|
+
colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
|
|
273
|
+
openrunner.log({"lidar": openrunner.PointCloud3D(points, colors=colors)})
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Bounding Boxes
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
img = openrunner.Image("photo.jpg")
|
|
280
|
+
boxes = [{"position": {"minX": 10, "minY": 20, "maxX": 100, "maxY": 150}, "class_id": 0}]
|
|
281
|
+
openrunner.log({"detections": openrunner.BoundingBoxes2D(img, boxes, class_labels={0: "cat"})})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Audio
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
import numpy as np
|
|
288
|
+
|
|
289
|
+
# From numpy array (mono, float32, -1 to 1)
|
|
290
|
+
audio = openrunner.Audio(np.random.randn(44100).astype(np.float32), sample_rate=44100)
|
|
291
|
+
openrunner.log({"audio_sample": audio})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Video
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
# From file path
|
|
298
|
+
openrunner.log({"demo": openrunner.Video("output.mp4", caption="training demo")})
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Matplotlib Figures
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
import matplotlib.pyplot as plt
|
|
305
|
+
|
|
306
|
+
plt.figure()
|
|
307
|
+
plt.plot([1, 2, 3], [4, 5, 6])
|
|
308
|
+
openrunner.log({"chart": openrunner.MatplotlibFigure()}) # captures current figure
|
|
309
|
+
plt.close()
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## LLM Tracing
|
|
313
|
+
|
|
314
|
+
Trace LLM API calls for debugging and cost tracking.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
import openrunner
|
|
318
|
+
|
|
319
|
+
openrunner.init(project="llm-app")
|
|
320
|
+
|
|
321
|
+
# Auto-trace OpenAI calls
|
|
322
|
+
openrunner.trace.patch_openai()
|
|
323
|
+
|
|
324
|
+
# Or manually trace any function
|
|
325
|
+
@openrunner.trace
|
|
326
|
+
def generate(prompt):
|
|
327
|
+
return client.chat.completions.create(
|
|
328
|
+
model="gpt-4", messages=[{"role": "user", "content": prompt}]
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Hyperparameter Sweeps
|
|
333
|
+
|
|
334
|
+
Run distributed hyperparameter searches.
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import openrunner
|
|
338
|
+
|
|
339
|
+
sweep_config = {
|
|
340
|
+
"method": "bayes",
|
|
341
|
+
"metric": {"name": "val_loss", "goal": "minimize"},
|
|
342
|
+
"parameters": {
|
|
343
|
+
"lr": {"min": 1e-5, "max": 1e-2, "distribution": "log_uniform"},
|
|
344
|
+
"epochs": {"values": [10, 20, 50]},
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
sweep_id = openrunner.sweep(sweep_config, project="my-project")
|
|
349
|
+
|
|
350
|
+
def train():
|
|
351
|
+
run = openrunner.init()
|
|
352
|
+
lr = openrunner.config.lr
|
|
353
|
+
# ... training loop ...
|
|
354
|
+
openrunner.finish()
|
|
355
|
+
|
|
356
|
+
openrunner.agent(sweep_id, function=train, count=20)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Remote Launch
|
|
360
|
+
|
|
361
|
+
Submit training jobs to remote infrastructure.
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
import openrunner
|
|
365
|
+
|
|
366
|
+
job = openrunner.launch(
|
|
367
|
+
project="my-project",
|
|
368
|
+
config={"lr": 0.001, "epochs": 50},
|
|
369
|
+
resource="gpu-a100",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
job.wait() # block until finished
|
|
373
|
+
print(job.state) # "finished"
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Model Registry
|
|
377
|
+
|
|
378
|
+
Version and alias models for production deployment.
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
# Log a model with aliases
|
|
382
|
+
artifact = openrunner.Artifact(name="classifier", type="model")
|
|
383
|
+
artifact.add_file("model.pt")
|
|
384
|
+
openrunner.link_artifact(artifact, aliases=["staging"])
|
|
385
|
+
|
|
386
|
+
# Use a model by alias
|
|
387
|
+
model_dir = openrunner.use_artifact("classifier:production")
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Alerts
|
|
391
|
+
|
|
392
|
+
Send notifications when training reaches milestones or encounters issues.
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
openrunner.alert(title="Training complete", text="Final accuracy: 95.2%", level="INFO")
|
|
396
|
+
openrunner.alert(title="Loss spike detected", level="WARN")
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Query API
|
|
400
|
+
|
|
401
|
+
Read-only access to runs, metrics, and projects for analysis and dashboards.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
api = openrunner.Api()
|
|
405
|
+
runs = api.runs("my-project", filters={"state": "finished"})
|
|
406
|
+
for run in runs:
|
|
407
|
+
print(f"{run.name}: {run.summary.get('accuracy')}")
|
|
408
|
+
```
|
|
409
|
+
|
|
241
410
|
## Migrating from W&B
|
|
242
411
|
|
|
243
412
|
Change one import — everything else stays the same:
|
|
@@ -302,6 +471,67 @@ trainer = pl.Trainer(logger=logger)
|
|
|
302
471
|
trainer.fit(model)
|
|
303
472
|
```
|
|
304
473
|
|
|
474
|
+
### Keras
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
from openrunner.integration.keras import OpenRunnerCallback
|
|
478
|
+
|
|
479
|
+
openrunner.init(project="keras-example")
|
|
480
|
+
|
|
481
|
+
model.fit(x_train, y_train, callbacks=[OpenRunnerCallback()])
|
|
482
|
+
|
|
483
|
+
openrunner.finish()
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### XGBoost
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
from openrunner.integration.xgboost import OpenRunnerCallback
|
|
490
|
+
|
|
491
|
+
openrunner.init(project="xgboost-example")
|
|
492
|
+
|
|
493
|
+
bst = xgb.train(params, dtrain, callbacks=[OpenRunnerCallback()])
|
|
494
|
+
|
|
495
|
+
openrunner.finish()
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### scikit-learn
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
from openrunner.integration.sklearn import log_model
|
|
502
|
+
|
|
503
|
+
openrunner.init(project="sklearn-example")
|
|
504
|
+
model.fit(X_train, y_train)
|
|
505
|
+
log_model(model) # Logs parameters and metrics
|
|
506
|
+
openrunner.finish()
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### FastAI
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
from openrunner.integration.fastai import OpenRunnerCallback
|
|
513
|
+
|
|
514
|
+
openrunner.init(project="fastai-example")
|
|
515
|
+
|
|
516
|
+
learn = cnn_learner(dls, resnet34, cbs=[OpenRunnerCallback()])
|
|
517
|
+
learn.fine_tune(5)
|
|
518
|
+
|
|
519
|
+
openrunner.finish()
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### LangChain
|
|
523
|
+
|
|
524
|
+
```python
|
|
525
|
+
from openrunner.integration.langchain import OpenRunnerTracer
|
|
526
|
+
|
|
527
|
+
openrunner.init(project="langchain-example")
|
|
528
|
+
|
|
529
|
+
tracer = OpenRunnerTracer()
|
|
530
|
+
chain.invoke({"input": "Hello"}, config={"callbacks": [tracer]})
|
|
531
|
+
|
|
532
|
+
openrunner.finish()
|
|
533
|
+
```
|
|
534
|
+
|
|
305
535
|
## Offline Mode
|
|
306
536
|
|
|
307
537
|
Train without connectivity, sync later:
|
|
@@ -190,6 +190,175 @@ print(run.config) # Config object
|
|
|
190
190
|
print(run.summary) # Summary object
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
### HTML
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# Log raw HTML for rich reports, custom visualizations, or formatted output
|
|
197
|
+
openrunner.log({"report": openrunner.Html("<h1>Training Report</h1><p>Loss converged at epoch 42.</p>")})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Histograms
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import numpy as np
|
|
204
|
+
|
|
205
|
+
weights = np.random.randn(10000)
|
|
206
|
+
openrunner.log({"weight_dist": openrunner.Histogram(weights, num_bins=50)})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Plotly Charts
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
import plotly.graph_objects as go
|
|
213
|
+
|
|
214
|
+
fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
|
|
215
|
+
openrunner.log({"interactive_plot": openrunner.Plotly(fig)})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Point Clouds
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
import numpy as np
|
|
222
|
+
|
|
223
|
+
points = np.random.randn(1000, 3)
|
|
224
|
+
colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
|
|
225
|
+
openrunner.log({"lidar": openrunner.PointCloud3D(points, colors=colors)})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Bounding Boxes
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
img = openrunner.Image("photo.jpg")
|
|
232
|
+
boxes = [{"position": {"minX": 10, "minY": 20, "maxX": 100, "maxY": 150}, "class_id": 0}]
|
|
233
|
+
openrunner.log({"detections": openrunner.BoundingBoxes2D(img, boxes, class_labels={0: "cat"})})
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Audio
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
import numpy as np
|
|
240
|
+
|
|
241
|
+
# From numpy array (mono, float32, -1 to 1)
|
|
242
|
+
audio = openrunner.Audio(np.random.randn(44100).astype(np.float32), sample_rate=44100)
|
|
243
|
+
openrunner.log({"audio_sample": audio})
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Video
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# From file path
|
|
250
|
+
openrunner.log({"demo": openrunner.Video("output.mp4", caption="training demo")})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Matplotlib Figures
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import matplotlib.pyplot as plt
|
|
257
|
+
|
|
258
|
+
plt.figure()
|
|
259
|
+
plt.plot([1, 2, 3], [4, 5, 6])
|
|
260
|
+
openrunner.log({"chart": openrunner.MatplotlibFigure()}) # captures current figure
|
|
261
|
+
plt.close()
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## LLM Tracing
|
|
265
|
+
|
|
266
|
+
Trace LLM API calls for debugging and cost tracking.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
import openrunner
|
|
270
|
+
|
|
271
|
+
openrunner.init(project="llm-app")
|
|
272
|
+
|
|
273
|
+
# Auto-trace OpenAI calls
|
|
274
|
+
openrunner.trace.patch_openai()
|
|
275
|
+
|
|
276
|
+
# Or manually trace any function
|
|
277
|
+
@openrunner.trace
|
|
278
|
+
def generate(prompt):
|
|
279
|
+
return client.chat.completions.create(
|
|
280
|
+
model="gpt-4", messages=[{"role": "user", "content": prompt}]
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Hyperparameter Sweeps
|
|
285
|
+
|
|
286
|
+
Run distributed hyperparameter searches.
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
import openrunner
|
|
290
|
+
|
|
291
|
+
sweep_config = {
|
|
292
|
+
"method": "bayes",
|
|
293
|
+
"metric": {"name": "val_loss", "goal": "minimize"},
|
|
294
|
+
"parameters": {
|
|
295
|
+
"lr": {"min": 1e-5, "max": 1e-2, "distribution": "log_uniform"},
|
|
296
|
+
"epochs": {"values": [10, 20, 50]},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
sweep_id = openrunner.sweep(sweep_config, project="my-project")
|
|
301
|
+
|
|
302
|
+
def train():
|
|
303
|
+
run = openrunner.init()
|
|
304
|
+
lr = openrunner.config.lr
|
|
305
|
+
# ... training loop ...
|
|
306
|
+
openrunner.finish()
|
|
307
|
+
|
|
308
|
+
openrunner.agent(sweep_id, function=train, count=20)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Remote Launch
|
|
312
|
+
|
|
313
|
+
Submit training jobs to remote infrastructure.
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
import openrunner
|
|
317
|
+
|
|
318
|
+
job = openrunner.launch(
|
|
319
|
+
project="my-project",
|
|
320
|
+
config={"lr": 0.001, "epochs": 50},
|
|
321
|
+
resource="gpu-a100",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
job.wait() # block until finished
|
|
325
|
+
print(job.state) # "finished"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Model Registry
|
|
329
|
+
|
|
330
|
+
Version and alias models for production deployment.
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
# Log a model with aliases
|
|
334
|
+
artifact = openrunner.Artifact(name="classifier", type="model")
|
|
335
|
+
artifact.add_file("model.pt")
|
|
336
|
+
openrunner.link_artifact(artifact, aliases=["staging"])
|
|
337
|
+
|
|
338
|
+
# Use a model by alias
|
|
339
|
+
model_dir = openrunner.use_artifact("classifier:production")
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Alerts
|
|
343
|
+
|
|
344
|
+
Send notifications when training reaches milestones or encounters issues.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
openrunner.alert(title="Training complete", text="Final accuracy: 95.2%", level="INFO")
|
|
348
|
+
openrunner.alert(title="Loss spike detected", level="WARN")
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Query API
|
|
352
|
+
|
|
353
|
+
Read-only access to runs, metrics, and projects for analysis and dashboards.
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
api = openrunner.Api()
|
|
357
|
+
runs = api.runs("my-project", filters={"state": "finished"})
|
|
358
|
+
for run in runs:
|
|
359
|
+
print(f"{run.name}: {run.summary.get('accuracy')}")
|
|
360
|
+
```
|
|
361
|
+
|
|
193
362
|
## Migrating from W&B
|
|
194
363
|
|
|
195
364
|
Change one import — everything else stays the same:
|
|
@@ -254,6 +423,67 @@ trainer = pl.Trainer(logger=logger)
|
|
|
254
423
|
trainer.fit(model)
|
|
255
424
|
```
|
|
256
425
|
|
|
426
|
+
### Keras
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
from openrunner.integration.keras import OpenRunnerCallback
|
|
430
|
+
|
|
431
|
+
openrunner.init(project="keras-example")
|
|
432
|
+
|
|
433
|
+
model.fit(x_train, y_train, callbacks=[OpenRunnerCallback()])
|
|
434
|
+
|
|
435
|
+
openrunner.finish()
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### XGBoost
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
from openrunner.integration.xgboost import OpenRunnerCallback
|
|
442
|
+
|
|
443
|
+
openrunner.init(project="xgboost-example")
|
|
444
|
+
|
|
445
|
+
bst = xgb.train(params, dtrain, callbacks=[OpenRunnerCallback()])
|
|
446
|
+
|
|
447
|
+
openrunner.finish()
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### scikit-learn
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
from openrunner.integration.sklearn import log_model
|
|
454
|
+
|
|
455
|
+
openrunner.init(project="sklearn-example")
|
|
456
|
+
model.fit(X_train, y_train)
|
|
457
|
+
log_model(model) # Logs parameters and metrics
|
|
458
|
+
openrunner.finish()
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### FastAI
|
|
462
|
+
|
|
463
|
+
```python
|
|
464
|
+
from openrunner.integration.fastai import OpenRunnerCallback
|
|
465
|
+
|
|
466
|
+
openrunner.init(project="fastai-example")
|
|
467
|
+
|
|
468
|
+
learn = cnn_learner(dls, resnet34, cbs=[OpenRunnerCallback()])
|
|
469
|
+
learn.fine_tune(5)
|
|
470
|
+
|
|
471
|
+
openrunner.finish()
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### LangChain
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
from openrunner.integration.langchain import OpenRunnerTracer
|
|
478
|
+
|
|
479
|
+
openrunner.init(project="langchain-example")
|
|
480
|
+
|
|
481
|
+
tracer = OpenRunnerTracer()
|
|
482
|
+
chain.invoke({"input": "Hello"}, config={"callbacks": [tracer]})
|
|
483
|
+
|
|
484
|
+
openrunner.finish()
|
|
485
|
+
```
|
|
486
|
+
|
|
257
487
|
## Offline Mode
|
|
258
488
|
|
|
259
489
|
Train without connectivity, sync later:
|
|
@@ -13,6 +13,9 @@ Public API:
|
|
|
13
13
|
openrunner.Video -> Video class for video logging
|
|
14
14
|
openrunner.Histogram -> Histogram class for distribution logging
|
|
15
15
|
openrunner.Plotly -> Plotly class for interactive charts
|
|
16
|
+
openrunner.PlotlyChart -> Enhanced Plotly with static PNG fallback
|
|
17
|
+
openrunner.MatplotlibFigure -> Capture matplotlib figure as PNG
|
|
18
|
+
openrunner.PointCloud3D -> 3D point cloud visualization
|
|
16
19
|
openrunner.BoundingBoxes2D -> Bounding box overlay on images
|
|
17
20
|
openrunner.Artifact -> Artifact class for versioned file collections
|
|
18
21
|
openrunner.Api -> Query API for read-only access to runs/projects
|
|
@@ -37,7 +40,18 @@ from typing import Any
|
|
|
37
40
|
from openrunner.artifact import Artifact
|
|
38
41
|
from openrunner.config import Config
|
|
39
42
|
from openrunner.launch import LaunchJob, launch, from_run as _launch_from_run
|
|
40
|
-
from openrunner.media import
|
|
43
|
+
from openrunner.media import (
|
|
44
|
+
Audio,
|
|
45
|
+
BoundingBoxes2D,
|
|
46
|
+
Histogram,
|
|
47
|
+
Image,
|
|
48
|
+
MatplotlibFigure,
|
|
49
|
+
Plotly,
|
|
50
|
+
PlotlyChart,
|
|
51
|
+
PointCloud3D,
|
|
52
|
+
Table,
|
|
53
|
+
Video,
|
|
54
|
+
)
|
|
41
55
|
from openrunner.query_api import Api
|
|
42
56
|
from openrunner.run import Run
|
|
43
57
|
from openrunner.settings import SDKSettings
|
|
@@ -53,7 +67,7 @@ launch.from_run = _launch_from_run # type: ignore[attr-defined]
|
|
|
53
67
|
# openrunner.trace.patch_openai() syntax
|
|
54
68
|
trace.patch_openai = _patch_openai # type: ignore[attr-defined]
|
|
55
69
|
|
|
56
|
-
__version__ = "0.
|
|
70
|
+
__version__ = "0.3.0"
|
|
57
71
|
|
|
58
72
|
logger = logging.getLogger("openrunner")
|
|
59
73
|
|
|
@@ -421,6 +421,183 @@ class Plotly:
|
|
|
421
421
|
)
|
|
422
422
|
|
|
423
423
|
|
|
424
|
+
class MatplotlibFigure:
|
|
425
|
+
"""Capture a Matplotlib figure as a PNG image for experiment logging.
|
|
426
|
+
|
|
427
|
+
Accepts an optional ``matplotlib.figure.Figure``. When *fig* is ``None``
|
|
428
|
+
the current active figure (``pyplot.gcf()``) is used, making the
|
|
429
|
+
common pattern ``openrunner.log({"chart": openrunner.MatplotlibFigure()})``
|
|
430
|
+
a one-liner.
|
|
431
|
+
|
|
432
|
+
Serialization is lazy -- the PNG bytes are produced only when the sender
|
|
433
|
+
thread calls ``_serialize()``.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def __init__(
|
|
437
|
+
self,
|
|
438
|
+
fig: Any | None = None,
|
|
439
|
+
caption: str | None = None,
|
|
440
|
+
dpi: int = 150,
|
|
441
|
+
) -> None:
|
|
442
|
+
self._fig = fig
|
|
443
|
+
self.caption = caption
|
|
444
|
+
self.dpi = dpi
|
|
445
|
+
|
|
446
|
+
def _serialize(self) -> tuple[bytes, str]:
|
|
447
|
+
"""Render figure to PNG bytes. Returns (bytes, content_type)."""
|
|
448
|
+
import matplotlib.pyplot as plt # lazy import
|
|
449
|
+
|
|
450
|
+
fig = self._fig if self._fig is not None else plt.gcf()
|
|
451
|
+
buf = io.BytesIO()
|
|
452
|
+
fig.savefig(buf, format="png", dpi=self.dpi, bbox_inches="tight")
|
|
453
|
+
buf.seek(0)
|
|
454
|
+
return buf.getvalue(), "image/png"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class PlotlyChart:
|
|
458
|
+
"""Enhanced Plotly figure that stores both interactive JSON and static PNG.
|
|
459
|
+
|
|
460
|
+
Stores the full Plotly JSON for interactive rendering in the frontend,
|
|
461
|
+
and optionally generates a static PNG fallback via ``fig.to_image()``
|
|
462
|
+
(requires the ``kaleido`` package).
|
|
463
|
+
|
|
464
|
+
Use ``Plotly`` for JSON-only storage, ``PlotlyChart`` when you also
|
|
465
|
+
want a static image fallback.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
def __init__(
|
|
469
|
+
self,
|
|
470
|
+
figure: Any,
|
|
471
|
+
caption: str | None = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
self._figure = figure
|
|
474
|
+
self.caption = caption
|
|
475
|
+
|
|
476
|
+
def _serialize(self) -> dict[str, Any]:
|
|
477
|
+
"""Serialize Plotly figure to dict with optional static PNG.
|
|
478
|
+
|
|
479
|
+
Returns a dict with keys:
|
|
480
|
+
- ``data``, ``layout`` (standard Plotly JSON)
|
|
481
|
+
- ``static_image`` (base64-encoded PNG string, or None if kaleido
|
|
482
|
+
is not available)
|
|
483
|
+
"""
|
|
484
|
+
import base64
|
|
485
|
+
|
|
486
|
+
# Get the JSON representation
|
|
487
|
+
if hasattr(self._figure, "to_json"):
|
|
488
|
+
json_str = self._figure.to_json()
|
|
489
|
+
result = json.loads(json_str)
|
|
490
|
+
elif hasattr(self._figure, "to_dict"):
|
|
491
|
+
result = self._figure.to_dict()
|
|
492
|
+
elif isinstance(self._figure, dict):
|
|
493
|
+
result = dict(self._figure)
|
|
494
|
+
else:
|
|
495
|
+
raise TypeError(
|
|
496
|
+
f"PlotlyChart: unsupported figure type {type(self._figure).__name__}. "
|
|
497
|
+
"Expected plotly.graph_objects.Figure or dict."
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Try to generate a static PNG fallback
|
|
501
|
+
static_image: str | None = None
|
|
502
|
+
if hasattr(self._figure, "to_image"):
|
|
503
|
+
try:
|
|
504
|
+
png_bytes = self._figure.to_image(format="png", width=800, height=500)
|
|
505
|
+
static_image = base64.b64encode(png_bytes).decode("ascii")
|
|
506
|
+
except Exception:
|
|
507
|
+
pass # kaleido not installed or failed -- skip
|
|
508
|
+
|
|
509
|
+
result["_static_image"] = static_image
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class PointCloud3D:
|
|
514
|
+
"""A 3D point cloud for experiment logging.
|
|
515
|
+
|
|
516
|
+
Accepts XYZ coordinates as a numpy array of shape (N, 3), with optional
|
|
517
|
+
per-point RGB colors and string labels. Data is serialized inline as
|
|
518
|
+
JSON (no S3 upload needed).
|
|
519
|
+
|
|
520
|
+
Usage::
|
|
521
|
+
|
|
522
|
+
pts = np.random.randn(1000, 3)
|
|
523
|
+
colors = np.random.randint(0, 255, (1000, 3))
|
|
524
|
+
openrunner.log({"cloud": openrunner.PointCloud3D(pts, colors=colors)})
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
def __init__(
|
|
528
|
+
self,
|
|
529
|
+
points: Any,
|
|
530
|
+
colors: Any | None = None,
|
|
531
|
+
labels: list[str] | None = None,
|
|
532
|
+
caption: str | None = None,
|
|
533
|
+
) -> None:
|
|
534
|
+
self._points = points
|
|
535
|
+
self._colors = colors
|
|
536
|
+
self._labels = labels
|
|
537
|
+
self.caption = caption
|
|
538
|
+
|
|
539
|
+
def _serialize(self) -> dict[str, Any]:
|
|
540
|
+
"""Serialize point cloud data to dict for JSON transmission.
|
|
541
|
+
|
|
542
|
+
Returns a dict with:
|
|
543
|
+
- ``points``: list of [x, y, z] lists
|
|
544
|
+
- ``colors``: list of [r, g, b] lists (or None)
|
|
545
|
+
- ``labels``: list of strings (or None)
|
|
546
|
+
- ``num_points``: total point count
|
|
547
|
+
- ``bounds``: {min: [x,y,z], max: [x,y,z]}
|
|
548
|
+
"""
|
|
549
|
+
import numpy as np
|
|
550
|
+
|
|
551
|
+
pts = np.asarray(self._points, dtype=np.float64)
|
|
552
|
+
if pts.ndim != 2 or pts.shape[1] != 3:
|
|
553
|
+
raise ValueError(
|
|
554
|
+
f"PointCloud3D: expected array of shape (N, 3), "
|
|
555
|
+
f"got {pts.shape}"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
result: dict[str, Any] = {
|
|
559
|
+
"points": pts.tolist(),
|
|
560
|
+
"num_points": int(pts.shape[0]),
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
# Bounds for frontend camera setup
|
|
564
|
+
if pts.shape[0] > 0:
|
|
565
|
+
result["bounds"] = {
|
|
566
|
+
"min": pts.min(axis=0).tolist(),
|
|
567
|
+
"max": pts.max(axis=0).tolist(),
|
|
568
|
+
}
|
|
569
|
+
else:
|
|
570
|
+
result["bounds"] = {"min": [0, 0, 0], "max": [0, 0, 0]}
|
|
571
|
+
|
|
572
|
+
# Colors
|
|
573
|
+
if self._colors is not None:
|
|
574
|
+
clr = np.asarray(self._colors)
|
|
575
|
+
if clr.shape != pts.shape:
|
|
576
|
+
raise ValueError(
|
|
577
|
+
f"PointCloud3D: colors shape {clr.shape} does not match "
|
|
578
|
+
f"points shape {pts.shape}"
|
|
579
|
+
)
|
|
580
|
+
# Normalize float colors (0-1) to int (0-255)
|
|
581
|
+
if clr.dtype.kind == "f":
|
|
582
|
+
clr = (clr.clip(0.0, 1.0) * 255).astype(np.uint8)
|
|
583
|
+
result["colors"] = clr.tolist()
|
|
584
|
+
else:
|
|
585
|
+
result["colors"] = None
|
|
586
|
+
|
|
587
|
+
# Labels
|
|
588
|
+
if self._labels is not None:
|
|
589
|
+
if len(self._labels) != pts.shape[0]:
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"PointCloud3D: labels length {len(self._labels)} does not "
|
|
592
|
+
f"match points count {pts.shape[0]}"
|
|
593
|
+
)
|
|
594
|
+
result["labels"] = list(self._labels)
|
|
595
|
+
else:
|
|
596
|
+
result["labels"] = None
|
|
597
|
+
|
|
598
|
+
return result
|
|
599
|
+
|
|
600
|
+
|
|
424
601
|
class BoundingBoxes2D:
|
|
425
602
|
"""Bounding box annotations overlaid on an image.
|
|
426
603
|
|
|
@@ -23,7 +23,18 @@ from openrunner.buffer import WriteBuffer
|
|
|
23
23
|
from openrunner.cache import ArtifactCache
|
|
24
24
|
from openrunner.config import Config
|
|
25
25
|
from openrunner.git_info import capture_git_info
|
|
26
|
-
from openrunner.media import
|
|
26
|
+
from openrunner.media import (
|
|
27
|
+
Audio,
|
|
28
|
+
BoundingBoxes2D,
|
|
29
|
+
Histogram,
|
|
30
|
+
Image,
|
|
31
|
+
MatplotlibFigure,
|
|
32
|
+
Plotly,
|
|
33
|
+
PlotlyChart,
|
|
34
|
+
PointCloud3D,
|
|
35
|
+
Table,
|
|
36
|
+
Video,
|
|
37
|
+
)
|
|
27
38
|
from openrunner.offline import OfflineStorage
|
|
28
39
|
from openrunner.sender import Sender
|
|
29
40
|
from openrunner.settings import SDKSettings
|
|
@@ -267,6 +278,16 @@ class Run:
|
|
|
267
278
|
"caption": value.caption,
|
|
268
279
|
"_histogram_data": value._serialize(),
|
|
269
280
|
})
|
|
281
|
+
elif isinstance(value, PlotlyChart):
|
|
282
|
+
# Enhanced Plotly: JSON + optional static PNG (inline JSONB)
|
|
283
|
+
self._buffer.put({
|
|
284
|
+
"_media": True,
|
|
285
|
+
"_media_type": "plotly",
|
|
286
|
+
"key": key,
|
|
287
|
+
"step": current_step,
|
|
288
|
+
"caption": value.caption,
|
|
289
|
+
"_plotly_data": value._serialize(),
|
|
290
|
+
})
|
|
270
291
|
elif isinstance(value, Plotly):
|
|
271
292
|
# Queue plotly figure (inline JSONB, no S3)
|
|
272
293
|
self._buffer.put({
|
|
@@ -277,6 +298,26 @@ class Run:
|
|
|
277
298
|
"caption": value.caption,
|
|
278
299
|
"_plotly_data": value._serialize(),
|
|
279
300
|
})
|
|
301
|
+
elif isinstance(value, MatplotlibFigure):
|
|
302
|
+
# Capture matplotlib figure as PNG image
|
|
303
|
+
self._buffer.put({
|
|
304
|
+
"_media": True,
|
|
305
|
+
"_media_type": "image",
|
|
306
|
+
"key": key,
|
|
307
|
+
"step": current_step,
|
|
308
|
+
"caption": value.caption,
|
|
309
|
+
"_image_obj": value,
|
|
310
|
+
})
|
|
311
|
+
elif isinstance(value, PointCloud3D):
|
|
312
|
+
# Queue point cloud data (inline JSONB, no S3)
|
|
313
|
+
self._buffer.put({
|
|
314
|
+
"_media": True,
|
|
315
|
+
"_media_type": "point_cloud_3d",
|
|
316
|
+
"key": key,
|
|
317
|
+
"step": current_step,
|
|
318
|
+
"caption": value.caption,
|
|
319
|
+
"_point_cloud_data": value._serialize(),
|
|
320
|
+
})
|
|
280
321
|
elif isinstance(value, BoundingBoxes2D):
|
|
281
322
|
# Queue bbox overlay (image bytes + JSON box data)
|
|
282
323
|
self._buffer.put({
|
|
@@ -252,6 +252,17 @@ class Sender:
|
|
|
252
252
|
data=item.get("_plotly_data"),
|
|
253
253
|
)
|
|
254
254
|
|
|
255
|
+
elif media_type == "point_cloud_3d":
|
|
256
|
+
# Point cloud data is already serialized -- send inline as JSONB
|
|
257
|
+
self._client.create_media_file(
|
|
258
|
+
run_id=self._run_id,
|
|
259
|
+
key=item["key"],
|
|
260
|
+
media_type="point_cloud_3d",
|
|
261
|
+
step=item.get("step"),
|
|
262
|
+
caption=item.get("caption"),
|
|
263
|
+
data=item.get("_point_cloud_data"),
|
|
264
|
+
)
|
|
265
|
+
|
|
255
266
|
elif media_type == "bounding_boxes_2d":
|
|
256
267
|
# BBox: upload the image first, then store box data as JSONB
|
|
257
268
|
bbox_obj = item["_bbox_obj"]
|
|
@@ -15,7 +15,10 @@ from openrunner.media import (
|
|
|
15
15
|
BoundingBoxes2D,
|
|
16
16
|
Histogram,
|
|
17
17
|
Image,
|
|
18
|
+
MatplotlibFigure,
|
|
18
19
|
Plotly,
|
|
20
|
+
PlotlyChart,
|
|
21
|
+
PointCloud3D,
|
|
19
22
|
Table,
|
|
20
23
|
Video,
|
|
21
24
|
)
|
|
@@ -459,3 +462,267 @@ class TestBoundingBoxes2D:
|
|
|
459
462
|
bbox = BoundingBoxes2D(img, [])
|
|
460
463
|
box_data = bbox._serialize_boxes()
|
|
461
464
|
assert box_data["boxes"] == []
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# MatplotlibFigure tests
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
class TestMatplotlibFigure:
|
|
472
|
+
"""Tests for the MatplotlibFigure class."""
|
|
473
|
+
|
|
474
|
+
def test_matplotlib_explicit_figure(self) -> None:
|
|
475
|
+
"""MatplotlibFigure from explicit figure serializes to PNG bytes."""
|
|
476
|
+
import matplotlib
|
|
477
|
+
matplotlib.use("Agg") # non-interactive backend
|
|
478
|
+
import matplotlib.pyplot as plt
|
|
479
|
+
|
|
480
|
+
fig, ax = plt.subplots()
|
|
481
|
+
ax.plot([1, 2, 3], [4, 5, 6])
|
|
482
|
+
mf = MatplotlibFigure(fig)
|
|
483
|
+
data, content_type = mf._serialize()
|
|
484
|
+
assert _is_png(data)
|
|
485
|
+
assert content_type == "image/png"
|
|
486
|
+
assert len(data) > 100 # non-trivial PNG
|
|
487
|
+
plt.close(fig)
|
|
488
|
+
|
|
489
|
+
def test_matplotlib_gcf(self) -> None:
|
|
490
|
+
"""MatplotlibFigure with no fig arg captures gcf()."""
|
|
491
|
+
import matplotlib
|
|
492
|
+
matplotlib.use("Agg")
|
|
493
|
+
import matplotlib.pyplot as plt
|
|
494
|
+
|
|
495
|
+
plt.figure()
|
|
496
|
+
plt.plot([0, 1], [0, 1])
|
|
497
|
+
mf = MatplotlibFigure()
|
|
498
|
+
data, content_type = mf._serialize()
|
|
499
|
+
assert _is_png(data)
|
|
500
|
+
assert content_type == "image/png"
|
|
501
|
+
plt.close("all")
|
|
502
|
+
|
|
503
|
+
def test_matplotlib_caption(self) -> None:
|
|
504
|
+
"""Caption attribute is stored correctly."""
|
|
505
|
+
mf = MatplotlibFigure(caption="loss curve")
|
|
506
|
+
assert mf.caption == "loss curve"
|
|
507
|
+
|
|
508
|
+
def test_matplotlib_custom_dpi(self) -> None:
|
|
509
|
+
"""Custom DPI produces larger PNG than low DPI."""
|
|
510
|
+
import matplotlib
|
|
511
|
+
matplotlib.use("Agg")
|
|
512
|
+
import matplotlib.pyplot as plt
|
|
513
|
+
|
|
514
|
+
fig, ax = plt.subplots()
|
|
515
|
+
ax.plot([1, 2, 3], [4, 5, 6])
|
|
516
|
+
|
|
517
|
+
mf_low = MatplotlibFigure(fig, dpi=50)
|
|
518
|
+
data_low, _ = mf_low._serialize()
|
|
519
|
+
|
|
520
|
+
mf_high = MatplotlibFigure(fig, dpi=300)
|
|
521
|
+
data_high, _ = mf_high._serialize()
|
|
522
|
+
|
|
523
|
+
assert len(data_high) > len(data_low)
|
|
524
|
+
plt.close(fig)
|
|
525
|
+
|
|
526
|
+
def test_matplotlib_roundtrip(self) -> None:
|
|
527
|
+
"""PNG output is a valid image loadable by PIL."""
|
|
528
|
+
import matplotlib
|
|
529
|
+
matplotlib.use("Agg")
|
|
530
|
+
import matplotlib.pyplot as plt
|
|
531
|
+
|
|
532
|
+
fig, ax = plt.subplots(figsize=(4, 3))
|
|
533
|
+
ax.bar([1, 2, 3], [10, 20, 15])
|
|
534
|
+
mf = MatplotlibFigure(fig)
|
|
535
|
+
data, _ = mf._serialize()
|
|
536
|
+
|
|
537
|
+
pil = PILImage.open(io.BytesIO(data))
|
|
538
|
+
assert pil.size[0] > 0
|
|
539
|
+
assert pil.size[1] > 0
|
|
540
|
+
plt.close(fig)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# PlotlyChart tests
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
class TestPlotlyChart:
|
|
548
|
+
"""Tests for the PlotlyChart class (enhanced Plotly)."""
|
|
549
|
+
|
|
550
|
+
def test_plotly_chart_from_dict(self) -> None:
|
|
551
|
+
"""PlotlyChart from dict returns data/layout with _static_image."""
|
|
552
|
+
fig = {
|
|
553
|
+
"data": [{"type": "scatter", "x": [1, 2], "y": [3, 4]}],
|
|
554
|
+
"layout": {"title": "Test"},
|
|
555
|
+
}
|
|
556
|
+
pc = PlotlyChart(fig)
|
|
557
|
+
result = pc._serialize()
|
|
558
|
+
assert result["data"] == fig["data"]
|
|
559
|
+
assert result["layout"] == fig["layout"]
|
|
560
|
+
# Dict input has no to_image method, so static_image should be None
|
|
561
|
+
assert result["_static_image"] is None
|
|
562
|
+
|
|
563
|
+
def test_plotly_chart_from_object_with_to_json(self) -> None:
|
|
564
|
+
"""PlotlyChart from object with to_json() serializes correctly."""
|
|
565
|
+
|
|
566
|
+
class FakeFigure:
|
|
567
|
+
def to_json(self):
|
|
568
|
+
return '{"data": [{"type": "bar"}], "layout": {}}'
|
|
569
|
+
|
|
570
|
+
pc = PlotlyChart(FakeFigure())
|
|
571
|
+
result = pc._serialize()
|
|
572
|
+
assert result["data"] == [{"type": "bar"}]
|
|
573
|
+
assert "_static_image" in result
|
|
574
|
+
|
|
575
|
+
def test_plotly_chart_from_object_with_to_dict(self) -> None:
|
|
576
|
+
"""PlotlyChart from object with to_dict() serializes correctly."""
|
|
577
|
+
|
|
578
|
+
class FakeFigure:
|
|
579
|
+
def to_dict(self):
|
|
580
|
+
return {"data": [{"type": "pie"}], "layout": {"title": "Pie"}}
|
|
581
|
+
|
|
582
|
+
pc = PlotlyChart(FakeFigure())
|
|
583
|
+
result = pc._serialize()
|
|
584
|
+
assert result["data"][0]["type"] == "pie"
|
|
585
|
+
|
|
586
|
+
def test_plotly_chart_caption(self) -> None:
|
|
587
|
+
"""Caption attribute is stored."""
|
|
588
|
+
pc = PlotlyChart({}, caption="interactive chart")
|
|
589
|
+
assert pc.caption == "interactive chart"
|
|
590
|
+
|
|
591
|
+
def test_plotly_chart_static_image_with_to_image(self) -> None:
|
|
592
|
+
"""When to_image() is available, _static_image is populated."""
|
|
593
|
+
import base64
|
|
594
|
+
|
|
595
|
+
class FakeFigure:
|
|
596
|
+
def to_json(self):
|
|
597
|
+
return '{"data": [], "layout": {}}'
|
|
598
|
+
|
|
599
|
+
def to_image(self, format="png", width=800, height=500):
|
|
600
|
+
# Return fake PNG bytes
|
|
601
|
+
return b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
|
|
602
|
+
|
|
603
|
+
pc = PlotlyChart(FakeFigure())
|
|
604
|
+
result = pc._serialize()
|
|
605
|
+
assert result["_static_image"] is not None
|
|
606
|
+
# Verify it's valid base64
|
|
607
|
+
decoded = base64.b64decode(result["_static_image"])
|
|
608
|
+
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
|
609
|
+
|
|
610
|
+
def test_plotly_chart_unsupported_type(self) -> None:
|
|
611
|
+
"""Unsupported type raises TypeError."""
|
|
612
|
+
pc = PlotlyChart(42)
|
|
613
|
+
try:
|
|
614
|
+
pc._serialize()
|
|
615
|
+
assert False, "Should have raised"
|
|
616
|
+
except TypeError as e:
|
|
617
|
+
assert "int" in str(e)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# PointCloud3D tests
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
class TestPointCloud3D:
|
|
625
|
+
"""Tests for the PointCloud3D class."""
|
|
626
|
+
|
|
627
|
+
def test_point_cloud_basic(self) -> None:
|
|
628
|
+
"""PointCloud3D with xyz points serializes correctly."""
|
|
629
|
+
pts = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
|
630
|
+
pc = PointCloud3D(pts)
|
|
631
|
+
result = pc._serialize()
|
|
632
|
+
|
|
633
|
+
assert result["num_points"] == 2
|
|
634
|
+
assert len(result["points"]) == 2
|
|
635
|
+
assert result["points"][0] == [1.0, 2.0, 3.0]
|
|
636
|
+
assert result["points"][1] == [4.0, 5.0, 6.0]
|
|
637
|
+
assert result["colors"] is None
|
|
638
|
+
assert result["labels"] is None
|
|
639
|
+
assert result["bounds"]["min"] == [1.0, 2.0, 3.0]
|
|
640
|
+
assert result["bounds"]["max"] == [4.0, 5.0, 6.0]
|
|
641
|
+
|
|
642
|
+
def test_point_cloud_with_colors(self) -> None:
|
|
643
|
+
"""PointCloud3D with per-point colors serializes correctly."""
|
|
644
|
+
pts = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
|
645
|
+
colors = np.array([[255, 0, 0], [0, 255, 0]], dtype=np.uint8)
|
|
646
|
+
pc = PointCloud3D(pts, colors=colors)
|
|
647
|
+
result = pc._serialize()
|
|
648
|
+
|
|
649
|
+
assert result["colors"] is not None
|
|
650
|
+
assert result["colors"][0] == [255, 0, 0]
|
|
651
|
+
assert result["colors"][1] == [0, 255, 0]
|
|
652
|
+
|
|
653
|
+
def test_point_cloud_float_colors(self) -> None:
|
|
654
|
+
"""Float colors (0-1) are normalized to int (0-255)."""
|
|
655
|
+
pts = np.array([[0.0, 0.0, 0.0]])
|
|
656
|
+
colors = np.array([[1.0, 0.5, 0.0]])
|
|
657
|
+
pc = PointCloud3D(pts, colors=colors)
|
|
658
|
+
result = pc._serialize()
|
|
659
|
+
|
|
660
|
+
assert result["colors"][0] == [255, 127, 0] or result["colors"][0] == [255, 128, 0]
|
|
661
|
+
|
|
662
|
+
def test_point_cloud_with_labels(self) -> None:
|
|
663
|
+
"""PointCloud3D with per-point labels serializes correctly."""
|
|
664
|
+
pts = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
|
665
|
+
labels = ["origin", "corner"]
|
|
666
|
+
pc = PointCloud3D(pts, labels=labels)
|
|
667
|
+
result = pc._serialize()
|
|
668
|
+
|
|
669
|
+
assert result["labels"] == ["origin", "corner"]
|
|
670
|
+
|
|
671
|
+
def test_point_cloud_caption(self) -> None:
|
|
672
|
+
"""Caption attribute is stored correctly."""
|
|
673
|
+
pts = np.zeros((1, 3))
|
|
674
|
+
pc = PointCloud3D(pts, caption="lidar scan")
|
|
675
|
+
assert pc.caption == "lidar scan"
|
|
676
|
+
|
|
677
|
+
def test_point_cloud_wrong_shape(self) -> None:
|
|
678
|
+
"""Non-(N,3) array raises ValueError."""
|
|
679
|
+
pts = np.zeros((5, 2))
|
|
680
|
+
pc = PointCloud3D(pts)
|
|
681
|
+
try:
|
|
682
|
+
pc._serialize()
|
|
683
|
+
assert False, "Should have raised"
|
|
684
|
+
except ValueError as e:
|
|
685
|
+
assert "(N, 3)" in str(e)
|
|
686
|
+
|
|
687
|
+
def test_point_cloud_color_shape_mismatch(self) -> None:
|
|
688
|
+
"""Mismatched colors shape raises ValueError."""
|
|
689
|
+
pts = np.zeros((3, 3))
|
|
690
|
+
colors = np.zeros((2, 3))
|
|
691
|
+
pc = PointCloud3D(pts, colors=colors)
|
|
692
|
+
try:
|
|
693
|
+
pc._serialize()
|
|
694
|
+
assert False, "Should have raised"
|
|
695
|
+
except ValueError as e:
|
|
696
|
+
assert "colors shape" in str(e)
|
|
697
|
+
|
|
698
|
+
def test_point_cloud_label_length_mismatch(self) -> None:
|
|
699
|
+
"""Mismatched labels length raises ValueError."""
|
|
700
|
+
pts = np.zeros((3, 3))
|
|
701
|
+
pc = PointCloud3D(pts, labels=["a", "b"])
|
|
702
|
+
try:
|
|
703
|
+
pc._serialize()
|
|
704
|
+
assert False, "Should have raised"
|
|
705
|
+
except ValueError as e:
|
|
706
|
+
assert "labels length" in str(e)
|
|
707
|
+
|
|
708
|
+
def test_point_cloud_empty(self) -> None:
|
|
709
|
+
"""Empty point cloud (0 points) serializes correctly."""
|
|
710
|
+
pts = np.zeros((0, 3))
|
|
711
|
+
pc = PointCloud3D(pts)
|
|
712
|
+
result = pc._serialize()
|
|
713
|
+
assert result["num_points"] == 0
|
|
714
|
+
assert result["points"] == []
|
|
715
|
+
assert result["bounds"]["min"] == [0, 0, 0]
|
|
716
|
+
assert result["bounds"]["max"] == [0, 0, 0]
|
|
717
|
+
|
|
718
|
+
def test_point_cloud_large(self) -> None:
|
|
719
|
+
"""Large point cloud (1000 points) serializes without error."""
|
|
720
|
+
pts = np.random.randn(1000, 3)
|
|
721
|
+
colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
|
|
722
|
+
labels = [f"pt_{i}" for i in range(1000)]
|
|
723
|
+
pc = PointCloud3D(pts, colors=colors, labels=labels)
|
|
724
|
+
result = pc._serialize()
|
|
725
|
+
assert result["num_points"] == 1000
|
|
726
|
+
assert len(result["points"]) == 1000
|
|
727
|
+
assert len(result["colors"]) == 1000
|
|
728
|
+
assert len(result["labels"]) == 1000
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|