Skip to content

v0.33.0

Choose a tag to compare

@github-actions github-actions released this 01 Oct 03:08
· 171 commits to main since this release
f008ce6

Added

  • Multi-label decode on every NMS decode path (edgefirst-decoder). DecoderBuilder::with_multi_label(true) now applies to split, quantized, 2-way, detection-only and ModelPack detection models, for both decode and decode_proto, not only fused float and per-scale models. With NMS disabled, multi-label no longer turns NMS back on. Quantized multi-label candidate selection reads the transposed score layout of a DMA-BUF output one contiguous class column at a time.

  • Decoder::decode_for_tracking decodes one label per box whatever the decoder's multi-label setting, for callers that feed an external tracker. The Python Decoder.decode_tracked uses it.

  • multi_label in the Python decoder and the C API. Every Decoder constructor takes multi_label=, and Decoder.multi_label reports the setting. C callers set it with ef_decoder_params_set_multi_label and read it back with ef_decoder_multi_label, which also reflects a model that declares it in metadata.

  • Qualcomm Adreno on Linux (proprietary KGSL driver, validated on IQ-9075 / Adreno 663). The GL backend detects Adreno from GL_RENDERER/EGL_VENDOR and applies these fallbacks automatically. None of them apply on Android.

    • Uses the GBM EGL display; the EGL device-platform display crashes in eglQueryString on this driver.
    • Serializes GL across processors, and guards tensor mmaps against eglDestroyImage, which unmaps its driver mapping twice. The C API exposes this guard as ef_tensor_with_cpu_mappings_excluded, so a consumer linking libedgefirst_tensor shares the library's single guard.
    • Declines DMA-BUF imports at plane offsets that are not 64-byte aligned (sources and destinations), and semi-planar sources whose chroma is a separate DMA-BUF.
    • Uploads NV planes into a fresh texture rather than one that held an EGLImage.
    • Creates single-channel render targets with the sized GL_R8 format.
    • Reports no GL float render support on a zero-copy backend, so float output goes to the CPU.
  • On-demand hardware tests and benchmarks across the board fleet (hardware-test.yml, hardware-bench.yml, both workflow_dispatch). One job per board entry: a runner label, or labels joined by + that one runner must all carry (imx95-frdm+ara240). The default is one board per SoC, imx8mp-evk, imx95-evk, rpi5, orin-nano, iq9075-evk. The tests workflow runs the whole on-target suite plus the C-API gates, where the CI hardware lane runs a selection; the bench workflow runs every benchmark case and summarises medians across boards, with a CSV of every median. Each summary names the runner that ran each entry. With the FLEET_RUNNERS_TOKEN organisation secret, an entry no online runner carries is reported as unavailable instead of queuing for 24 hours.

  • scripts/on-target-bundle.sh builds the self-contained bundle a board runs (tests: binaries, C-API libraries, G3 and gate sources; bench: benchmark binaries; both with merged testdata), and scripts/on-target-run.sh and scripts/on-target-bench.sh run it on the board. The fleet workflows and scripts/on-target-test.sh use the same bundle and scripts.

