use crate::capabilities::validate_device;
use crate::config::DetectionParams;
use crate::detection::postprocess::word_rects_from_mask;
use crate::host_resize::{pad_hw_end, resize_bilinear};
use crate::model::{DetectionGraphConfig, build_detection_graph};
use crate::preprocess::BLACK_VALUE;
use crate::weights::{
HF_DETECTION_ST, HF_DETECTION_ST_FULL, SafetensorsFile, prefer_safetensors_path,
};
use anyhow::{Result, anyhow};
use rlx_core::flow_bridge::compile_options_for_profile;
use rlx_core::flow_util::attach_built_params;
use rlx_flow::CompileProfile;
use rlx_runtime::{CompiledGraph, Device, Session};
use rten_imageproc::RotatedRect;
use rten_tensor::prelude::*;
use rten_tensor::{NdTensor, NdTensorView};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
const DET_STRIDE: usize = 64;
const DET_MAX_SIDE: usize = 1280;
pub struct RlxTextDetector {
weights: SafetensorsFile,
params: DetectionParams,
device: Device,
cache: Mutex<HashMap<(usize, usize), CompiledGraph>>,
forced_hw: Option<(usize, usize)>,
}
impl RlxTextDetector {
pub fn from_path(
path: impl AsRef<Path>,
params: DetectionParams,
device: Device,
) -> Result<Self> {
Self::from_safetensors(path.as_ref(), params, device)
}
pub fn from_safetensors(path: &Path, params: DetectionParams, device: Device) -> Result<Self> {
let forced = std::env::var("OCR_DETECTION_HW").ok().and_then(|s| {
let (h, w) = s.split_once(',')?;
Some((h.trim().parse().ok()?, w.trim().parse().ok()?))
});
Self::new(path, params, device, forced)
}
pub fn from_safetensors_sized(
path: &Path,
params: DetectionParams,
cfg: DetectionGraphConfig,
device: Device,
) -> Result<Self> {
Self::new(path, params, device, Some((cfg.height, cfg.width)))
}
fn new(
path: &Path,
params: DetectionParams,
device: Device,
forced_hw: Option<(usize, usize)>,
) -> Result<Self> {
validate_device(device)?;
Ok(Self {
weights: SafetensorsFile::open(path)?,
params,
device,
cache: Mutex::new(HashMap::new()),
forced_hw,
})
}
fn ensure_compiled(&self, in_h: usize, in_w: usize) -> Result<()> {
let mut cache = self.cache.lock().map_err(|_| anyhow!("lock poisoned"))?;
if cache.contains_key(&(in_h, in_w)) {
return Ok(());
}
let mut wm = self.weights.weight_map()?;
let cfg = DetectionGraphConfig {
batch: 1,
height: in_h,
width: in_w,
};
let (graph, param_map) = build_detection_graph(&mut wm, cfg)?;
let opts = compile_options_for_profile(&CompileProfile::encoder(), self.device);
let mut compiled = Session::new(self.device).compile_with(graph, &opts);
attach_built_params(&mut compiled, param_map, &[]);
cache.insert((in_h, in_w), compiled);
Ok(())
}
fn plan(&self, img_h: usize, img_w: usize) -> (usize, usize, usize, usize) {
let round_up = |v: usize, m: usize| v.div_ceil(m) * m;
match self.forced_hw {
Some((fh, fw)) => {
let scale = (fh as f32 / img_h as f32)
.min(fw as f32 / img_w as f32)
.min(1.0);
let sh = ((img_h as f32 * scale).round() as usize).clamp(1, fh);
let sw = ((img_w as f32 * scale).round() as usize).clamp(1, fw);
(fh, fw, sh, sw)
}
None => {
let long = img_h.max(img_w);
let scale = if long > DET_MAX_SIDE {
DET_MAX_SIDE as f32 / long as f32
} else {
1.0
};
let sh = ((img_h as f32 * scale).round() as usize).max(1);
let sw = ((img_w as f32 * scale).round() as usize).max(1);
(round_up(sh, DET_STRIDE), round_up(sw, DET_STRIDE), sh, sw)
}
}
}
pub fn from_model_dir(dir: &Path, params: DetectionParams, device: Device) -> Result<Self> {
let path =
prefer_safetensors_path(dir, crate::weights::HF_DETECTION_ST, HF_DETECTION_ST_FULL);
if !path.is_file() {
let _fallback = dir.join(HF_DETECTION_ST);
anyhow::bail!(
"missing detection safetensors in {dir:?} (need ocr-detection-full.safetensors); \
run `rlx-ocr-convert` on {:?}",
dir.join("text-detection-ssfbcj81.rten")
);
}
Self::from_safetensors(&path, params, device)
}
pub fn fixed_input_hw(&self) -> Option<(usize, usize)> {
self.forced_hw
}
pub fn detect_words(&self, image: NdTensorView<f32, 3>) -> Result<Vec<RotatedRect>> {
let mask = self.detect_text_pixels(image)?;
Ok(word_rects_from_mask(
mask.view(),
self.params.text_threshold,
self.params.min_area,
))
}
pub fn detect_text_pixels(&self, image: NdTensorView<f32, 3>) -> Result<NdTensor<f32, 2>> {
let [img_chans, img_height, img_width] = image.shape();
let image = image.reshaped([1, img_chans, img_height, img_width]);
let (in_h, in_w, scaled_h, scaled_w) = self.plan(img_height, img_width);
self.ensure_compiled(in_h, in_w)?;
let scaled: NdTensor<f32, 4> = if scaled_h != img_height || scaled_w != img_width {
resize_bilinear(image.view(), scaled_h, scaled_w)
} else {
NdTensor::from_data(image.shape(), image.to_vec())
};
let padded: NdTensor<f32, 4> = if scaled_h != in_h || scaled_w != in_w {
pad_hw_end(scaled.view(), in_h - scaled_h, in_w - scaled_w, BLACK_VALUE)
} else {
scaled
};
let input: Vec<f32> = padded.iter().copied().collect();
let mut cache = self.cache.lock().map_err(|_| anyhow!("lock poisoned"))?;
let compiled = cache
.get_mut(&(in_h, in_w))
.ok_or_else(|| anyhow!("detection graph not compiled for {in_h}x{in_w}"))?;
let flat = compiled
.run(&[("image", input.as_slice())])
.into_iter()
.next()
.ok_or_else(|| anyhow!("detection returned no output"))?;
let mask = NdTensor::from_data([1, 1, in_h, in_w], flat);
let mask = resize_bilinear(
mask.slice((.., .., ..scaled_h, ..scaled_w)),
img_height,
img_width,
);
Ok(mask.into_shape([img_height, img_width]))
}
}