edgefirst-image
High-performance image processing for edge AI inference pipelines.
This crate provides hardware-accelerated image loading, format conversion, resizing, rotation, and cropping operations optimized for ML preprocessing workflows.
Role in edgefirst-hal
edgefirst-image sits at the centre of the EdgeFirst HAL workspace, owning
the GPU/G2D/CPU dispatch and segmentation-mask rendering. Its dependency
neighbours:
- Depends on
edgefirst-tensorforTensorDyn,BufferIdentity, and thePboOpstrait it implements for the GL backend. - Depends unconditionally on
edgefirst-decoderforDetectBox,Segmentation, and the proto-mask data feedingdraw_proto_masks(there is no opt-out feature flag). - Optionally depends on
edgefirst-tracker(featuretracker) fordraw_masks_tracked. - Re-exported from
edgefirst-halasedgefirst_hal::image. - Bridged to C via
edgefirst-hal-capi(cbindgen-generated C ABI). - Bridged to Python via
crates/python(PyO3 binding over the Rust umbrella crate; does not go through the C ABI).
Features
- Multiple backends — Automatic selection: OpenGL (GPU) → G2D (NXP i.MX) → CPU (fallback)
- Format conversion — RGB/RGBA/BGRA/GREY, planar RGB(A), semi-planar NV12/NV16/NV24, packed YUYV/VYUY
- Geometric transforms — Source crop, resize, letterbox, rotate (90° increments), flip
- Zero-copy integration — Works with
edgefirst-tensorDMA-BUF, IOSurface, AHardwareBuffer, PBO, and SHM buffers - Tiled preprocessing — SAHI grids for small-object detection in 4K frames
- JPEG/PNG support — Load and save with EXIF orientation handling
Quick Start
use ;
use ;
use ;
// Decode an image into its native format. The codec reports the source's
// native pixel format (JPEG -> NV12/GREY, PNG -> RGB/RGBA/GREY) and sizes,
// then configures the destination tensor during the decode.
let bytes = read?;
let info = peek_info?;
let mut decoder = new;
let mut src = image?;
src.load_image?;
let src = from;
// Create processor (auto-selects best backend)
let mut processor = new?;
// Create destination with desired size and format (the convert below
// handles NV12 -> RGBA colour conversion, resize, and letterboxing)
let mut dst =
processor.create_image?;
// Convert with resize, rotation, letterboxing
processor.convert?;
// Save result
save_jpeg?;
Backends
| Backend | Platform | Hardware | Notes |
|---|---|---|---|
| G2D | Linux (NXP i.MX 8M Plus / 8M Mini) | 2D blit engine | Fastest for NXP platforms; no mask rendering |
| OpenGL | Linux, macOS/iOS, Android | GPU | One engine everywhere behind the GlPlatform seam: EGL/GBM + DMA-BUF on Linux, ANGLE→Metal + IOSurface on Apple, native EGL + AHardwareBuffer on Android |
| CPU | All | SIMD (NEON / AVX2, rayon) | Portable fallback, always available |
Supported Formats
| Format | Description | Channels |
|---|---|---|
PixelFormat::Rgba |
32-bit RGBA | 4 |
PixelFormat::Rgb |
24-bit RGB | 3 |
PixelFormat::Nv12 |
YUV 4:2:0 semi-planar | 1.5 |
PixelFormat::Nv16 |
YUV 4:2:2 semi-planar | 2 |
PixelFormat::Nv24 |
YUV 4:4:4 semi-planar | 3 |
PixelFormat::Yuyv |
YUV 4:2:2 packed | 2 |
PixelFormat::Grey |
8-bit grayscale | 1 |
PixelFormat::PlanarRgb |
Planar RGB | 3 |
PixelFormat::Vyuy |
YUV 4:2:2 packed (VYUY order) | 2 |
PixelFormat::Bgra |
32-bit BGRA | 4 |
PixelFormat::PlanarRgba |
Planar RGBA | 4 |
Note: Int8 variants (e.g. packed RGB int8, planar RGB int8) use DType::I8 with the corresponding PixelFormat rather than separate format constants.
Feature Flags
opengl(default) — Enable the OpenGL backend.tracker— Enable multi-object tracking indraw_masks_tracked(). Requiresfeatures = ["tracker"]in your dependency declaration.opencv— Build the OpenCV comparison benchmark. Needs a system OpenCV install.dma_test_formats,g2d_test_formats— Test-only: unlock the zero-copy and G2D fixture tiers. See TESTING.md.
The decoder dependency is unconditional — there is no feature flag to opt out of detection-box and mask rendering.
Environment Variables
Backend selection:
EDGEFIRST_FORCE_BACKEND— Force a single backend:cpu,g2d, oropengl. Disables the fallback chain, and makes theEDGEFIRST_DISABLE_*variables inert.EDGEFIRST_DISABLE_GL/EDGEFIRST_DISABLE_G2D/EDGEFIRST_DISABLE_CPU— Set to1to drop that backend from the chain.
Memory and transfer:
EDGEFIRST_TENSOR_FORCE_MEM— Set to1to force heap memory (disables DMA/SHM).EDGEFIRST_FORCE_TRANSFER— Force the GPU transfer method:dmabuf,pbo, orsync.EDGEFIRST_EGL_CACHE_CAPACITY— Per-cache EGLImage capacity (default 64).EDGEFIRST_OPENGL_RENDERSURFACE— Set to1to use renderbuffer-backed EGLImages for DMA destinations. Required on i.MX 95 / Mali-G310 with Neutron NPU DMA-BUF destinations. Defaults to0(texture path).
Conversion behaviour:
EDGEFIRST_COLORIMETRY—fast(default) orexact.fastkeeps single-plane NV12 on the driver's YUV sampler even when its colorimetry does not match;exactforces the in-shader matrix. See ARCHITECTURE.md § Colorimetry.EDGEFIRST_NV_CONVERT_PATH—auto(default),sampler, orshader. Pins the NV12 GPU conversion path for A/B measurement.EDGEFIRST_GL_SERIALIZE—fullorlifecycle. Overrides the per-driver GL serialization policy.EDGEFIRST_PROTO_COMPUTE— Set to1to enable the experimental GLES 3.1 compute shader path for proto repack. Requires GLES 3.1 hardware.
Segmentation Mask Rendering
Three rendering pipelines for YOLO instance segmentation masks:
MaskOverlay
MaskOverlay controls how segmentation masks are composited onto the destination image:
use MaskOverlay;
// Default: no background replacement, full opacity
let overlay = default;
// With a background image and 50% transparent masks
let overlay = MaskOverlay ;
Fields:
background: Option<&TensorDyn>— Optional tensor to blit intodstbefore drawing masks. Must matchdst's shape and format, and must not aliasdst(an aliased pair returnsError::AliasedBuffers).Noneclearsdstinstead.opacity: f32— Scales mask alpha in the range0.0(invisible) to1.0(fully opaque, default).letterbox: Option<[f32; 4]>—[xmin, ymin, xmax, ymax]in model-input normalized space. When set, decoder output is mapped back to the original image's coordinates. Build it from the sameCropyou gaveconvert()withMaskOverlay::with_letterbox_crop.color_mode: ColorMode—Class(default, colour per class label),Instance(colour per detection index), orTrack(reserved for track IDs; behaves likeInstancetoday).
draw_masks()
Convenience method that decodes model outputs, runs NMS, and draws segmentation masks in a single call:
let boxes = processor.draw_masks?;
draw_masks_tracked()
Like draw_masks() but integrates a Tracker for maintaining object identities across frames. The tracker runs after NMS but before mask extraction. Requires the tracker feature flag.
let = processor.draw_masks_tracked?;
Returns (Vec<DetectBox>, Vec<TrackInfo>).
Fused GPU Proto Path (draw_proto_masks)
Computes sigmoid(coefficients @ protos) per-pixel in a fragment shader — no intermediate mask materialization. Preferred for real-time overlay.
let mut detections = Vecnew;
if let Some = decoder.decode_proto?
Hybrid CPU+GPU Path
The CPU materializes binary masks with materialize_masks(), then OpenGL blits
them via draw_decoded_masks(). This is the recommended pattern on Vivante
GC7000UL (i.MX 8M Plus), where the fused fragment shader falls off a
performance cliff at high detection counts:
use MaskResolution;
let masks = processor.materialize_masks?;
drop; // free the proto tensor immediately
processor.draw_decoded_masks?;
Shader Variants
| Variant | Proto Format | Interpolation |
|---|---|---|
| int8-nearest | R8I quantized | Nearest neighbor |
| int8-bilinear | R8I quantized | Manual 4-tap bilinear |
| f32 | R32F float | Hardware GL_LINEAR |
| f16 | R16F half | Hardware GL_LINEAR |
Int8 Interpolation Mode
Control quantized proto interpolation quality:
processor.set_int8_interpolation_mode;
See BENCHMARKS.md for per-platform performance numbers.
Zero-Copy Model Input
Use create_image() to allocate the destination tensor with the processor's
optimal memory backend (DMA-buf, PBO, or system memory). This enables
zero-copy GPU paths that direct Tensor::new() allocation cannot achieve:
let mut dst =
processor.create_image?;
processor.convert?;
If you need to write into a pre-allocated buffer with a specific memory type (e.g. an NPU-bound tensor), you can still use direct allocation:
let mut model_input = new?;
model_input.set_format?;
let mut dst = from;
processor.convert?;
Tiled Preprocessing (SAHI)
For small-object detection in high-resolution frames (e.g. 4K), run the model at
its native tile resolution over an overlapping grid of native-resolution crops —
SAHI (Slicing Aided Hyper Inference). ImageProcessor renders every tile into
a single tall packed batch tensor with one GL import and one flush, so the
N tiles cost roughly one GPU sync rather than N.
use TilingConfig;
use ;
// 640x640 tiles, >=20% overlap (the minimum; actual overlap is redistributed
// evenly so every tile is full-size and the last tile lands flush at frame-tile).
let cfg = new.with_overlap;
// plan_tiles is pure geometry (no GPU): use its length to size the batch.
let placements = processor.plan_tiles?;
// One tall destination: tile_w wide, N*tile_h tall — N tiles stacked
// vertically. Allocate once, reuse per frame.
let mut batch = processor.alloc_tile_batch?;
// Render all tiles (deferred convert per tile + single flush). Returns the same
// placements mapping each tile band back to full-frame coordinates.
let placements = processor.tile_into?;
Each tile selects its source crop by sampling the whole-frame tensor (texture
coordinates), never by viewing it — a viewed source would mint one EGLImage import
per tile and defeat the zero-copy property. The destination tiles are sibling views
of one parent buffer rendered as glViewport/glScissor bands.
Batch-format limit. The GL band lowering handles single-pass geometry only. A zero-copy (DMA) batch destination in packed
Rgbor any planar layout reinterprets the render-target shape (W*3/4 × H,H*3bands), which the band path does not yet compute, so GL declines those tiles and each one falls back to CPU. Batch inRgba,Bgra, orGreyto stay on the batched GPU path today, or usetile_one— it writes whole slots rather than bands, so it is not subject to this limit at any format.
For pipelined I/O (overlapping preprocessing, inference, and collection), use
tile_one to render into a caller-owned model-input slot (e.g. one slot of a ring)
instead of the batched tile_into:
let placements = processor.plan_tiles?; // pure, no GPU
let mut slot = processor.create_image?;
for p in &placements
plan_tiles is pure geometry (no GPU work) so a profiler can size pools up front;
tile_one issues one deferred convert into a caller-owned slot so tiles overlap
with inference. The returned TilePlacement values are consumed by the decoder's
lift_tile_boxes / TiledFrameAccumulator to merge per-tile detections back into
full-frame results (see the edgefirst-decoder README).
Zero-Copy External Buffer (Linux)
When integrating with an NPU delegate (e.g. VxDelegate) that owns its own
DMA-BUF buffers, use import_image() to render directly into the
delegate's buffer — eliminating the memcpy between HAL's buffer and the
delegate's buffer:
use PlaneDescriptor;
// UC1: Render into VxDelegate's DMA-BUF — zero copies
let pd = new?; // dups fd — caller keeps ownership
let mut dst = processor.import_image?;
processor.convert?;
// dst's backing memory IS vx_fd — no memcpy needed
For the reverse direction (HAL allocates, consumer imports):
let hal_dst =
processor.create_image?;
let fd = hal_dst.dmabuf_clone?; // Error if not DMA-backed
vxdelegate.register_buffer?;
Performance tip: When rotating through a pool of DMA-BUFs (e.g. 2-3
from VxDelegate), create the TensorDyn wrappers once at init and reuse
them across frames. This avoids EGL image cache misses (~100-300us each).
Multiplane NV12/NV16
For V4L2 multi-planar DMA-BUF buffers (separate Y and UV file descriptors):
let img = from_planes?;
let src = from;
processor.convert?;
The OpenGL backend imports each plane's DMA-BUF fd separately for zero-copy GPU access.
Documentation
- Architecture overview: ARCHITECTURE.md
- Testing guide: TESTING.md
- Full API reference: docs.rs/edgefirst-image
- Project README: ../../README.md
License
Licensed under the Apache License, Version 2.0. See LICENSE for details.