use std::ffi::CStr;
use std::fmt;
use std::path::Path;
use std::sync::OnceLock;
use ndarray::{ArrayD, IxDyn};
use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::sys;
use ort::value::{Tensor, Value};
#[cfg(target_os = "windows")]
const DEFAULT_DYLIB: &str = "onnxruntime.dll";
#[cfg(target_vendor = "apple")]
const DEFAULT_DYLIB: &str = "libonnxruntime.dylib";
#[cfg(not(any(target_os = "windows", target_vendor = "apple")))]
const DEFAULT_DYLIB: &str = "libonnxruntime.so";
#[derive(Debug)]
pub enum ModelError {
Load(String),
Run(String),
UnsupportedOutput(String),
RuntimeMissing(String),
}
impl fmt::Display for ModelError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Load(message) => write!(f, "could not load the ONNX model: {message}"),
Self::Run(message) => write!(f, "inference failed: {message}"),
Self::UnsupportedOutput(message) => {
write!(f, "unsupported model output: {message}")
}
Self::RuntimeMissing(message) => write!(f, "ONNX Runtime is unavailable: {message}"),
}
}
}
impl std::error::Error for ModelError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Port {
pub name: String,
pub shape: Vec<i64>,
pub dtype: String,
}
pub struct Model {
session: Session,
inputs: Vec<Port>,
outputs: Vec<Port>,
}
impl Model {
pub fn load(path: &str) -> Result<Self, ModelError> {
ensure_runtime()?;
let builder = Session::builder().map_err(|error| ModelError::Load(error.to_string()))?;
let mut builder = builder
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|error| ModelError::Load(error.to_string()))?;
let session = builder
.commit_from_file(path)
.map_err(|error| ModelError::Load(error.to_string()))?;
let inputs = session
.inputs()
.iter()
.map(|input| Port {
name: input.name().to_string(),
shape: tensor_shape(input.dtype()),
dtype: type_name(input.dtype()),
})
.collect();
let outputs = session
.outputs()
.iter()
.map(|output| Port {
name: output.name().to_string(),
shape: tensor_shape(output.dtype()),
dtype: type_name(output.dtype()),
})
.collect();
Ok(Self {
session,
inputs,
outputs,
})
}
pub fn inputs(&self) -> &[Port] {
&self.inputs
}
pub fn outputs(&self) -> &[Port] {
&self.outputs
}
pub fn run(&mut self, input: ArrayD<f32>) -> Result<Vec<ArrayD<f32>>, ModelError> {
let name = self
.inputs
.first()
.map(|port| port.name.clone())
.ok_or_else(|| ModelError::Run("the graph declares no inputs".into()))?;
Ok(self
.run_named(vec![(name, input)])?
.into_iter()
.map(|(_, array)| array)
.collect())
}
pub fn run_named(
&mut self,
inputs: Vec<(String, ArrayD<f32>)>,
) -> Result<Vec<(String, ArrayD<f32>)>, ModelError> {
if inputs.is_empty() {
return Err(ModelError::Run("no inputs were supplied".into()));
}
let declared: Vec<&str> = self.inputs.iter().map(|port| port.name.as_str()).collect();
for (name, _) in &inputs {
if !declared.contains(&name.as_str()) {
return Err(ModelError::Run(format!(
"the graph has no input named {name:?}; it declares {declared:?}"
)));
}
}
let mut values = Vec::with_capacity(inputs.len());
for (name, array) in inputs {
let shape: Vec<i64> = array.shape().iter().map(|&dim| dim as i64).collect();
let (data, _) = array.into_raw_vec_and_offset();
let tensor = Tensor::from_array((shape, data))
.map_err(|error| ModelError::Run(error.to_string()))?;
values.push((name, tensor));
}
let outputs = self
.session
.run(values)
.map_err(|error| ModelError::Run(error.to_string()))?;
self.outputs
.iter()
.map(|port| {
let value = outputs.get(port.name.as_str()).ok_or_else(|| {
ModelError::UnsupportedOutput(format!(
"{} is missing from the result",
port.name
))
})?;
Ok((port.name.clone(), extract_f32(value, &port.name)?))
})
.collect()
}
}
fn extract_f32(value: &Value, name: &str) -> Result<ArrayD<f32>, ModelError> {
let (shape, data) = value.try_extract_tensor::<f32>().map_err(|error| {
ModelError::UnsupportedOutput(format!(
"{name} is not a float32 tensor ({error}); export the model with float outputs"
))
})?;
let dims: Vec<usize> = shape.iter().map(|&dim| dim as usize).collect();
ArrayD::from_shape_vec(IxDyn(&dims), data.to_vec()).map_err(|error| {
ModelError::UnsupportedOutput(format!("{name} has an inconsistent shape: {error}"))
})
}
fn probe_runtime() -> &'static Result<String, String> {
static PROBE: OnceLock<Result<String, String>> = OnceLock::new();
PROBE.get_or_init(|| {
let target = std::env::var_os("ORT_DYLIB_PATH")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_DYLIB.into());
let target = Path::new(&target);
let library = unsafe { libloading::Library::new(target) }
.map_err(|error| format!("could not open {} ({error})", target.display()))?;
let version = {
let entry: libloading::Symbol<unsafe extern "system" fn() -> *const sys::OrtApiBase> =
unsafe { library.get("OrtGetApiBase") }.map_err(|_| {
format!(
"{} is a library, but not ONNX Runtime — it exports no OrtGetApiBase",
target.display()
)
})?;
let base = unsafe { entry() };
if base.is_null() {
return Err(format!("{}: OrtGetApiBase returned null", target.display()));
}
let version = unsafe { CStr::from_ptr(((*base).GetVersionString)()) }
.to_string_lossy()
.into_owned();
if unsafe { ((*base).GetApi)(sys::ORT_API_VERSION) }.is_null() {
return Err(format!(
"{} is ONNX Runtime {version}, which is too old: EventCV needs API level {} \
(ONNX Runtime 1.{} or newer)",
target.display(),
sys::ORT_API_VERSION,
sys::ORT_API_VERSION
));
}
version
};
std::mem::forget(library);
Ok(version)
})
}
pub fn runtime_version() -> Option<String> {
probe_runtime().as_ref().ok().cloned()
}
fn ensure_runtime() -> Result<(), ModelError> {
match probe_runtime() {
Ok(_) => Ok(()),
Err(reason) => Err(ModelError::RuntimeMissing(format!(
"{reason}. EventCV loads ONNX Runtime at run time instead of compiling it in, and the \
wheels ship a copy — reinstall from PyPI (`pip install --force-reinstall eventcv`), \
install one alongside (`pip install eventcv[onnx]`, or conda's `onnxruntime-cpp`), or \
point ORT_DYLIB_PATH at a libonnxruntime you already have"
))),
}
}
fn tensor_shape(value_type: &ort::value::ValueType) -> Vec<i64> {
match value_type {
ort::value::ValueType::Tensor { shape, .. } => shape.iter().copied().collect(),
_ => Vec::new(),
}
}
fn type_name(value_type: &ort::value::ValueType) -> String {
match value_type {
ort::value::ValueType::Tensor { ty, .. } => format!("{ty:?}").to_lowercase(),
other => format!("{other:?}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn runtime_available() -> bool {
!matches!(
Model::load("/nonexistent/model.onnx"),
Err(ModelError::RuntimeMissing(_))
)
}
#[test]
fn a_missing_file_is_a_load_error() {
if !runtime_available() {
return;
}
let error = Model::load("/nonexistent/model.onnx")
.err()
.expect("loading a missing file must fail");
assert!(matches!(error, ModelError::Load(_)));
assert!(error.to_string().contains("ONNX"));
}
#[test]
fn a_non_onnx_file_is_a_load_error() {
if !runtime_available() {
return;
}
let mut path = std::env::temp_dir();
path.push(format!("eventcv-not-a-model-{}.onnx", std::process::id()));
std::fs::write(&path, b"this is not a protobuf").unwrap();
let error = Model::load(path.to_str().unwrap())
.err()
.expect("loading a non-model must fail");
assert!(matches!(error, ModelError::Load(_)));
std::fs::remove_file(&path).ok();
}
}