Face & Object Detector with Flutter + Rust

Build a desktop CV app using Rust ONNX inference and Flutter UI.

Introduction

30 Second Summary

Think about how your phone can instantly spot faces in a photo or how security cameras identify people and cars in real time. That detection runs on specialized machine learning models that analyze images pixel by pixel.

In this project, you will build a Linux desktop application that detects faces and objects in any image you pick from your filesystem. The detection engine runs in Rust for maximum speed, while Flutter provides a polished visual interface with color-coded bounding boxes overlaid on your images.

What You'll Build

A Flutter desktop app powered by a Rust inference engine that runs a YOLOv8 ONNX model to detect 80 different object classes in images.

By the end of this project, you'll have:

  • A Rust inference library that loads and runs a YOLOv8 ONNX model for object detection across 80 COCO classes including people, vehicles, and animals.
  • A Flutter desktop app with an image picker and detection results viewer that displays bounding boxes, labels, and confidence scores.
  • A flutter_rust_bridge integration connecting Dart and Rust with zero-boilerplate interop.
  • Secret Mission: Add a dedicated face detection model with facial landmark visualization alongside the general object detector.

Are there any prerequisites?

This project assumes basic familiarity with Rust and Dart/Flutter. You should be comfortable running terminal commands and reading code, but you do not need prior experience with machine learning or computer vision.

Before We Start

Before We Start...

Install Flutter, Rust, and System Dependencies

Before you can build anything, you need the two core toolchains this project relies on. Rust provides the high-performance inference engine, and Flutter provides the cross-platform desktop UI.

In this step, you will install both toolchains along with the system libraries required for the Flutter Linux desktop target and the code-generation bridge that connects Dart to Rust.

In this step, get ready to:
  • Install Rust via rustup and verify with version commands.
  • Install Flutter SDK and system dependencies for Linux desktop.
  • Install the flutter_rust_bridge codegen tool and enable Linux desktop.
Install Rust via rustup and Flutter SDK

Rust is installed and managed through rustup, the official toolchain installer. This project requires Rust 1.88 or higher because the ort crate sets that as its minimum supported Rust version.

  • Press the Windows key to open your search bar.
  • Type Terminal and press Enter to open it.
  • Install Rust by running this command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

What does this command do?

This downloads and runs the official rustup installer script. It installs the Rust compiler (rustc), the package manager (cargo), and adds them to your PATH.

  • When prompted with installation options, press Enter to proceed with the default installation.
  • After installation completes, apply the new PATH to your current terminal session by running:
source "$HOME/.cargo/env"
  • Verify Rust installed correctly by running:
rustc --version
cargo --version

✔️ I see version 1.88 or higher

Rust is ready. Move on to installing Flutter below.

ⓧ I see an older version

You have Rust installed, but need a newer version.

  • Update your Rust installation by running:
rustup update

Re-run rustc --version to confirm you now see 1.88 or higher.

ⓧ Command not found

The PATH update may not have taken effect.

  • Close and reopen your terminal, then try the version commands again.
  • If it still fails, re-run the curl command above and make sure it completes without errors.

Now install the Flutter SDK. You will clone the stable channel from the official Flutter repository.

  • Create a development directory and clone the Flutter stable channel by running:
mkdir -p ~/development
git clone https://github.com/flutter/flutter.git -b stable ~/development/flutter
  • Add Flutter to your PATH for the current session and future sessions by running:
echo 'export PATH="$HOME/development/flutter/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

What if I use zsh?

If your shell is zsh instead of bash, append the export line to ~/.zshenv instead of ~/.bashrc, then run source ~/.zshenv.

  • Verify Flutter is accessible by running:
flutter doctor

✔️ I see Flutter doctor output

Flutter is installed and accessible. You may see warnings about missing Android toolchain or Chrome. Those are expected and safe to ignore because this project only uses Linux desktop.

ⓧ Command not found

  • Make sure the export line was added to your shell profile by running cat ~/.bashrc | grep flutter.
  • Open a new terminal window and try flutter doctor again.
  • Confirm the Flutter directory exists by running ls ~/development/flutter/bin/flutter.

Seeing git clone errors?

Make sure git is installed on your system. Run sudo apt install git if the command is not found, then re-run the clone command.

Help me troubleshoot the Flutter SDK git clone failing on my Linux machine

Install system dependencies and the bridge codegen

Flutter Linux desktop apps require several system libraries for GTK windowing, compilation, and linking. The flutter_rust_bridge codegen tool also needs to be installed so it can generate the Dart bindings from your Rust code.

  • Install the system dependencies for Flutter Linux desktop development by running:
sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev

What are these packages for?

  • clang provides the C/C++ compiler needed by Rust's linker and Flutter's native builds.
  • cmake and ninja-build are the build systems Flutter uses to compile Linux desktop targets.
  • libgtk-3-dev provides GTK3 headers for the Flutter desktop window.
  • libstdc++-12-dev provides the C++ standard library headers required for linking.
  • When prompted, enter your password and press Y to confirm the installation.
  • Install the flutter_rust_bridge_codegen tool by running:
cargo install flutter_rust_bridge_codegen

What is flutter_rust_bridge?

This tool auto-generates Dart bindings from your Rust code. It handles all the complex FFI (foreign function interface) boilerplate so you can call Rust functions directly from Dart without manual memory management.

The install may take a few minutes as cargo compiles the tool from source.

Cargo install taking a very long time?

Compiling from source on a slower machine can take 5-10 minutes. This is normal. Let it run to completion.

If the install fails with a linker error, make sure clang was installed in the previous command.

Help me fix a cargo install failure for flutter_rust_bridge_codegen on Linux

Verify the full toolchain

Time to confirm everything is in place before you scaffold the project.

  • Verify the bridge codegen is installed by running:
flutter_rust_bridge_codegen --version

✔️ I see a version number

You should see 2.12.0 or similar. The codegen is ready.

ⓧ Command not found

  • Make sure ~/.cargo/bin is on your PATH.
  • Run source "$HOME/.cargo/env" and try the version command again.
  • If it still fails, re-run cargo install flutter_rust_bridge_codegen and check for error messages.
  • Enable Flutter's Linux desktop support by running:
flutter config --enable-linux-desktop

Why run this command?

In newer Flutter versions, Linux desktop is enabled by default. Running this command explicitly ensures it is active regardless of your Flutter version.

  • Run a final check to confirm your Linux desktop development environment is complete:
flutter doctor

You should see a green checkmark next to Flutter and Linux toolchain in the output. Warnings about Android or iOS are expected and can be ignored for this project.

Your full toolchain is locked and loaded. Next up, you will scaffold the Flutter + Rust project structure using the bridge codegen tool.

Scaffold the Flutter + Rust Project

Your toolchain is installed and ready. Now it is time to create the actual project that combines Flutter and Rust into a single application.

The flutter_rust_bridge_codegen tool has a create command that scaffolds an entire Flutter + Rust project in one shot. It generates all the glue code, build configuration, and directory structure so Flutter can call Rust functions without you writing any manual FFI bindings.

In this step, get ready to:
  • Create the vision_app project with flutter_rust_bridge_codegen.
  • Verify the scaffold runs as a Linux desktop app.
  • Explore the generated project structure.
Create the project with flutter_rust_bridge_codegen

The flutter_rust_bridge codegen tool creates a complete project skeleton with Flutter on one side and Rust on the other, already wired together through auto-generated bindings.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project and move into it by running these commands:
flutter_rust_bridge_codegen create vision_app && cd vision_app

What does this command do?

The create subcommand scaffolds a full Flutter project with an embedded Rust crate. It sets up CMake build hooks so that flutter run automatically compiles the Rust code alongside the Dart code.

The && chains the cd so your terminal lands inside the new vision_app folder once creation finishes.

  • Confirm the project was created by running:
ls

You should see folders including lib/, rust/, linux/, and files like pubspec.yaml and flutter_rust_bridge.yaml.

Command not found or create failed?

  • Make sure flutter_rust_bridge_codegen is on your PATH. Run flutter_rust_bridge_codegen --version to verify it returns 2.12.0.
  • If you see errors about missing clang or cmake, re-run the system dependency install from Step 1.
  • help me troubleshoot why flutter_rust_bridge_codegen create failed on my system
Verify the scaffold builds and runs

