Skip to main content

media_pp/elements/sink/
ort_detector.rs

1use std::{path::Path, sync::Arc};
2
3use crate::pp_log::{PpLog, pp_error, pp_info};
4use ffmpeg_next as ffmpeg;
5use ndarray::{Array4, Axis, s};
6use ort::{inputs, session::Session, value::TensorRef};
7use thiserror::Error as ThisError;
8
9use crate::{
10    buffer::MediaBuffer,
11    contract::{InputContract, MediaKind, MemoryDomain, PortContract},
12    control::ControlMsg,
13    element::{Element, ElementType, Sink, element_pp_log},
14    error::Result,
15};
16
17/// One detected object, in the pixel space of the frame [`OrtDetector`]
18/// was handed — see its own doc comment for why no further rescaling is
19/// needed to place this on top of that same frame.
20#[derive(Debug, Clone, Copy)]
21pub struct Detection {
22    /// Index into whatever label set the model was trained on — see
23    /// [`COCO_CLASS_LABELS`] for stock Ultralytics YOLOv8/v11 weights.
24    pub class_id: usize,
25    /// Model confidence score after thresholding and NMS.
26    pub score: f32,
27    /// Top-left corner (not center — already converted from the model's
28    /// own center/width/height encoding).
29    pub x: f32,
30    /// Top edge in input-frame pixels.
31    pub y: f32,
32    /// Detection-box width in input-frame pixels.
33    pub width: f32,
34    /// Detection-box height in input-frame pixels.
35    pub height: f32,
36}
37
38/// Convenience label table for the 80 COCO classes stock Ultralytics
39/// YOLOv8/v11 weights are trained on. Meaningless for a custom-trained
40/// model with a different class set — index [`Detection::class_id`] into
41/// your own labels in that case instead.
42#[rustfmt::skip]
43pub const COCO_CLASS_LABELS: [&str; 80] = [
44    "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light",
45    "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant",
46    "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",
47    "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle",
48    "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", "broccoli",
49    "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "dining table", "toilet",
50    "tv", "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator",
51    "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush",
52];
53
54/// Errors specific to `OrtDetector`. Converts into the crate-wide
55/// `Error` via `?` (see [`crate::error::Error`]).
56#[derive(Debug, ThisError)]
57pub enum OrtDetectorError {
58    /// ONNX Runtime rejected model loading, tensor binding, or inference.
59    #[error("onnxruntime error: {0}")]
60    Ort(#[from] ort::Error),
61    /// The input video is not RGB24 as required by the model tensor conversion.
62
63    #[error(
64        "OrtDetector only accepts RGB24 Video frames, got {0:?}; \
65         link it straight after a SwScaler configured with Pixel::RGB24"
66    )]
67    UnsupportedFormat(ffmpeg::format::Pixel),
68    /// The sink received a buffer other than decoded video.
69
70    #[error(
71        "OrtDetector only accepts decoded Video frames, got a {0}; \
72         link it straight after a SwScaler"
73    )]
74    UnsupportedBuffer(&'static str),
75}
76
77/// Terminal sink that runs a YOLOv8/v11-style ONNX object-detection model
78/// (an Ultralytics export: one image input, one `[1, 4 + num_classes,
79/// num_boxes]` output, box coordinates as center/width/height) on every
80/// incoming frame via `ort`, then hands the decoded, NMS-filtered
81/// detections to a plain closure — same "bring your own closure" shape as
82/// [`crate::elements::AppSink`], except the closure gets structured
83/// [`Detection`]s instead of a raw [`MediaBuffer`].
84///
85/// Expects every frame's pixel dimensions to already match the model's own
86/// input resolution (e.g. 640x640 for stock YOLOv8/11 weights) and its
87/// format to be `Pixel::RGB24` — put a [`crate::elements::SwScaler`]
88/// configured that way directly upstream. Because of that, a detection's
89/// box coordinates need no rescaling back to some "original" resolution:
90/// they come straight out of the model in the exact same pixel space as
91/// the frame handed to the closure.
92///
93/// Input/output tensors are bound by position, not by name (`images` /
94/// `output0` aren't assumed) — whatever the export happens to call its
95/// single input and single output, this binds to index `0` of each.
96///
97/// NMS is per-class (a box only suppresses another box of the *same*
98/// `class_id`), matching Ultralytics' own default (non-agnostic) NMS.
99pub struct OrtDetector<F> {
100    pp_log: PpLog,
101    name: Arc<str>,
102    session: Session,
103    conf_threshold: f32,
104    iou_threshold: f32,
105    on_detections: F,
106}
107
108impl<F> OrtDetector<F>
109where
110    F: FnMut(&ffmpeg::frame::Video, &[Detection]) -> Result<()> + Send + 'static,
111{
112    /// `conf_threshold` drops candidate boxes below that class score before
113    /// NMS ever sees them; `iou_threshold` is how much two same-class boxes
114    /// may overlap before the lower-scoring one is suppressed as a
115    /// duplicate of the other.
116    pub fn new(
117        name: impl Into<String>,
118        model_path: impl AsRef<Path>,
119        conf_threshold: f32,
120        iou_threshold: f32,
121        on_detections: F,
122    ) -> Result<Self> {
123        let model_path_display = model_path.as_ref().display().to_string();
124        let session = Session::builder()
125            .map_err(OrtDetectorError::from)?
126            .commit_from_file(model_path)
127            .map_err(OrtDetectorError::from)?;
128        let name: Arc<str> = name.into().into();
129        let pp_log = element_pp_log(ElementType::OrtDetector, &name, None);
130        pp_info!(
131            pp_log: &pp_log,
132            "model loaded: path={model_path_display}, conf_threshold={conf_threshold}, iou_threshold={iou_threshold}"
133        );
134        Ok(Self {
135            name,
136            pp_log,
137            session,
138            conf_threshold,
139            iou_threshold,
140            on_detections,
141        })
142    }
143
144    /// Builds the `[1, 3, height, width]` normalized input tensor from
145    /// `frame`'s packed RGB24 bytes (skipping over `stride`'s per-row
146    /// padding, which is usually wider than `width * 3`), runs inference,
147    /// then decodes + NMS-filters the raw output into [`Detection`]s.
148    fn detect(&mut self, frame: &ffmpeg::frame::Video) -> Result<Vec<Detection>> {
149        let width = frame.width() as usize;
150        let height = frame.height() as usize;
151        let stride = frame.stride(0);
152        let data = frame.data(0);
153
154        let mut input = Array4::<f32>::zeros((1, 3, height, width));
155        for y in 0..height {
156            let row = &data[y * stride..y * stride + width * 3];
157            for x in 0..width {
158                let pixel = &row[x * 3..x * 3 + 3];
159                input[[0, 0, y, x]] = pixel[0] as f32 / 255.0;
160                input[[0, 1, y, x]] = pixel[1] as f32 / 255.0;
161                input[[0, 2, y, x]] = pixel[2] as f32 / 255.0;
162            }
163        }
164
165        let outputs = self
166            .session
167            .run(inputs![
168                TensorRef::from_array_view(&input).map_err(OrtDetectorError::from)?
169            ])
170            .map_err(OrtDetectorError::from)?;
171        // `[1, 4 + num_classes, num_boxes]` -> transpose -> `[num_boxes, 4 +
172        // num_classes, 1]` -> drop the now-trailing batch axis -> `[num_boxes,
173        // 4 + num_classes]`, one row per candidate box.
174        let output = outputs[0]
175            .try_extract_array::<f32>()
176            .map_err(OrtDetectorError::from)?
177            .t()
178            .into_owned();
179        let output = output.slice(s![.., .., 0]);
180
181        let mut candidates = Vec::new();
182        for row in output.axis_iter(Axis(0)) {
183            let (class_id, score) = row
184                .iter()
185                // first 4 columns are the box, not a class score
186                .skip(4)
187                .enumerate()
188                .map(|(index, value)| (index, *value))
189                .reduce(|best, next| if next.1 > best.1 { next } else { best })
190                .expect("model output has at least one class column");
191            if score < self.conf_threshold {
192                continue;
193            }
194            let (cx, cy, w, h) = (row[0usize], row[1usize], row[2usize], row[3usize]);
195            candidates.push(Detection {
196                class_id,
197                score,
198                x: cx - w / 2.0,
199                y: cy - h / 2.0,
200                width: w,
201                height: h,
202            });
203        }
204
205        Ok(non_max_suppression(candidates, self.iou_threshold))
206    }
207}
208
209fn iou(a: &Detection, b: &Detection) -> f32 {
210    let (ax2, ay2) = (a.x + a.width, a.y + a.height);
211    let (bx2, by2) = (b.x + b.width, b.y + b.height);
212    let overlap_w = (ax2.min(bx2) - a.x.max(b.x)).max(0.0);
213    let overlap_h = (ay2.min(by2) - a.y.max(b.y)).max(0.0);
214    let intersection = overlap_w * overlap_h;
215    let union = a.width * a.height + b.width * b.height - intersection;
216    if union <= 0.0 {
217        0.0
218    } else {
219        intersection / union
220    }
221}
222
223/// Highest score first, then greedily keeps each box that doesn't overlap
224/// (past `iou_threshold`) an already-kept box of the same `class_id`.
225fn non_max_suppression(mut candidates: Vec<Detection>, iou_threshold: f32) -> Vec<Detection> {
226    candidates.sort_by(|a, b| b.score.total_cmp(&a.score));
227
228    let mut kept: Vec<Detection> = Vec::with_capacity(candidates.len());
229    'candidates: for candidate in candidates {
230        for already_kept in &kept {
231            if already_kept.class_id == candidate.class_id
232                && iou(already_kept, &candidate) > iou_threshold
233            {
234                continue 'candidates;
235            }
236        }
237        kept.push(candidate);
238    }
239    kept
240}
241
242impl<F> Element for OrtDetector<F>
243where
244    F: FnMut(&ffmpeg::frame::Video, &[Detection]) -> Result<()> + Send + 'static,
245{
246    fn name(&self) -> Arc<str> {
247        self.name.clone()
248    }
249
250    fn element_type(&self) -> ElementType {
251        ElementType::OrtDetector
252    }
253
254    fn pp_log(&self) -> &PpLog {
255        &self.pp_log
256    }
257
258    fn pp_log_mut(&mut self) -> &mut PpLog {
259        &mut self.pp_log
260    }
261}
262
263impl<F> Sink for OrtDetector<F>
264where
265    F: FnMut(&ffmpeg::frame::Video, &[Detection]) -> Result<()> + Send + 'static,
266{
267    /// Reads the pixels on the CPU to build its input tensor.
268    fn input_contract(&self) -> InputContract {
269        InputContract::Fixed(PortContract::frame(
270            MediaKind::VideoFrame,
271            MemoryDomain::System,
272        ))
273    }
274
275    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
276        match buf {
277            MediaBuffer::Video(frame) => {
278                if frame.format() != ffmpeg::format::Pixel::RGB24 {
279                    pp_error!(self, "unsupported pixel format: {:?}", frame.format());
280                    return Err(OrtDetectorError::UnsupportedFormat(frame.format()).into());
281                }
282                let detections = self
283                    .detect(&frame)
284                    .inspect_err(|error| pp_error!(self, "detect failed: {error}"))?;
285                (self.on_detections)(&frame, &detections)
286            }
287            MediaBuffer::Eos => Ok(()),
288            MediaBuffer::Packet(_) => {
289                pp_error!(self, "unsupported buffer: Packet");
290                Err(OrtDetectorError::UnsupportedBuffer("Packet").into())
291            }
292            MediaBuffer::Audio(_) => {
293                pp_error!(self, "unsupported buffer: Audio");
294                Err(OrtDetectorError::UnsupportedBuffer("Audio").into())
295            }
296        }
297    }
298
299    fn control(&mut self, _msg: ControlMsg) -> Result<()> {
300        // Terminal, same as AppSink/D3d12Renderer: nothing buffered or
301        // downstream to flush/forward for any ControlMsg.
302        Ok(())
303    }
304}