Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
49d6b0d
feat: add `blueye bundle-model` CLI for creating CV model packages
jp-pino Jul 8, 2026
1f6a3bc
feat: default bundled packages to autolaunch on the drone
jp-pino Jul 8, 2026
18b38e5
fix: widen pyzmq to <28 so Python 3.14 installs a prebuilt wheel
jp-pino Jul 8, 2026
a0cd273
feat: extensible CLI scaffolding with third-party tool discovery
jp-pino Jul 9, 2026
9171cb7
fix: address review feedback on bundle-model input handling
jp-pino Jul 9, 2026
64571bb
feat: drone.cv_models SDK feature, --push in bundle-model, and `bluey…
jp-pino Jul 10, 2026
88695a4
feat: add `blueye logs` command for listing and downloading dive logs
jp-pino Jul 10, 2026
c34cc57
feat: add --mcap conversion to `blueye logs download`
jp-pino Jul 10, 2026
f748cd2
refactor: address review — CLI in core deps, single docs page, curl e…
jp-pino Jul 10, 2026
58dbee4
Merge branch 'jp-pino/bundle-model-cli' into jp-pino/logs-cli
jp-pino Jul 10, 2026
3ddfab8
docs: fold `blueye logs` into the CLI page, add quick-start example
jp-pino Jul 10, 2026
0ec14ad
docs: name the Blueye X3 Ultra as the CV-capable model
jp-pino Jul 10, 2026
379069f
Merge branch 'jp-pino/bundle-model-cli' into jp-pino/logs-cli
jp-pino Jul 10, 2026
5829fd2
feat: local log conversion, filterable interactive view, retire mcap …
jp-pino Jul 10, 2026
e3c2bd6
fix: scope checkbox toggle-all/invert to the filtered rows
jp-pino Jul 10, 2026
bb7e5fb
fix: construct the rebinding test prompt headlessly for Windows CI
jp-pino Jul 10, 2026
44cdcad
fix: reject combined download selectors and non-positive --latest
jp-pino Jul 10, 2026
7b6fa78
feat: add version/description/author/license to bundle-model metadata
follesoe Jul 22, 2026
92119dc
fix: address Copilot review round two
follesoe Jul 22, 2026
d884faa
Merge branch 'jp-pino/bundle-model-cli' into jp-pino/logs-cli
jp-pino Jul 27, 2026
2a37bc6
Merge pull request #220 from BluEye-Robotics/jp-pino/logs-cli
johannesschrimpf Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions blueye/sdk/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Command line interface for the Blueye SDK.

This package provides the `blueye` console script. It is deliberately kept out of
`blueye.sdk.__init__` so the SDK itself imports without the optional `[cli]` extra
installed. Only the standard library may be imported at module level here — the CLI
must be able to start (and print dependency guidance) when the extra is missing.
"""

from .main import main

__all__ = ["main"]
111 changes: 111 additions & 0 deletions blueye/sdk/cli/bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Zip writing for the `blueye bundle-model` CLI.

Stdlib-only. The bundle is a flat zip: ``model.onnx``, any external weight files under
their exact embedded names, and ``model_meta.json`` — all at the archive root, matching
how BlueyeCV packages are unpacked onto the drone (``unzip -d <package_dir>``).
"""

from __future__ import annotations

import json
import logging
import zipfile
from pathlib import Path
from typing import Callable

logger = logging.getLogger(__name__)

#: Name the model file always gets inside the bundle (the reference-package convention).
MODEL_FILE_NAME = "model.onnx"
META_FILE_NAME = "model_meta.json"

_CHUNK_SIZE = 4 * 1024 * 1024


class BundleError(Exception):
"""A user-facing bundling problem (missing files, name collisions, ...)."""


def _copy_into_zip(
archive: zipfile.ZipFile,
source: Path,
arcname: str,
progress: Callable[[int], None],
) -> None:
"""Stream one file into the archive in chunks, reporting bytes written."""
info = zipfile.ZipInfo.from_file(source, arcname)
info.compress_type = zipfile.ZIP_DEFLATED # from_file defaults to ZIP_STORED
with source.open("rb") as reader, archive.open(info, "w") as writer:
while True:
chunk = reader.read(_CHUNK_SIZE)
if not chunk:
break
writer.write(chunk)
progress(len(chunk))


def bundle_size(onnx_path: Path, external_files: list[str]) -> int:
"""Total input size in bytes (for progress reporting).

Args:
onnx_path: Path to the .onnx file.
external_files: External-data file names living next to the .onnx.
"""
total = onnx_path.stat().st_size
for name in external_files:
total += (onnx_path.parent / name).stat().st_size
return total


def write_bundle(
meta: dict,
onnx_path: Path,
external_files: list[str],
output_path: Path,
progress: Callable[[int], None] | None = None,
) -> None:
"""Write the model package zip.

The archive is written to ``<output>.part`` and atomically renamed on success, so
an interrupted run never leaves a truncated zip behind.

Args:
meta: The validated model_meta dict.
onnx_path: The source .onnx file (stored as ``model.onnx``).
external_files: External-data file names (must exist beside the .onnx; stored
under their exact names because the .onnx references them by name).
output_path: Destination zip path.
progress: Optional callback receiving the number of bytes just written.

Raises:
BundleError: When an external file is missing or collides with a reserved name.
"""
progress = progress or (lambda _byte_count: None)

for name in external_files:
if "/" in name or "\\" in name:
raise BundleError(
f"External data location '{name}' contains a path separator. Re-save the "
"model with all tensors in one file next to it, e.g.:\n"
" onnx.save(onnx.load(p), out, save_as_external_data=True,\n"
" all_tensors_to_one_file=True, location='model.onnx_data')"
)
if name in (MODEL_FILE_NAME, META_FILE_NAME):
raise BundleError(f"External data file '{name}' collides with a reserved bundle name.")
if not (onnx_path.parent / name).is_file():
raise BundleError(
f"The model references external data file '{name}', but it was not found "
f"next to {onnx_path.name}. Copy it into {onnx_path.parent} first."
)

output_path.parent.mkdir(parents=True, exist_ok=True)
partial_path = output_path.with_suffix(output_path.suffix + ".part")
try:
with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
_copy_into_zip(archive, onnx_path, MODEL_FILE_NAME, progress)
for name in external_files:
_copy_into_zip(archive, onnx_path.parent / name, name, progress)
archive.writestr(META_FILE_NAME, json.dumps(meta, indent=2, ensure_ascii=False) + "\n")
partial_path.replace(output_path)
finally:
partial_path.unlink(missing_ok=True)
Loading
Loading