Before writing any custom code, you need to confirm the default scaffold compiles and launches as a Linux desktop app. This proves the Flutter-to-Rust build pipeline is working end to end.

  • Build and launch the demo app by running:
flutter run -d linux

What happens when you run this?

Flutter compiles the Dart code in lib/ and simultaneously builds the Rust crate in rust/ via CMake hooks. The compiled Rust library is linked into the final Linux executable.

The first build takes a few minutes because it compiles the Rust dependencies from scratch. Subsequent builds are much faster thanks to incremental compilation.

A desktop window should appear showing the default flutter_rust_bridge demo app. The app displays a button that calls a Rust function and shows the result as text on screen.

  • Once you have confirmed the window appears, press q in your terminal to quit the running app.

App fails to build or window does not appear?

  • Run flutter doctor and check for any missing Linux desktop toolchain components.
  • If you see linker errors mentioning libgtk, confirm you installed libgtk-3-dev in Step 1.
  • If the Rust build hangs, ensure you have at least 4 GB of free RAM available. Rust compilation is memory-intensive.
  • help me debug why flutter run -d linux is failing for my flutter_rust_bridge project
Explore the generated project structure

Understanding the directory layout helps you know where to put your code in the coming steps.

  • View the top-level folders by running:
ls rust/src/api/

You should see at least one .rs file inside the api/ directory. This is where you will write the Rust functions that Flutter can call.

What are the key directories?

  • lib/ contains all your Flutter/Dart code including main.dart.
  • rust/ is a full Rust crate with its own Cargo.toml. This is where the detection engine will live.
  • rust/src/api/ is the special directory. Any public Rust function you write here gets automatically exposed to Dart when you regenerate the bridge.
  • rust/src/lib.rs ties the crate together. It already contains mod frb_generated; and pub mod api; for the bridge to work.

Why flutter_rust_bridge instead of raw FFI?

Raw dart:ffi requires you to manually manage memory, write C-compatible function signatures, and generate bindings by hand. flutter_rust_bridge handles all of this automatically.

You write normal Rust with rich types (structs, enums, Vec, Result) and the codegen translates everything into Dart-friendly APIs. This lets you focus on the ML logic instead of pointer arithmetic.

Your project is scaffolded and running. Next up, you will replace the demo Rust function with a real object detection engine powered by ONNX Runtime.

Build the Rust Object Detection Engine

Your Flutter shell is running, but there is no intelligence behind it yet. In this step you will build the Rust-powered inference engine that takes an image, feeds it through a YOLOv8 neural network, and returns a list of detected objects with their positions and confidence scores.

The engine uses ONNX Runtime to execute the model on CPU. You will handle the full computer vision pipeline: loading the model, preprocessing pixels into a normalized tensor, running inference, and filtering duplicate detections with non-maximum suppression.

In this step, get ready to:
  • Add the ONNX Runtime, ndarray, and image crates to your Rust project.
  • Download the YOLOv8m ONNX model file.
  • Write the full detection engine with preprocessing, inference, and NMS post-processing.
Add dependencies and download the ONNX model

Before writing any detection logic, you need three Rust crates and the pre-trained model file. The ort crate wraps ONNX Runtime, ndarray handles tensor math, and image loads and resizes images.

  • Open rust/Cargo.toml and replace its entire contents with the following:
[package]
name = "rust_lib_vision_app"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "staticlib"]

[dependencies]
flutter_rust_bridge = "=2.12.0"
ort = { version = "=2.0.0-rc.12", features = ["download-binaries", "ndarray"] }
ndarray = "0.17"
image = "0.25"

What do these dependencies do?

  • ort (pinned to 2.0.0-rc.12): Rust bindings for ONNX Runtime. The download-binaries feature auto-downloads the runtime library so you don't install it manually. The ndarray feature enables direct tensor interop.
  • ndarray (0.17): NumPy-style multidimensional arrays in Rust. You use it to build the input tensor and slice the output.
  • image (0.25): Image decoding and resizing. Handles JPEG, PNG, BMP, and WebP formats.
  • Save rust/Cargo.toml.

Now download the YOLOv8m ONNX model. This is a ~50MB file that contains the pre-trained neural network weights for detecting 80 object classes.

  • Create the assets directory and download the model by running these commands:
mkdir -p assets
wget -O assets/yolov8m.onnx https://cdn.pyke.io/pyke:ort-rs/example-models@0.0.0/yolov8m.onnx

Why this specific model?

YOLOv8m (medium) balances accuracy and speed. It detects 80 COCO classes including person, car, dog, and dozens more everyday objects. The ort project hosts this exact ONNX export for use with their Rust examples.

  • Confirm the download completed by running:
ls -lh assets/yolov8m.onnx
  • You should see a file around 50MB in size.

Download taking too long or failing?

  • If wget is not installed, install it with sudo apt install wget and retry.
  • If the download stalls, press Ctrl+C and retry. The CDN occasionally has slow moments.
  • If you see a 0 bytes file, delete it with rm assets/yolov8m.onnx and download again.

Help me troubleshoot my model download.

Finally, register the model as a Flutter asset so the app can locate it at runtime.

  • Open pubspec.yaml in your editor.
  • Find the flutter: section near the bottom of the file.
  • Add the assets key below uses-material-design: true so it looks like this:
flutter:
  uses-material-design: true
  assets:
    - assets/yolov8m.onnx

What does registering an asset do?

Flutter bundles listed assets into the final application package. Without this entry, the model file would not be included when you build the app and the Rust code would fail to find it.

  • Save pubspec.yaml.
Write the core detection function

The detection engine lives in a single Rust file that handles everything from loading the model to parsing raw inference output into bounding boxes. You will build it up in chunks, starting with the data structures and model initialization.

  • Create a new file at rust/src/api/detection.rs.
  • Add the imports, constants, and struct definitions by pasting this code:
use image::GenericImageView;
use ndarray::{Array, Axis, s};
use ort::{
    inputs,
    session::Session,
    value::TensorRef,
};
use std::path::Path;
use std::sync::OnceLock;

// Global model session, loaded once and reused across calls
static MODEL: OnceLock<Session> = OnceLock::new();

// Detection thresholds and input dimensions
const CONFIDENCE_THRESHOLD: f32 = 0.5;
const NMS_IOU_THRESHOLD: f32 = 0.7;
const INPUT_SIZE: u32 = 640;

// A single detected object with bounding box, label, and confidence
pub struct DetectedObject {
    pub x1: f32,
    pub y1: f32,
    pub x2: f32,
    pub y2: f32,
    pub label: String,
    pub confidence: f32,
}

// The full detection result including timing info
pub struct DetectionResult {
    pub objects: Vec<DetectedObject>,
    pub inference_time_ms: u64,
}

What does this code do?

  • OnceLock<Session> stores the loaded ONNX model globally. Loading a model is expensive, so you do it once and reuse the session for every detection call.
  • CONFIDENCE_THRESHOLD (0.5) filters out weak detections. Only boxes with 50%+ confidence survive.
  • NMS_IOU_THRESHOLD (0.7) controls how aggressively overlapping boxes get merged.
  • INPUT_SIZE (640) is the fixed dimension YOLOv8 expects. Every image gets resized to 640x640 before inference.
  • DetectedObject and DetectionResult are the structs that Flutter will receive through the bridge.

Next, add the function that loads the ONNX model into memory.

  • Below the DetectionResult struct, add the init_model function:
pub fn init_model(model_path: String) -> Result<String, String> {
    // Create an ONNX Runtime session with 4 threads for parallel execution
    let session = Session::builder()
        .map_err(|e| format!("Failed to create session builder: {e}"))?
        .with_intra_threads(4)
        .map_err(|e| format!("Failed to set threads: {e}"))?
        .commit_from_file(&model_path)
        .map_err(|e| format!("Failed to load model: {e}"))?;

    // Store the session globally so detect_objects can use it
    MODEL.set(session).map_err(|_| "Model already initialized".to_string())?;
    Ok("Model loaded successfully".to_string())
}

What does init_model do?

  • Session::builder() creates an ONNX Runtime session configuration.
  • with_intra_threads(4) tells the runtime to use 4 CPU threads for parallel computation within a single inference call.
  • commit_from_file() loads the model weights from disk into the session.
  • MODEL.set(session) stores the ready session in the global OnceLock. The Flutter side calls this once on app startup.