Fixed

  • YUYV and NV12 sources in a PBO were converted on the CPU on a Jetson. Without DMA-BUF import, create_image backs every source with a PBO, and the GL backend refused YUYV and NV12 from one ("PBO upload not supported", "NV R8 upload cannot map a PBO source", "Opengl doesn't support YUYV source texture"). Those are the formats cameras deliver. Where the platform has no zero-copy import, a PBO or host-memory YUYV/NV12 source now reaches the same shaders as an imported one on every route (packed, packed RGB, planar, and their int8 variants), copied on the GPU from the buffer rather than through a CPU map: 4-12x faster than the CPU converter on Orin Nano. Where the platform has zero-copy import, such sources stay on the CPU, which beats the upload there (Mesa V3D's PBO upload runs well behind it).

  • A 4K frame on a Raspberry Pi 5 lost DMA-BUF. Its CMA pool is 64 MB, and a DMA-BUF allocation that CMA could not fit failed rather than trying the system heap, so create_image fell back to a PBO. DMA-BUF allocation now takes the system heap when the CMA heap is absent or full. System-heap memory is not physically contiguous; a consumer that cannot import it declines it as it declines any other import.

  • A PBO from another processor was bound by name in the wrong GL context. GL buffer names belong to a context, and each processor has its own. Converting from or into a PBO that another processor allocated, or one wrapped with ef_tensor_wrap_pbo, read or wrote whichever buffer had that name in this context, or failed with a GL error. Such a PBO is now read and written through its own map, which the owning processor serves.

  • A planar GL convert after a packed-RGB one wrote wrong second and third planes on Vivante. The packed-RGB route left its intermediate texture bound to texture unit 1, and the planar route, which samples the same texture on unit 0 through a per-plane swizzle, then produced wrong G and B planes (off by up to 173) for every later planar convert on that processor. The packed-RGB route now unbinds it.

  • The float path's upload from host memory ignored a padded source pitch and sheared every row after the first.

  • max_det and pre_nms_top_k were ignored on some decode paths. Flat and split YOLO detection models capped Decoder::decode output at the output Vec's capacity (300 for Vec::new()) instead of max_det, and never applied pre_nms_top_k. YOLO26 end-to-end models also capped at the Vec capacity, and ModelPack detection never applied pre_nms_top_k. Every path now uses the decoder's max_det and pre_nms_top_k; the output Vec's capacity is only an allocation hint, as documented. The pre_nms_top_k half of this changes detection-only output; see Changed.

  • With NMS bypassed, max_det kept the first anchors, not the best boxes (edgefirst-decoder). nms = None on YOLO detection and ModelPack models truncated to max_det in anchor order. It now keeps the max_det highest-scoring boxes and returns them in descending score order, as the NMS paths and the segmentation paths already did.

  • Equal scores at the pre_nms_top_k cut were chosen arbitrarily. The cut now keeps the lower anchor index among equal scores, so the candidates that reach NMS are reproducible against a reference decoder.

  • ef_decoder_params_new defaulted max_det to 100 (libedgefirst_decoder), not the library default of 300 it documents and the Rust builder and Python use. It is now 300; a C caller that relied on the old value gets up to 300 detections per frame, and ef_decoder_params_set_max_det(p, 100) keeps the old cap.

  • Quantized 2-way YOLO segmentation models could not be decoded. decode, decode_proto and decode_tracked passed the detection tensor to candidate selection without transposing it, tripping a shape assertion for any real model. decode also left the mask-coefficient tensor untransposed, so mask generation failed a shape check whenever the anchor count differed from the prototype count. Both tensors are now transposed as in the split quantized path.

  • A two-plane NV source whose GL import is refused now reports the import's reason instead of a misleading "draw_src_texture does not support Nv12".

  • ef_tensor_builder_wrap closed the caller's fd on some failures. It adopted the handle before building the tensor, so a handle from_fd refused, or a format the builder could not apply, was closed on the way out while the other failures left it open. The dynamic backend's TensorDyn::from_fd closes the fd itself whenever wrap fails, and aborted on the double close ("IO Safety violation: owned file descriptor already closed"). wrap now adopts the handle only when it returns a tensor; on any failure the handle stays open and remains the caller's.

  • GL convert from a host-memory source into a DMA planar destination failed on drivers that accept the destination import (Mali, V3D). The single-pass planar shader can only read a DMA source, so the convert ended with "OpenGL EGLImage requires DMA tensor, got Mem" and fell back to the CPU. A Mem/Shm source into a planar destination now takes the two-pass route, whose first pass uploads it. Vivante masked this: it refuses an unaligned destination import, and that refusal already re-planned the convert.

  • Blob import dropped per-channel zero points. An asymmetric per-channel quantized tensor exported with blob::export came back from blob::import as symmetric. Import now rebuilds Quantization::per_channel when zero points are present. Quantization the header declares but cannot be rebuilt (bad axis, mismatched counts) is an InvalidArgument error rather than a silently unquantized tensor.

  • Blob reference-mode import lost quantization on both the Unix and D3D11 paths.

  • Some valid blobs could not be parsed. The strings region was padded relative to its own start rather than the blob, so an export whose quantization arrays ended 4-byte aligned (e.g. per-tensor symmetric) failed BlobView::parse.

  • Padded planar tensors reported the wrong plane stride. For [C, H, W] with a row stride, the descriptor, blob and ef_tensor_strides now report [row_stride * H, row_stride, esz], agreeing with the plane table and the mapped layout. Previously strides[0] was the tight H * W * esz (descriptor, blob) or the row stride (C API). A padded batched [N, ...] image now steps N over one padded image, and batch(n) starts at that padded offset rather than at n tight images, which put the element inside the previous element's padding. A single image blob refuses a batched tensor instead of describing it as one image.

  • Python planar and semi-planar tensors with a padded pitch. The buffer protocol (memoryview, np.asarray, TensorMap.numpy()) padded only the outermost stride, so a planar [C, H, W] view started each plane inside the previous plane's row padding. from_numpy assumed one padded row per pixel of height and refused padded planar tensors, and semi-planar ones whose combined H*k rows exceed the height. Both now use the same strides as the descriptor, including a one-row image whose pitch exceeds its whole logical size. from_numpy refuses a pitch that is not a whole number of elements rather than truncating it.

  • Tensor::width()/height() on a batched [N, H, W, C] image returned H as the width, which gave batch() and strided maps the wrong pitch. They now describe one element of a batched tensor and return None for ranks that are not an image (previously a panic on a flattened formatted tensor). Strided maps and copy_to_flat of a batched packed tensor now cover all N * H rows.

  • configure_image could keep a stale row stride when reconfiguring to the same format, reporting a pitch below the new width's minimum.

  • A reference import whose producer pitch the format refuses is now an error instead of being ignored.

  • DRM ioctl request codes are built with nix's request_code_* macros instead of hand-rolled x86/ARM encoding.

Changed

  • scripts/on-target-test.sh runs one invocation per host at a time. Two runs against the same host shared its remote directory and its local results directory, so one could read back the other's results: a PASS with a fraction of the suite, as a filtered run's 31 tests were reported for another run's rpi5-hailo. Each host is now locked for the whole deploy, run and fetch, and a host another run holds is reported as BUSY with the holder's name instead.
  • hardware-bench.yml gives each board 120 minutes, up from 90: the i.MX 8M Plus (Cortex-A53) took 60 minutes for every case.
  • Class-aware NMS is the default (edgefirst-decoder). Nms::Auto resolves to ClassAware when the model config does not set nms, and Nms::default() is ClassAware, matching trainer validation (ModelPack, Ultralytics) and COCO evaluation. Overlapping objects of different classes (a person on a bicycle) are no longer suppressed against each other. A v1 config that says "nms": "auto" now takes this default instead of becoming class-agnostic. Configs that set nms: class_agnostic are unaffected. The C and Python ABI is unchanged but output changes: ef_decoder_params_set_nms(p, 1), the C default, and Nms.Auto, the Python default, now mean class-aware NMS for a config without nms, so deployed ModelPack models and v1 "nms": "auto" models return more boxes where objects of different classes overlap.
  • ModelPack detection honours the NMS mode. ModelPack detection models (flat and split) previously ran class-agnostic NMS regardless of the configured mode; they now use the decoder's resolved mode, and nms = None bypasses NMS as it does for YOLO. A ModelPack config with no nms key (older YAML/JSON configs) therefore now gets class-aware NMS; exports that declare "nms": "class_agnostic" keep class-agnostic NMS.
  • Multi-label decode can be declared in edgefirst.json. The optional top-level nms_multi_label key enables it for a model; with_multi_label, the Python multi_label argument and ef_decoder_params_set_multi_label override it. This replaces the 0.25.2 rule that multi-label was builder-only. Because a model file can now switch every untracked decode (including ef_decoder_decode and the Python decode) to validation decode, building a decoder whose multi-label comes from metadata logs a warning naming nms_multi_label. decode_tracked, decode_proto_tracked and the Python decode_tracked always decode one label per box and log a warning once per decoder when it has multi-label enabled, instead of failing a debug_assert.
  • pre_nms_top_k now caps detection-only models (edgefirst-decoder, C API, Python). Flat and split YOLO detection and ModelPack detection used to send every score-passing candidate to NMS; they now keep the top pre_nms_top_k by score first, as the segmentation paths did. With the default of 300 this drops candidates on any frame where more than 300 pass the score threshold, typically only at validation thresholds (~0.001). Set pre_nms_top_k to 0 (unbounded) for validation, or to at least the anchor count under argmax decode. Equal scores at the cut keep the lower anchor index.
  • Multi-label decode defaults pre_nms_top_k to 30 000 (edgefirst-decoder, C API, Python), Ultralytics' max_nms, when multi-label is on (from the API or nms_multi_label) and the cap was not set explicitly; multi-label emits up to anchors × classes candidates, which 300 would truncate. An explicit with_pre_nms_top_k, ef_decoder_params_set_pre_nms_top_k or decoder.pre_nms_top_k still wins, and 0 means unbounded everywhere. The tracking entry points (decode_tracked, decode_proto_tracked, decode_for_tracking, Python decode_tracked) decode argmax, so they keep the 300 default unless the cap was set explicitly. In Python, assigning decoder.pre_nms_top_k makes the cap explicit for every decode (even if it equals a default), reading it returns the cap decode uses, and assigning None restores the defaults. The new DEFAULT_PRE_NMS_TOP_K and MULTI_LABEL_PRE_NMS_TOP_K constants name both defaults. The C API no longer passes its own 300 to the builder, so it follows the same rule.
  • Multi-label decode honours an explicit class-agnostic NMS mode (edgefirst-decoder). Multi-label decode used to switch any enabled NMS to class-aware. It now runs the decoder's resolved mode over the multi-label candidates, as Ultralytics non_max_suppression(multi_label=True, agnostic=True) does: with_nms(Some(Nms::ClassAgnostic)), nms=Nms.ClassAgnostic, ef_decoder_params_set_nms(p, 3) or a config's "nms": "class_agnostic" now suppresses across the classes of one anchor. With no mode set, NMS resolves to class-aware and multi-label keeps every class, as before.
  • Breaking Rust API changes (edgefirst-decoder): SchemaV2 and ConfigOutputs gain a public nms_multi_label field and are now #[non_exhaustive], so code outside the crate can no longer build them with struct literals (even with ..Default::default()): start from SchemaV2::new() / ConfigOutputs::new(outputs) / Default::default() or a parser, then assign the public fields. Later optional fields (such as a tiling section) will then not be breaking; NmsMode::from_v1 returns Option<NmsMode> (None for Nms::Auto); Nms::default() is ClassAware; Decoder::pre_nms_top_k is now Option<usize>: None (the default) is resolved per decode by the new Decoder::pre_nms_top_k_for(multi_label) (30 000 for a multi-label decode, 300 for argmax, including tracking), and Some(n) is an explicit cap every decode uses as given (Some(0) unbounded), so code that read or assigned a usize must read pre_nms_top_k_for(decoder.multi_label()) or assign Some(n). Nms::Auto must be resolved by DecoderBuilder::build: assigning Some(Nms::Auto) to the public Decoder::nms field directly now trips a debug_assert! at decode in debug builds (release builds treat it as class-aware).
  • TensorTrait::capacity_bytes and TensorTrait::set_logical_shape no longer have default bodies; every implementor provides them.
  • edgefirst_tensor::protocol::c_byte_strides is public: the one stride convention shared by the descriptor, the blob and the C API.
  • scripts/on-target-test.sh deploys one bundle and runs it on the board. Its options and summary are unchanged; the summary now also gives each host's passed, failed and ignored test totals. It arms HAL_TEST_REQUIRE_DMA when the board's DMA heap allocates, as the CI board lane does, so a heap that should work and does not fails instead of skipping. REQUIRE_GPU=1 fails a host with no render node (NO-GPU) instead of letting every GPU test skip. A host without rsync gets the bundle by tar over ssh instead of failing to sync.
  • CI on-target lane runs on four boards: imx8mp-evk (Vivante), imx95-evk (Mali), rpi5 (V3D) and orin-nano (Tegra). It replaces the retired nxp-imx8mp-latest label, which no runner answers any more. The board archive is linked against glibc 2.35 so it loads on Ubuntu 22.04 (JetPack 6). Board jobs now fail when a board with a render node cannot bring up GL (HAL_TEST_REQUIRE_GL), as scripts/on-target-test.sh already did. Tests that need a zero-copy buffer treat its absence as a failure only where one must exist: under HAL_TEST_REQUIRE_DMA, or under HAL_TEST_REQUIRE_GL on a Linux host with a DMA heap. The Jetson has no heap, so they skip there.
  • make verify-version runs the check release.yml and publish.yml gate on (.github/scripts/verify_version.py) instead of a separate, weaker shell check. The script now also checks the standalone C API crates' version requirements on the internal crates they build from, and the internal crate versions in their own Cargo.lock. A 0.x requirement matches only its own minor series, so a "0.32.0" requirement left behind by a 0.33.0 bump makes the crate unresolvable, and nothing flagged it.
  • Dependencies updated. opencv moves from 0.99 to 0.101 (the optional opencv feature, used only by the opencv_benchmark comparison benchmark). Every lockfile, including the standalone C API crates', takes the latest semver-compatible releases.

Removed

  • benchmark.yml. It ran four benchmarks on the i.MX 8M Plus only and scraped their text output; hardware-bench.yml replaces it.