Now for the main event. The detect_objects function takes an image path, preprocesses it into a tensor, runs inference, and parses the raw output into bounding boxes.

  • Below init_model, add the detect_objects function:
pub fn detect_objects(image_path: String) -> Result<DetectionResult, String> {
    let model = MODEL.get().ok_or("Model not initialized. Call init_model first.")?;

    // Load the image and record its original dimensions
    let img = image::open(Path::new(&image_path))
        .map_err(|e| format!("Failed to open image: {e}"))?;

    let (img_width, img_height) = (img.width(), img.height());
    // Resize to the fixed input size YOLOv8 expects
    let resized = img.resize_exact(INPUT_SIZE, INPUT_SIZE, image::imageops::FilterType::CatmullRom);

    // Build a 4D tensor: [batch=1, channels=3, height=640, width=640]
    // Normalize pixel values from 0-255 to 0.0-1.0
    let mut input = Array::zeros((1, 3, INPUT_SIZE as usize, INPUT_SIZE as usize));
    for pixel in resized.pixels() {
        let x = pixel.0 as usize;
        let y = pixel.1 as usize;
        let [r, g, b, _] = pixel.2.0;
        input[[0, 0, y, x]] = (r as f32) / 255.0;
        input[[0, 1, y, x]] = (g as f32) / 255.0;
        input[[0, 2, y, x]] = (b as f32) / 255.0;
    }

    // Run inference and measure elapsed time
    let start = std::time::Instant::now();
    let outputs = model
        .run(inputs!["images" => TensorRef::from_array_view(&input).map_err(|e| format!("Tensor error: {e}"))?].map_err(|e| format!("Input error: {e}"))?)
        .map_err(|e| format!("Inference failed: {e}"))?;
    let inference_time_ms = start.elapsed().as_millis() as u64;

    // Extract and transpose the output: [1, 84, 8400] -> [8400, 84, 1]
    // Each row: [xc, yc, w, h, class_scores x 80]
    let output = outputs["output0"]
        .try_extract_array::<f32>()
        .map_err(|e| format!("Output extraction failed: {e}"))?
        .t()
        .into_owned();

    // Parse each detection candidate
    let mut boxes: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let output_slice = output.slice(s![.., .., 0]);

    for row in output_slice.axis_iter(Axis(0)) {
        let row: Vec<f32> = row.iter().copied().collect();
        // Find the class with highest probability
        let (class_id, prob) = row
            .iter()
            .skip(4)
            .enumerate()
            .map(|(index, value)| (index, *value))
            .reduce(|accum, row| if row.1 > accum.1 { row } else { accum })
            .unwrap_or((0, 0.0));

        if prob < CONFIDENCE_THRESHOLD {
            continue;
        }

        // Convert from center format to corner format, scaled to original image size
        let xc = row[0] / INPUT_SIZE as f32 * img_width as f32;
        let yc = row[1] / INPUT_SIZE as f32 * img_height as f32;
        let w = row[2] / INPUT_SIZE as f32 * img_width as f32;
        let h = row[3] / INPUT_SIZE as f32 * img_height as f32;

        boxes.push((xc - w / 2.0, yc - h / 2.0, xc + w / 2.0, yc + h / 2.0, class_id, prob));
    }

    // Sort by confidence descending, then apply NMS
    boxes.sort_by(|a, b| b.5.partial_cmp(&a.5).unwrap_or(std::cmp::Ordering::Equal));
    let filtered = nms(&boxes, NMS_IOU_THRESHOLD);

    // Map numeric class IDs to human-readable labels
    let objects: Vec<DetectedObject> = filtered
        .into_iter()
        .map(|(x1, y1, x2, y2, class_id, confidence)| DetectedObject {
            x1,
            y1,
            x2,
            y2,
            label: COCO_LABELS.get(class_id).unwrap_or(&"unknown").to_string(),
            confidence,
        })
        .collect();

    Ok(DetectionResult {
        objects,
        inference_time_ms,
    })
}

What does detect_objects do?

This function runs the full detection pipeline in four stages:

  • Preprocessing: Loads the image, resizes to 640x640, and converts pixels into a normalized CHW (channels, height, width) float tensor.
  • Inference: Feeds the tensor into the ONNX model via the inputs! macro and collects the output. The timer measures how long this takes.
  • Output parsing: The raw output is a [1, 84, 8400] tensor. After transposing, each of the 8400 rows contains 4 bounding box coordinates plus 80 class confidence scores.
  • Filtering: Boxes below the confidence threshold are dropped, then NMS removes overlapping duplicates.

The file won't compile yet

detect_objects calls nms() and references COCO_LABELS, which you have not defined yet. You will add those in the next substep.

Implement non-maximum suppression and class labels

When a model detects a person in an image, it often produces several overlapping bounding boxes for the same person. Non-maximum suppression (NMS) keeps only the highest-confidence box from each cluster of overlaps.

  • At the bottom of rust/src/api/detection.rs (below detect_objects), add the NMS and IoU helper functions:
fn nms(boxes: &[(f32, f32, f32, f32, usize, f32)], iou_threshold: f32) -> Vec<(f32, f32, f32, f32, usize, f32)> {
    let mut result: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let mut remaining = boxes.to_vec();

    // Greedily pick the top box, remove all boxes that overlap too much
    while !remaining.is_empty() {
        result.push(remaining[0]);
        remaining = remaining
            .iter()
            .filter(|b| iou(&remaining[0], b) < iou_threshold)
            .copied()
            .collect();
    }

    result
}

// Calculate intersection-over-union between two bounding boxes
fn iou(a: &(f32, f32, f32, f32, usize, f32), b: &(f32, f32, f32, f32, usize, f32)) -> f32 {
    let x1 = a.0.max(b.0);
    let y1 = a.1.max(b.1);
    let x2 = a.2.min(b.2);
    let y2 = a.3.min(b.3);

    let intersection = (x2 - x1).max(0.0) * (y2 - y1).max(0.0);
    let area_a = (a.2 - a.0) * (a.3 - a.1);
    let area_b = (b.2 - b.0) * (b.3 - b.1);
    let union = area_a + area_b - intersection;

    if union <= 0.0 { 0.0 } else { intersection / union }
}

How does NMS work?

The algorithm is greedy. It picks the box with the highest confidence, adds it to results, then removes every remaining box that overlaps it above the IoU threshold. It repeats until no boxes remain.

IoU (Intersection over Union) measures overlap. Two boxes covering the same area have IoU of 1.0. Two boxes that don't touch at all have IoU of 0.0. The threshold of 0.7 means boxes overlapping more than 70% get merged.

Finally, add the COCO class labels. These are the 80 object categories that YOLOv8 can recognize.

  • Below the iou function, add the labels array:
const COCO_LABELS: &[&str] = &[
    "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
    "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
    "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
    "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
    "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
    "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
    "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
    "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
    "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
    "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier",
    "toothbrush",
];

What are COCO labels?

COCO (Common Objects in Context) is the dataset YOLOv8 was trained on. The model outputs a numeric class ID (0-79) for each detection. This array maps those IDs to human-readable names like "person", "car", or "dog".

Now register the new module so the Rust crate knows about your detection file.

  • Open rust/src/api/mod.rs and replace its contents with:
pub mod detection;
  • Save both rust/src/api/detection.rs and rust/src/api/mod.rs.

Verify that everything compiles by building the Rust crate.

  • Build the Rust library by running:
cd rust && cargo build

What should I see?

The first build takes several minutes because ort downloads the ONNX Runtime binary (~200MB) and compiles all dependencies. Subsequent builds are fast.

When it finishes you should see a line like:

Finished `dev` profile [unoptimized + debuginfo] target(s)
  • After the build succeeds, return to the project root by running:
cd ..

Build failing with errors?

  • If you see a linker error mentioning clang or cc, make sure you installed the system dependencies from Step 1 (clang, cmake, pkg-config).
  • If you see a network error while downloading ONNX Runtime, check your internet connection and retry the build.
  • If the compiler complains about a missing function or type, double-check you pasted all code chunks into detection.rs in order (imports, structs, init_model, detect_objects, nms, iou, COCO_LABELS).

Help me debug my Rust build error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both detection.rs and mod.rs, and that your build passed.

ⓧ I'd like to double check the full code

Here is the complete rust/src/api/detection.rs file:

use image::GenericImageView;
use ndarray::{Array, Axis, s};
use ort::{
    inputs,
    session::Session,
    value::TensorRef,
};
use std::path::Path;
use std::sync::OnceLock;

static MODEL: OnceLock<Session> = OnceLock::new();

const CONFIDENCE_THRESHOLD: f32 = 0.5;
const NMS_IOU_THRESHOLD: f32 = 0.7;
const INPUT_SIZE: u32 = 640;

pub struct DetectedObject {
    pub x1: f32,
    pub y1: f32,
    pub x2: f32,
    pub y2: f32,
    pub label: String,
    pub confidence: f32,
}

pub struct DetectionResult {
    pub objects: Vec<DetectedObject>,
    pub inference_time_ms: u64,
}

pub fn init_model(model_path: String) -> Result<String, String> {
    let session = Session::builder()
        .map_err(|e| format!("Failed to create session builder: {e}"))?
        .with_intra_threads(4)
        .map_err(|e| format!("Failed to set threads: {e}"))?
        .commit_from_file(&model_path)
        .map_err(|e| format!("Failed to load model: {e}"))?;

    MODEL.set(session).map_err(|_| "Model already initialized".to_string())?;
    Ok("Model loaded successfully".to_string())
}

pub fn detect_objects(image_path: String) -> Result<DetectionResult, String> {
    let model = MODEL.get().ok_or("Model not initialized. Call init_model first.")?;

    let img = image::open(Path::new(&image_path))
        .map_err(|e| format!("Failed to open image: {e}"))?;

    let (img_width, img_height) = (img.width(), img.height());
    let resized = img.resize_exact(INPUT_SIZE, INPUT_SIZE, image::imageops::FilterType::CatmullRom);

    let mut input = Array::zeros((1, 3, INPUT_SIZE as usize, INPUT_SIZE as usize));
    for pixel in resized.pixels() {
        let x = pixel.0 as usize;
        let y = pixel.1 as usize;
        let [r, g, b, _] = pixel.2.0;
        input[[0, 0, y, x]] = (r as f32) / 255.0;
        input[[0, 1, y, x]] = (g as f32) / 255.0;
        input[[0, 2, y, x]] = (b as f32) / 255.0;
    }

    let start = std::time::Instant::now();
    let outputs = model
        .run(inputs!["images" => TensorRef::from_array_view(&input).map_err(|e| format!("Tensor error: {e}"))?].map_err(|e| format!("Input error: {e}"))?)
        .map_err(|e| format!("Inference failed: {e}"))?;
    let inference_time_ms = start.elapsed().as_millis() as u64;

    let output = outputs["output0"]
        .try_extract_array::<f32>()
        .map_err(|e| format!("Output extraction failed: {e}"))?
        .t()
        .into_owned();

    let mut boxes: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let output_slice = output.slice(s![.., .., 0]);

    for row in output_slice.axis_iter(Axis(0)) {
        let row: Vec<f32> = row.iter().copied().collect();
        let (class_id, prob) = row
            .iter()
            .skip(4)
            .enumerate()
            .map(|(index, value)| (index, *value))
            .reduce(|accum, row| if row.1 > accum.1 { row } else { accum })
            .unwrap_or((0, 0.0));

        if prob < CONFIDENCE_THRESHOLD {
            continue;
        }

        let xc = row[0] / INPUT_SIZE as f32 * img_width as f32;
        let yc = row[1] / INPUT_SIZE as f32 * img_height as f32;
        let w = row[2] / INPUT_SIZE as f32 * img_width as f32;
        let h = row[3] / INPUT_SIZE as f32 * img_height as f32;

        boxes.push((xc - w / 2.0, yc - h / 2.0, xc + w / 2.0, yc + h / 2.0, class_id, prob));
    }

    boxes.sort_by(|a, b| b.5.partial_cmp(&a.5).unwrap_or(std::cmp::Ordering::Equal));
    let filtered = nms(&boxes, NMS_IOU_THRESHOLD);

    let objects: Vec<DetectedObject> = filtered
        .into_iter()
        .map(|(x1, y1, x2, y2, class_id, confidence)| DetectedObject {
            x1,
            y1,
            x2,
            y2,
            label: COCO_LABELS.get(class_id).unwrap_or(&"unknown").to_string(),
            confidence,
        })
        .collect();

    Ok(DetectionResult {
        objects,
        inference_time_ms,
    })
}

fn nms(boxes: &[(f32, f32, f32, f32, usize, f32)], iou_threshold: f32) -> Vec<(f32, f32, f32, f32, usize, f32)> {
    let mut result: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let mut remaining = boxes.to_vec();

    while !remaining.is_empty() {
        result.push(remaining[0]);
        remaining = remaining
            .iter()
            .filter(|b| iou(&remaining[0], b) < iou_threshold)
            .copied()
            .collect();
    }

    result
}

fn iou(a: &(f32, f32, f32, f32, usize, f32), b: &(f32, f32, f32, f32, usize, f32)) -> f32 {
    let x1 = a.0.max(b.0);
    let y1 = a.1.max(b.1);
    let x2 = a.2.min(b.2);
    let y2 = a.3.min(b.3);

    let intersection = (x2 - x1).max(0.0) * (y2 - y1).max(0.0);
    let area_a = (a.2 - a.0) * (a.3 - a.1);
    let area_b = (b.2 - b.0) * (b.3 - b.1);
    let union = area_a + area_b - intersection;

    if union <= 0.0 { 0.0 } else { intersection / union }
}

const COCO_LABELS: &[&str] = &[
    "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
    "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
    "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
    "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
    "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
    "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
    "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
    "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
    "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
    "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier",
    "toothbrush",
];

And here is rust/Cargo.toml:

[package]
name = "rust_lib_vision_app"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "staticlib"]

[dependencies]
flutter_rust_bridge = "=2.12.0"
ort = { version = "=2.0.0-rc.12", features = ["download-binaries", "ndarray"] }
ndarray = "0.17"
image = "0.25"

And rust/src/api/mod.rs:

pub mod detection;

Your detection engine is compiled and ready. Next up, you will bridge this Rust code to Flutter so Dart can call init_model and detect_objects directly.

Bridge Rust to Flutter

Your Rust detection engine is complete. It can load a YOLOv8 model, preprocess images, run inference, and filter results with NMS.

The missing piece is making all of that callable from Dart. The flutter_rust_bridge codegen tool reads your pub functions and structs in Rust, then generates type-safe Dart bindings automatically. No manual FFI, no C headers, no serialization boilerplate.

In this step, get ready to:
  • Confirm your Rust API is ready for binding generation.
  • Generate Dart bindings with the bridge codegen.
  • Test the bridge by calling detection from Dart.
Confirm the Rust API is bridge-ready

flutter_rust_bridge scans every file inside rust/src/api/ for pub functions and pub structs. It converts Rust naming conventions to Dart automatically.

  • Open rust/src/api/detection.rs in your editor.
  • Confirm that DetectedObject, DetectionResult, init_model, and detect_objects are all marked pub.

How does the bridge know what to expose?

Any pub item inside the rust/src/api/ directory becomes part of the generated Dart API. Private functions (like nms and iou) stay hidden. No annotations or macros required.

The bridge converts snake_case Rust names to camelCase Dart names. So init_model becomes initModel and detect_objects becomes detectObjects in Dart.

Generate the Dart bindings

Before generating bindings, you need to add the Flutter packages your app will use alongside the bridge.

  • Add the file_selector and path_provider packages by running these commands:
flutter pub add file_selector:^1.0.2 path_provider:^2.1.5

What are these packages for?

file_selector is the official Flutter package for picking files from the filesystem on desktop platforms. path_provider helps locate standard directories. You will use both in the next step when building the detection UI.

  • Generate the Dart bindings from your Rust code by running:
flutter_rust_bridge_codegen generate
  • Confirm the command completed successfully. You should see output indicating that bindings were generated.
  • Verify the generated file exists by running:
ls lib/src/rust/api/detection.dart

You should see the file path printed, confirming the bindings are in place.

What did the codegen produce?

The generated lib/src/rust/api/detection.dart file contains Dart classes for DetectedObject and DetectionResult, plus async functions initModel and detectObjects. These are fully typed, with named parameters matching your Rust function signatures.

Codegen failed with errors?

Make sure your Rust code compiles cleanly. Run cd rust && cargo build && cd .. to check for compilation errors before retrying the codegen.

Also confirm that rust/src/api/mod.rs contains pub mod detection; so the bridge can find your module.

Why is flutter_rust_bridge_codegen failing on my Rust code?

Test the bridge from Dart

Time to prove the bridge works end-to-end. You will write a quick test in lib/main.dart that loads the model and runs detection on a sample image.

  • Find any .jpg or .png image on your system to use as a test. Note its full file path (e.g. /home/user/photos/test.jpg).
  • Replace the entire contents of lib/main.dart with this test code:
import 'src/rust/api/detection.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  // Initialize the flutter_rust_bridge runtime
  await RustLib.init();

  // Load the YOLOv8 model from the assets folder
  final modelPath = 'assets/yolov8m.onnx';
  final loadResult = await initModel(modelPath: modelPath);
  print('Model status: $loadResult');

  // Run detection on a test image
  final result = await detectObjects(imagePath: [[TEST_IMAGE_PATH="/home/user/photos/test.jpg"]]);
  print('Found ${result.objects.length} objects in ${result.inferenceTimeMs}ms');

  // Print each detected object
  for (final obj in result.objects) {
    print('  ${obj.label}: ${(obj.confidence * 100).toStringAsFixed(1)}%');
  }
}

What does this test code do?

RustLib.init() initializes the bridge runtime so Dart can call Rust functions. initModel loads the ONNX model into memory. detectObjects sends an image path to Rust, which preprocesses, runs inference, applies NMS, and returns typed results back to Dart.

The entire inference pipeline runs in Rust. Dart only receives the final DetectionResult struct with clean, typed fields.

  • Run the app to test the bridge:
flutter run -d linux

You should see console output showing the model loaded successfully, followed by detected objects with labels and confidence percentages.

First run downloads ONNX Runtime

The first time you build, the ort crate's download-binaries feature automatically downloads the ONNX Runtime shared library. This may take a minute. Subsequent builds skip this step.

Seeing zero objects detected?

Make sure your test image contains recognizable objects (people, cars, animals, furniture). The model uses a confidence threshold of 0.5, so subtle or distant objects may not register.

If you see the model load message but detection fails, confirm the image path is an absolute path and the file exists.

Help me debug why my Rust detection returns no objects from Dart.

The bridge is confirmed working. Your Rust inference engine is now callable from Dart with full type safety. Next up, you will build the Flutter UI that lets users pick images and see detection results overlaid with bounding boxes.

Build the Flutter Detection UI

Your Rust detection engine is wired up and the bridge is working. Now it is time to give your app a face.

In this step you will replace the scaffold demo code with a production UI that lets you pick any image from your filesystem, sends it through the Rust inference engine, and draws color-coded bounding boxes on every detected object.

In this step, get ready to:
  • Create the app shell and main detection screen with a file picker button.
  • Wire up model loading and image detection with loading states.
  • Build a CustomPainter that draws labeled bounding boxes over detected objects.
Create the app shell and image picker

The current lib/main.dart still contains the scaffold demo code from flutter_rust_bridge_codegen create. You will replace the entire file with your own app.

  • Open lib/main.dart in your editor.
  • Select all the existing code and delete it.
  • Paste in the app entry point and MaterialApp configuration:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';
import 'src/rust/api/detection.dart';
import 'src/rust/frb_generated.dart';

// Initialize the Rust bridge and launch the app
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const VisionApp());
}

// Root widget configuring theme and routing to the detector screen
class VisionApp extends StatelessWidget {
  const VisionApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Vision Detector',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const DetectorScreen(),
    );
  }
}

What does this code do?

The imports pull in dart:io for file handling, the file_selector package for native file dialogs, and the auto-generated Dart bindings (detection.dart and frb_generated.dart) that expose your Rust functions.

The main() function initializes the flutter_rust_bridge runtime with RustLib.init() before anything else runs. VisionApp is a minimal MaterialApp that routes to your detection screen.

You will not see a change yet. The next chunk adds the detection screen itself.

  • Directly below the VisionApp class, paste the DetectorScreen widget with its state management and image picker logic:
// The main screen widget
class DetectorScreen extends StatefulWidget {
  const DetectorScreen({super.key});

  @override
  State<DetectorScreen> createState() => _DetectorScreenState();
}

class _DetectorScreenState extends State<DetectorScreen> {
  // State variables tracking the current image, detection results, and loading
  String? _imagePath;
  DetectionResult? _result;
  bool _isLoading = false;
  bool _modelLoaded = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadModel();
  }

  // Load the ONNX model via the Rust bridge on startup
  Future<void> _loadModel() async {
    setState(() => _isLoading = true);
    try {
      final modelPath = 'assets/yolov8m.onnx';
      final result = await initModel(modelPath: modelPath);
      setState(() {
        _modelLoaded = true;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Failed to load model: $e';
        _isLoading = false;
      });
    }
  }

  // Open a native file dialog, then run detection on the selected image
  Future<void> _pickImage() async {
    const typeGroup = XTypeGroup(
      label: 'Images',
      extensions: ['jpg', 'jpeg', 'png', 'bmp', 'webp'],
    );
    final file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file == null) return;

    setState(() {
      _imagePath = file.path;
      _result = null;
      _isLoading = true;
      _error = null;
    });

    try {
      final result = await detectObjects(imagePath: file.path);
      setState(() {
        _result = result;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Detection failed: $e';
        _isLoading = false;
      });
    }
  }

  // Build the Scaffold with an AppBar showing detection stats
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('\u{1f50d} Vision Detector'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          if (_result != null)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Center(
                child: Text(
                  '${_result!.objects.length} objects | ${_result!.inferenceTimeMs}ms',
                  style: const TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: _buildImageArea(),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _modelLoaded && !_isLoading ? _pickImage : null,
                  icon: const Icon(Icons.image_search),
                  label: const Text('Pick Image & Detect'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

What does this code do?

  • Five state variables track the selected image path, detection results, loading state, model readiness, and errors.
  • initState triggers _loadModel() immediately so the ONNX model is ready before the user picks an image.
  • _pickImage() uses openFile from the file_selector package with an XTypeGroup filtering for common image extensions. After picking, it passes the file path straight to the Rust detectObjects function.
  • The build() method creates a Scaffold with an AppBar that shows the object count and inference time, plus a bottom button disabled until the model loads.

You will not see a change yet. The _buildImageArea() method referenced in build() does not exist yet.

Add the image display area with loading states

The image area needs to handle three visual states: a placeholder when no image is selected, a loading spinner during inference, and the actual image once detection completes.

  • Directly below the build() method (still inside _DetectorScreenState), paste the image area builder and close the class:
  // Conditionally render placeholder, loading, or image with detection overlay
  Widget _buildImageArea() {
    if (_error != null) {
      return Center(child: Text(_error!, style: const TextStyle(color: Colors.red)));
    }
    if (_isLoading && _imagePath == null) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_imagePath == null) {
      return const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.photo_camera, size: 64, color: Colors.grey),
            SizedBox(height: 16),
            Text('Pick an image to detect faces and objects',
                style: TextStyle(fontSize: 16, color: Colors.grey)),
          ],
        ),
      );
    }

    return Stack(
      children: [
        Center(
          child: LayoutBuilder(
            builder: (context, constraints) {
              return Stack(
                children: [
                  Image.file(
                    File(_imagePath!),
                    fit: BoxFit.contain,
                    width: constraints.maxWidth,
                    height: constraints.maxHeight,
                  ),
                  if (_result != null)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: DetectionPainter(
                          result: _result!,
                          imagePath: _imagePath!,
                          displayWidth: constraints.maxWidth,
                          displayHeight: constraints.maxHeight,
                        ),
                      ),
                    ),
                ],
              );
            },
          ),
        ),
        if (_isLoading)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
}

What does this code do?

  • The method checks state in priority order: error first, then loading (model initialization), then empty (no image picked yet), and finally the full image display.
  • A LayoutBuilder captures the available width and height so the detection overlay can scale correctly.
  • When results exist, a CustomPaint widget fills the image area and passes the detection data to a DetectionPainter (which you will create next).
  • The trailing } closes the _DetectorScreenState class.

You will not see a change yet. The file references DetectionPainter which does not exist yet.

Build the bounding box painter

Flutter's CustomPainter class lets you draw directly on a canvas. You will use it to render colored rectangles and labels for each detected object.

  • Below the closing brace of _DetectorScreenState, paste the DetectionPainter class:
// Custom painter that draws bounding boxes and labels over the image
class DetectionPainter extends CustomPainter {
  final DetectionResult result;
  final String imagePath;
  final double displayWidth;
  final double displayHeight;

  DetectionPainter({
    required this.result,
    required this.imagePath,
    required this.displayWidth,
    required this.displayHeight,
  });

  // Map object classes to distinct colors
  Color _colorForLabel(String label) {
    switch (label) {
      case 'person':
        return Colors.blue;
      case 'car':
      case 'truck':
      case 'bus':
        return Colors.green;
      case 'dog':
      case 'cat':
        return Colors.orange;
      default:
        return Colors.red;
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
    for (final obj in result.objects) {
      final color = _colorForLabel(obj.label);
      final paint = Paint()
        ..color = color
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3.0;

      // Scale bounding box coordinates to the display size
      final scaleX = size.width / displayWidth;
      final scaleY = size.height / displayHeight;

      final rect = Rect.fromLTRB(
        obj.x1 * scaleX,
        obj.y1 * scaleY,
        obj.x2 * scaleX,
        obj.y2 * scaleY,
      );

      canvas.drawRect(rect, paint);

      // Draw the label and confidence percentage above the box
      final textPainter = TextPainter(
        text: TextSpan(
          text: '${obj.label} ${(obj.confidence * 100).toStringAsFixed(0)}%',
          style: TextStyle(
            color: Colors.white,
            fontSize: 14,
            background: Paint()..color = color,
          ),
        ),
        textDirection: TextDirection.ltr,
      )..layout();

      textPainter.paint(canvas, Offset(rect.left, rect.top - 18));
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

What does this code do?

  • _colorForLabel() assigns blue to people, green to vehicles, orange to pets, and red to everything else. This makes it easy to tell classes apart at a glance.
  • The paint() method loops through every DetectedObject, scales its coordinates to match the on-screen image size, draws a colored rectangle, and paints a label with the confidence score just above the box.
  • shouldRepaint returns true so the canvas re-draws whenever detection results change.
  • Save lib/main.dart.
  • Run the app by executing:
flutter run -d linux
  • You should see a window titled "Vision Detector" with a camera icon placeholder and a **Pick Image & Detect** button at the bottom.
  • Click **Pick Image & Detect**, select any image containing people or objects, and wait a few seconds.
  • You should see colored bounding boxes drawn over detected objects, with labels like "person 92%" or "car 87%" above each box. The AppBar shows the total object count and inference time in milliseconds.

App crashes or shows a red error screen?

  • Check that assets/yolov8m.onnx exists in your project root and is registered in pubspec.yaml under the assets: section.
  • Make sure you closed the _DetectorScreenState class with a } before the DetectionPainter class. A missing brace causes the painter to be nested inside the state class.
  • If you see "Model not initialized", the model path may be wrong. Confirm the path string matches 'assets/yolov8m.onnx' exactly.

Help me debug my Flutter detection app

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/main.dart and the app is running with detection results visible.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart file. Compare it against yours:

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';
import 'src/rust/api/detection.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const VisionApp());
}

class VisionApp extends StatelessWidget {
  const VisionApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Vision Detector',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const DetectorScreen(),
    );
  }
}

class DetectorScreen extends StatefulWidget {
  const DetectorScreen({super.key});

  @override
  State<DetectorScreen> createState() => _DetectorScreenState();
}

class _DetectorScreenState extends State<DetectorScreen> {
  String? _imagePath;
  DetectionResult? _result;
  bool _isLoading = false;
  bool _modelLoaded = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadModel();
  }

  Future<void> _loadModel() async {
    setState(() => _isLoading = true);
    try {
      final modelPath = 'assets/yolov8m.onnx';
      final result = await initModel(modelPath: modelPath);
      setState(() {
        _modelLoaded = true;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Failed to load model: $e';
        _isLoading = false;
      });
    }
  }

  Future<void> _pickImage() async {
    const typeGroup = XTypeGroup(
      label: 'Images',
      extensions: ['jpg', 'jpeg', 'png', 'bmp', 'webp'],
    );
    final file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file == null) return;

    setState(() {
      _imagePath = file.path;
      _result = null;
      _isLoading = true;
      _error = null;
    });

    try {
      final result = await detectObjects(imagePath: file.path);
      setState(() {
        _result = result;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Detection failed: $e';
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('\u{1f50d} Vision Detector'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          if (_result != null)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Center(
                child: Text(
                  '${_result!.objects.length} objects | ${_result!.inferenceTimeMs}ms',
                  style: const TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: _buildImageArea(),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _modelLoaded && !_isLoading ? _pickImage : null,
                  icon: const Icon(Icons.image_search),
                  label: const Text('Pick Image & Detect'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildImageArea() {
    if (_error != null) {
      return Center(child: Text(_error!, style: const TextStyle(color: Colors.red)));
    }
    if (_isLoading && _imagePath == null) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_imagePath == null) {
      return const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.photo_camera, size: 64, color: Colors.grey),
            SizedBox(height: 16),
            Text('Pick an image to detect faces and objects',
                style: TextStyle(fontSize: 16, color: Colors.grey)),
          ],
        ),
      );
    }

    return Stack(
      children: [
        Center(
          child: LayoutBuilder(
            builder: (context, constraints) {
              return Stack(
                children: [
                  Image.file(
                    File(_imagePath!),
                    fit: BoxFit.contain,
                    width: constraints.maxWidth,
                    height: constraints.maxHeight,
                  ),
                  if (_result != null)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: DetectionPainter(
                          result: _result!,
                          imagePath: _imagePath!,
                          displayWidth: constraints.maxWidth,
                          displayHeight: constraints.maxHeight,
                        ),
                      ),
                    ),
                ],
              );
            },
          ),
        ),
        if (_isLoading)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
}

class DetectionPainter extends CustomPainter {
  final DetectionResult result;
  final String imagePath;
  final double displayWidth;
  final double displayHeight;

  DetectionPainter({
    required this.result,
    required this.imagePath,
    required this.displayWidth,
    required this.displayHeight,
  });

  Color _colorForLabel(String label) {
    switch (label) {
      case 'person':
        return Colors.blue;
      case 'car':
      case 'truck':
      case 'bus':
        return Colors.green;
      case 'dog':
      case 'cat':
        return Colors.orange;
      default:
        return Colors.red;
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
    for (final obj in result.objects) {
      final color = _colorForLabel(obj.label);
      final paint = Paint()
        ..color = color
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3.0;

      final scaleX = size.width / displayWidth;
      final scaleY = size.height / displayHeight;

      final rect = Rect.fromLTRB(
        obj.x1 * scaleX,
        obj.y1 * scaleY,
        obj.x2 * scaleX,
        obj.y2 * scaleY,
      );

      canvas.drawRect(rect, paint);

      final textPainter = TextPainter(
        text: TextSpan(
          text: '${obj.label} ${(obj.confidence * 100).toStringAsFixed(0)}%',
          style: TextStyle(
            color: Colors.white,
            fontSize: 14,
            background: Paint()..color = color,
          ),
        ),
        textDirection: TextDirection.ltr,
      )..layout();

      textPainter.paint(canvas, Offset(rect.left, rect.top - 18));
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Your app now loads a YOLOv8 model on startup, lets you pick any image, runs full object detection through Rust, and paints the results right on screen. Next up, you will polish the experience and test with diverse images.

Polish and Test with Multiple Images

Your detection UI is fully wired up. The app picks images, runs Rust-powered inference, and draws bounding boxes with labels.

Now it is time to put the detector through its paces. You will verify that the class-based color coding works correctly across different object types, and experiment with the confidence and NMS thresholds to understand how they affect detection quality.

In this step, get ready to:
  • Confirm color coding works across people, vehicles, animals, and other objects.
  • Test with diverse images and verify the object count and inference time display.
  • Understand and experiment with detection thresholds for optimal results.
Verify class-based color coding and object count

Your app already has a color system built into the _colorForLabel method. Each detected class gets its own color so you can visually distinguish object types at a glance.

How does the color coding work?

The _colorForLabel method in DetectionPainter maps class names to colors. People get blue bounding boxes. Cars, trucks, and buses get green. Dogs and cats get orange. Everything else defaults to red.

The AppBar also shows a live summary with the total object count and inference time in milliseconds. This appears automatically after each detection run.

  • Run your app by executing this command in the terminal:
flutter run -d linux
  • Click Pick Image & Detect.
  • Select a photo from your filesystem that contains at least one person and one vehicle (car, truck, or bus).

After detection completes, you should see blue bounding boxes around people and green bounding boxes around vehicles. The AppBar displays something like 5 objects | 230ms showing the total count and inference time.

Finding test images

Search for "street scene" or "park with people and dogs" on any free image site. Download a few images with mixed object types to properly exercise the color coding. Images with higher resolution will take slightly longer to process due to the resize step.

  • Now pick a second image that contains a dog or cat.
  • Confirm that the animal gets an orange bounding box while any people in the same image are still blue.

All boxes are the same color?

If every bounding box is red, check that your _colorForLabel method in lib/main.dart matches the exact class label strings from the COCO dataset. The labels are lowercase, so person works but Person would fall through to the default red.

Help me debug my color coding

Test with diverse images and tune detection thresholds

The detection engine uses two key thresholds that control result quality. Understanding them helps you decide when to adjust detection sensitivity for different use cases.

What do the two thresholds control?

  • CONFIDENCE_THRESHOLD (set to 0.5) controls how sure the model must be before reporting a detection. Lower values catch more objects but produce more false positives. Higher values give fewer, more reliable results.
  • NMS_IOU_THRESHOLD (set to 0.7) controls how much overlap is allowed between two boxes before one is suppressed. Lower values remove more overlapping boxes. Higher values keep more of them.
  • Test with a crowded scene containing many people or objects clustered together.
  • Check the AppBar for the total count and note the inference time.
  • Test with a simple image containing just one or two objects to confirm it still detects cleanly without noise.

To experiment with different threshold values, you can modify the constants in rust/src/api/detection.rs.

  • In rust/src/api/detection.rs, find these two lines near the top:
const CONFIDENCE_THRESHOLD: f32 = 0.5;
const NMS_IOU_THRESHOLD: f32 = 0.7;
  • Try lowering CONFIDENCE_THRESHOLD to 0.3 and re-running the app to see how many additional (lower-confidence) detections appear.
  • Try raising it to 0.8 to see only the model's most confident predictions.

What should I expect at different threshold values?

At 0.3 confidence, you will see many more boxes, some around objects the model is unsure about. These can overlap and look noisy. At 0.8, you get very few boxes, only the most obvious objects.

The defaults (0.5 confidence, 0.7 IoU) are a well-tested balance for general use. Leave them at these values for the final version of your app.

  • Set CONFIDENCE_THRESHOLD back to 0.5 and NMS_IOU_THRESHOLD back to 0.7 for the final version.
  • Save rust/src/api/detection.rs.
  • Re-run the app and pick one final test image to confirm everything works cleanly.
flutter run -d linux

Your final test should show colored bounding boxes with labels and confidence percentages. The AppBar displays the total count and inference time.

Detection seems slow or hangs?

Very large images (4000+ pixels wide) take longer because the image crate decodes and resizes the full image before inference. Try a smaller image (under 2000px) to compare. Inference time shown in the AppBar is only the model run, not the preprocessing.

Help me debug slow inference

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and your detection app is running smoothly with the default thresholds.

ⓧ I'd like to double check the full code

Here is the complete rust/src/api/detection.rs for reference:

use image::GenericImageView;
use ndarray::{Array, Axis, s};
use ort::{
    inputs,
    session::Session,
    value::TensorRef,
};
use std::path::Path;
use std::sync::OnceLock;

static MODEL: OnceLock<Session> = OnceLock::new();

const CONFIDENCE_THRESHOLD: f32 = 0.5;
const NMS_IOU_THRESHOLD: f32 = 0.7;
const INPUT_SIZE: u32 = 640;

pub struct DetectedObject {
    pub x1: f32,
    pub y1: f32,
    pub x2: f32,
    pub y2: f32,
    pub label: String,
    pub confidence: f32,
}

pub struct DetectionResult {
    pub objects: Vec<DetectedObject>,
    pub inference_time_ms: u64,
}

pub fn init_model(model_path: String) -> Result<String, String> {
    let session = Session::builder()
        .map_err(|e| format!("Failed to create session builder: {e}"))?
        .with_intra_threads(4)
        .map_err(|e| format!("Failed to set threads: {e}"))?
        .commit_from_file(&model_path)
        .map_err(|e| format!("Failed to load model: {e}"))?;

    MODEL.set(session).map_err(|_| "Model already initialized".to_string())?;
    Ok("Model loaded successfully".to_string())
}

pub fn detect_objects(image_path: String) -> Result<DetectionResult, String> {
    let model = MODEL.get().ok_or("Model not initialized. Call init_model first.")?;

    let img = image::open(Path::new(&image_path))
        .map_err(|e| format!("Failed to open image: {e}"))?;

    let (img_width, img_height) = (img.width(), img.height());
    let resized = img.resize_exact(INPUT_SIZE, INPUT_SIZE, image::imageops::FilterType::CatmullRom);

    let mut input = Array::zeros((1, 3, INPUT_SIZE as usize, INPUT_SIZE as usize));
    for pixel in resized.pixels() {
        let x = pixel.0 as usize;
        let y = pixel.1 as usize;
        let [r, g, b, _] = pixel.2.0;
        input[[0, 0, y, x]] = (r as f32) / 255.0;
        input[[0, 1, y, x]] = (g as f32) / 255.0;
        input[[0, 2, y, x]] = (b as f32) / 255.0;
    }

    let start = std::time::Instant::now();
    let outputs = model
        .run(inputs!["images" => TensorRef::from_array_view(&input).map_err(|e| format!("Tensor error: {e}"))?].map_err(|e| format!("Input error: {e}"))?)
        .map_err(|e| format!("Inference failed: {e}"))?;
    let inference_time_ms = start.elapsed().as_millis() as u64;

    let output = outputs["output0"]
        .try_extract_array::<f32>()
        .map_err(|e| format!("Output extraction failed: {e}"))?
        .t()
        .into_owned();

    let mut boxes: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let output_slice = output.slice(s![.., .., 0]);

    for row in output_slice.axis_iter(Axis(0)) {
        let row: Vec<f32> = row.iter().copied().collect();
        let (class_id, prob) = row
            .iter()
            .skip(4)
            .enumerate()
            .map(|(index, value)| (index, *value))
            .reduce(|accum, row| if row.1 > accum.1 { row } else { accum })
            .unwrap_or((0, 0.0));

        if prob < CONFIDENCE_THRESHOLD {
            continue;
        }

        let xc = row[0] / INPUT_SIZE as f32 * img_width as f32;
        let yc = row[1] / INPUT_SIZE as f32 * img_height as f32;
        let w = row[2] / INPUT_SIZE as f32 * img_width as f32;
        let h = row[3] / INPUT_SIZE as f32 * img_height as f32;

        boxes.push((xc - w / 2.0, yc - h / 2.0, xc + w / 2.0, yc + h / 2.0, class_id, prob));
    }

    boxes.sort_by(|a, b| b.5.partial_cmp(&a.5).unwrap_or(std::cmp::Ordering::Equal));
    let filtered = nms(&boxes, NMS_IOU_THRESHOLD);

    let objects: Vec<DetectedObject> = filtered
        .into_iter()
        .map(|(x1, y1, x2, y2, class_id, confidence)| DetectedObject {
            x1,
            y1,
            x2,
            y2,
            label: COCO_LABELS.get(class_id).unwrap_or(&"unknown").to_string(),
            confidence,
        })
        .collect();

    Ok(DetectionResult {
        objects,
        inference_time_ms,
    })
}

fn nms(boxes: &[(f32, f32, f32, f32, usize, f32)], iou_threshold: f32) -> Vec<(f32, f32, f32, f32, usize, f32)> {
    let mut result: Vec<(f32, f32, f32, f32, usize, f32)> = Vec::new();
    let mut remaining = boxes.to_vec();

    while !remaining.is_empty() {
        result.push(remaining[0]);
        remaining = remaining
            .iter()
            .filter(|b| iou(&remaining[0], b) < iou_threshold)
            .copied()
            .collect();
    }

    result
}

fn iou(a: &(f32, f32, f32, f32, usize, f32), b: &(f32, f32, f32, f32, usize, f32)) -> f32 {
    let x1 = a.0.max(b.0);
    let y1 = a.1.max(b.1);
    let x2 = a.2.min(b.2);
    let y2 = a.3.min(b.3);

    let intersection = (x2 - x1).max(0.0) * (y2 - y1).max(0.0);
    let area_a = (a.2 - a.0) * (a.3 - a.1);
    let area_b = (b.2 - b.0) * (b.3 - b.1);
    let union = area_a + area_b - intersection;

    if union <= 0.0 { 0.0 } else { intersection / union }
}

const COCO_LABELS: &[&str] = &[
    "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
    "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
    "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
    "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
    "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
    "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
    "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
    "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
    "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
    "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier",
    "toothbrush",
];

And here is the complete lib/main.dart:

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path_provider/path_provider.dart';
import 'src/rust/api/detection.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const VisionApp());
}

class VisionApp extends StatelessWidget {
  const VisionApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Vision Detector',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const DetectorScreen(),
    );
  }
}

class DetectorScreen extends StatefulWidget {
  const DetectorScreen({super.key});

  @override
  State<DetectorScreen> createState() => _DetectorScreenState();
}

class _DetectorScreenState extends State<DetectorScreen> {
  String? _imagePath;
  DetectionResult? _result;
  bool _isLoading = false;
  bool _modelLoaded = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadModel();
  }

  Future<void> _loadModel() async {
    setState(() => _isLoading = true);
    try {
      final modelPath = 'assets/yolov8m.onnx';
      final result = await initModel(modelPath: modelPath);
      setState(() {
        _modelLoaded = true;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Failed to load model: $e';
        _isLoading = false;
      });
    }
  }

  Future<void> _pickImage() async {
    const typeGroup = XTypeGroup(
      label: 'Images',
      extensions: ['jpg', 'jpeg', 'png', 'bmp', 'webp'],
    );
    final file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file == null) return;

    setState(() {
      _imagePath = file.path;
      _result = null;
      _isLoading = true;
      _error = null;
    });

    try {
      final result = await detectObjects(imagePath: file.path);
      setState(() {
        _result = result;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Detection failed: $e';
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('\uD83D\uDD0D Vision Detector'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          if (_result != null)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Center(
                child: Text(
                  '${_result!.objects.length} objects | ${_result!.inferenceTimeMs}ms',
                  style: const TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: _buildImageArea(),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _modelLoaded && !_isLoading ? _pickImage : null,
                  icon: const Icon(Icons.image_search),
                  label: const Text('Pick Image & Detect'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildImageArea() {
    if (_error != null) {
      return Center(child: Text(_error!, style: const TextStyle(color: Colors.red)));
    }
    if (_isLoading && _imagePath == null) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_imagePath == null) {
      return const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.photo_camera, size: 64, color: Colors.grey),
            SizedBox(height: 16),
            Text('Pick an image to detect faces and objects',
                style: TextStyle(fontSize: 16, color: Colors.grey)),
          ],
        ),
      );
    }

    return Stack(
      children: [
        Center(
          child: LayoutBuilder(
            builder: (context, constraints) {
              return Stack(
                children: [
                  Image.file(
                    File(_imagePath!),
                    fit: BoxFit.contain,
                    width: constraints.maxWidth,
                    height: constraints.maxHeight,
                  ),
                  if (_result != null)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: DetectionPainter(
                          result: _result!,
                          imagePath: _imagePath!,
                          displayWidth: constraints.maxWidth,
                          displayHeight: constraints.maxHeight,
                        ),
                      ),
                    ),
                ],
              );
            },
          ),
        ),
        if (_isLoading)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
}

class DetectionPainter extends CustomPainter {
  final DetectionResult result;
  final String imagePath;
  final double displayWidth;
  final double displayHeight;

  DetectionPainter({
    required this.result,
    required this.imagePath,
    required this.displayWidth,
    required this.displayHeight,
  });

  Color _colorForLabel(String label) {
    switch (label) {
      case 'person':
        return Colors.blue;
      case 'car':
      case 'truck':
      case 'bus':
        return Colors.green;
      case 'dog':
      case 'cat':
        return Colors.orange;
      default:
        return Colors.red;
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
    for (final obj in result.objects) {
      final color = _colorForLabel(obj.label);
      final paint = Paint()
        ..color = color
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3.0;

      final scaleX = size.width / displayWidth;
      final scaleY = size.height / displayHeight;

      final rect = Rect.fromLTRB(
        obj.x1 * scaleX,
        obj.y1 * scaleY,
        obj.x2 * scaleX,
        obj.y2 * scaleY,
      );

      canvas.drawRect(rect, paint);

      final textPainter = TextPainter(
        text: TextSpan(
          text: '${obj.label} ${(obj.confidence * 100).toStringAsFixed(0)}%',
          style: TextStyle(
            color: Colors.white,
            fontSize: 14,
            background: Paint()..color = color,
          ),
        ),
        textDirection: TextDirection.ltr,
      )..layout();

      textPainter.paint(canvas, Offset(rect.left, rect.top - 18));
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Your object detection app is complete and tested. You have a working desktop application that runs YOLOv8 inference in Rust, bridges seamlessly to Flutter, and displays color-coded results in real time. Next up, take on the Secret Mission to add dedicated face detection with facial landmarks.

Secret mission

Add Dedicated Face Detection with Landmarks

Your object detector can spot people in photos, but it treats the whole body as one box. What if you could zero in on faces specifically and pinpoint the exact position of each eye, nose, and mouth corner? In this secret mission, you will integrate a second ONNX model dedicated to face detection with facial landmark output.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • vision_app project folder (contains all source code, ONNX models, and build artifacts).
  • flutter_rust_bridge_codegen cargo binary.
  • Rust toolchain (installed via rustup).
  • Flutter SDK.
  • System packages (clang, cmake, ninja-build, pkg-config, libgtk-3-dev, liblzma-dev, libstdc++-12-dev).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the project further.

  • Your vision_app folder stays on your Desktop with all code and models intact.
  • Flutter, Rust, and system packages remain installed and available for other projects.
  • The ONNX model files (yolov8m.onnx and face_detection.onnx) take up space in the assets/ folder but have no ongoing cost.

Pause - I'll come back to this later

Shut down any running Flutter processes to free up memory. Your files stay on disk for when you return.

  • If the Flutter app is still running, press Ctrl+C in the terminal to stop it.
  • Close any open VS Code or Cursor windows for the project.
  • Your vision_app folder and all installed tools remain ready for your next session.

Delete - I don't want to use this again

Remove all project resources and optionally uninstall the tools. Follow the steps below to clean up completely.

  • Delete the project folder by running:
rm -rf ~/Desktop/vision_app
  • Uninstall flutter_rust_bridge_codegen by running:
cargo uninstall flutter_rust_bridge_codegen
  • Uninstall the Rust toolchain by running:
rustup self uninstall
  • Remove the Flutter SDK by deleting the folder where you installed it and removing the Flutter bin/ path from your shell configuration file (.bashrc or .zshrc).
  • Remove the system packages by running:
sudo apt remove clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev
  • Confirm the project folder is gone by running:
ls ~/Desktop/vision_app

You should see a "No such file or directory" message confirming the folder has been deleted.

Nice Work!

Nice Work!

You just built a production-grade computer vision application powered by Rust and Flutter!

You've learned how to:

  • Built a Rust inference engine using the ort crate to run YOLOv8 object detection via ONNX Runtime, including image preprocessing, tensor operations with ndarray, and non-maximum suppression post-processing.
  • Created a seamless Flutter-Rust bridge using flutter_rust_bridge for zero-boilerplate interop between Dart and native Rust code.
  • Built a Flutter desktop app with a CustomPainter overlay that draws color-coded bounding boxes, labels, and confidence scores on detected objects.
  • Added dedicated face detection with facial landmark visualization using a second ONNX model and a mode toggle in the UI.

Ready to quiz yourself?