use std::path::{Path, PathBuf};
use rapid_structure_rs::evaluation::ocr::{
EvaluationCase, EvaluationReport, EvaluationSummary, evaluate_case,
};
use rapid_structure_rs::{FormulaPolicy, TablePolicy, WordOutputMode, to_output_items};
use serde_json::{Value, json};
use super::error::ServeError;
use super::server::{EngineLoad, ServeShared, recognize_with, text_request};
fn evaluation_disabled() -> ServeError {
ServeError::Evaluation {
reason: "POST /api/evaluate is disabled because the server was started without \
--eval-root <DIR>; evaluation reads local paths (a manifest and the images it \
references) instead of uploaded bytes, so it must be enabled explicitly with a \
sandbox directory"
.to_string(),
}
}
pub(super) const IOU_THRESHOLD: f32 = 0.5;
pub(super) fn parse_manifest_body(bytes: &[u8]) -> Result<PathBuf, ServeError> {
let value: Value = serde_json::from_slice(bytes).map_err(|_| ServeError::BadRequest)?;
let object = value.as_object().ok_or(ServeError::BadRequest)?;
if object.len() != 1 {
return Err(ServeError::BadRequest);
}
let raw = object
.get("manifest")
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.ok_or(ServeError::BadRequest)?;
Ok(PathBuf::from(raw))
}
#[derive(Debug, Clone)]
pub(super) struct EvalRoot {
root: PathBuf,
}
impl EvalRoot {
pub fn new(directory: &Path) -> Result<Self, String> {
if !directory.is_dir() {
return Err(format!(
"{} does not exist or is not a directory",
directory.display()
));
}
let root = std::fs::canonicalize(directory)
.map_err(|error| format!("cannot canonicalize {}: {error}", directory.display()))?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn resolve(&self, requested: &Path, what: &str) -> Result<PathBuf, ServeError> {
let canonical =
std::fs::canonicalize(requested).map_err(|error| ServeError::Evaluation {
reason: format!(
"cannot resolve {what} `{}` inside the --eval-root sandbox {}: {error}",
requested.display(),
self.root.display()
),
})?;
if !canonical.starts_with(&self.root) {
return Err(ServeError::Evaluation {
reason: format!(
"{what} `{}` resolves to `{}`, which is outside the --eval-root sandbox {}; \
evaluation only reads files inside that directory",
requested.display(),
canonical.display(),
self.root.display()
),
});
}
Ok(canonical)
}
pub fn resolve_case(
&self,
manifest: &Path,
case: &EvaluationCase,
) -> Result<PathBuf, ServeError> {
let requested = resolve_image(manifest, case);
self.resolve(&requested, &format!("the image of case `{}`", case.image))
}
}
pub(super) fn load_cases(manifest: &Path, limit: usize) -> Result<Vec<EvaluationCase>, ServeError> {
let text = std::fs::read_to_string(manifest).map_err(|error| ServeError::Evaluation {
reason: format!("cannot read the manifest `{}`: {error}", manifest.display()),
})?;
let cases: Vec<EvaluationCase> =
serde_json::from_str(&text).map_err(|error| ServeError::Evaluation {
reason: format!(
"the manifest `{}` is not a `rapidstruct evaluate` manifest (expected a JSON array \
of `{{ image, text, boxes }}`): {error}",
manifest.display()
),
})?;
if cases.is_empty() {
return Err(ServeError::Evaluation {
reason: format!("the manifest `{}` lists no cases", manifest.display()),
});
}
if cases.len() > limit {
return Err(ServeError::Evaluation {
reason: format!(
"the manifest `{}` lists {} cases, over the --max-eval-cases limit of {limit}; \
split the manifest or raise the limit",
manifest.display(),
cases.len()
),
});
}
Ok(cases)
}
pub(super) fn resolve_image(manifest: &Path, case: &EvaluationCase) -> PathBuf {
let image = Path::new(&case.image);
if image.is_absolute() {
image.to_path_buf()
} else {
manifest
.parent()
.unwrap_or_else(|| Path::new("."))
.join(image)
}
}
pub(super) fn run(shared: &ServeShared, manifest: &Path) -> Result<Value, ServeError> {
let root = shared.eval_root().ok_or_else(evaluation_disabled)?.clone();
let manifest = root.resolve(manifest, "the manifest")?;
let cases = load_cases(&manifest, shared.max_eval_cases())?;
match shared.ensure_engine_loaded(false) {
EngineLoad::Ready => {}
EngineLoad::BlockedModelsMissing => return Err(shared.models_missing_error()),
EngineLoad::Failed => {
return Err(ServeError::EngineUnavailable {
reason: shared
.engine_failure_reason()
.unwrap_or_else(|| "the OCR engine could not be created".to_string()),
});
}
}
let mut reports = Vec::with_capacity(cases.len());
for case in cases {
let image = root.resolve_case(&manifest, &case)?;
let bytes = std::fs::read(&image).map_err(|error| ServeError::Evaluation {
reason: format!(
"cannot read the image `{}` for case `{}`: {error}",
image.display(),
case.image
),
})?;
let request = text_request(
std::sync::Arc::from(bytes.into_boxed_slice()),
None,
FormulaPolicy::default(),
TablePolicy::default(),
WordOutputMode::Off,
);
let output = recognize_with(shared, request)?;
let metrics = evaluate_case(
&case.text,
&case.boxes,
&to_output_items(&output),
IOU_THRESHOLD,
);
reports.push(EvaluationReport {
image: case.image,
metrics,
});
}
let summary = EvaluationSummary::from_cases(reports);
let mut report = crate::evaluation_report_value(&summary).map_err(|_| ServeError::Internal)?;
if let Some(object) = report.as_object_mut() {
object.insert("iou_threshold".to_string(), json!(IOU_THRESHOLD));
object.insert(
"manifest_file".to_string(),
json!(
manifest
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
),
);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{EvalRoot, IOU_THRESHOLD, load_cases, parse_manifest_body, resolve_image};
use crate::serve::error::ServeError;
use rapid_structure_rs::evaluation::ocr::EvaluationCase;
fn sandbox_root() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/m1-evaluate-sandbox")
}
#[test]
fn the_eval_root_must_be_a_real_directory() {
let root = sandbox_root().join("root-check");
std::fs::create_dir_all(&root).expect("fixture dir");
EvalRoot::new(&root).expect("an existing directory is a valid sandbox");
let missing = root.join("definitely-not-here");
let detail = EvalRoot::new(&missing).expect_err("a missing root must be refused");
assert!(detail.contains("definitely-not-here"), "{detail}");
let file = root.join("not-a-directory.json");
std::fs::write(&file, b"[]").expect("write");
let detail = EvalRoot::new(&file).expect_err("a file is not a sandbox root");
assert!(detail.contains("not a directory"), "{detail}");
std::fs::remove_dir_all(sandbox_root().join("root-check")).ok();
}
#[test]
fn the_sandbox_resolves_inside_and_refuses_outside() {
let root = sandbox_root().join("prefix-check/sandbox");
std::fs::create_dir_all(root.join("nested")).expect("fixture dirs");
std::fs::write(root.join("nested/inside.png"), b"x").expect("write");
let elsewhere = sandbox_root().join("prefix-check/sandbox-elsewhere");
std::fs::create_dir_all(&elsewhere).expect("fixture dir");
std::fs::write(elsewhere.join("outside.png"), b"x").expect("write");
let sandbox = EvalRoot::new(&root).expect("valid sandbox");
let inside = sandbox
.resolve(&root.join("nested/inside.png"), "the image")
.expect("an in-root file is allowed");
assert!(inside.starts_with(sandbox.root()));
let error = sandbox
.resolve(&elsewhere.join("outside.png"), "the image")
.expect_err("an out-of-root file must be refused");
let reason = error.detail()["reason"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
reason.contains("outside the --eval-root sandbox"),
"{reason}"
);
assert!(reason.contains("outside.png"), "{reason}");
let error = sandbox
.resolve(&root.join("nested/missing.png"), "the image")
.expect_err("a missing file must be refused");
assert!(
error.detail()["reason"]
.as_str()
.unwrap_or_default()
.contains("missing.png"),
"{}",
error.detail()
);
std::fs::remove_dir_all(sandbox_root().join("prefix-check")).ok();
}
#[test]
fn the_evaluate_body_carries_only_a_manifest_path() {
assert_eq!(
parse_manifest_body(br#"{"manifest":"D:\\m\\golden.json"}"#).expect("ok"),
std::path::PathBuf::from("D:\\m\\golden.json")
);
assert_eq!(
parse_manifest_body(br#"{"manifest":" D:\\m\\golden.json "}"#).expect("trimmed"),
std::path::PathBuf::from("D:\\m\\golden.json")
);
for bad in [
&br#"{"manifest":""}"#[..],
&br#"{"manifest":" "}"#[..],
&br#"{"manifest":null}"#[..],
&br#"{"manifest":"a.json","iou_threshold":0.9}"#[..],
&br#"{"path":"a.json"}"#[..],
&br#"{}"#[..],
&br#"[]"#[..],
&br#"not json"#[..],
] {
assert!(
matches!(parse_manifest_body(bad), Err(ServeError::BadRequest)),
"{}",
String::from_utf8_lossy(bad)
);
}
}
#[test]
fn a_bad_manifest_is_rejected_with_a_locating_reason() {
let root =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/m4-evaluate-tests");
std::fs::create_dir_all(&root).expect("fixture dir");
let missing = root.join("definitely-not-here.json");
let error = load_cases(&missing, 4).expect_err("missing manifest");
let reason = error.detail()["reason"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(reason.contains("definitely-not-here.json"), "{reason}");
assert_eq!(error.status_code(), 400);
assert_eq!(error.code(), "bad_request");
let not_a_manifest = root.join("not-a-manifest.json");
std::fs::write(¬_a_manifest, b"{\"cases\":[]}").expect("write");
let reason = load_cases(¬_a_manifest, 4)
.expect_err("object instead of array")
.detail()["reason"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(reason.contains("not-a-manifest.json"), "{reason}");
let empty = root.join("empty.json");
std::fs::write(&empty, b"[]").expect("write");
let reason = load_cases(&empty, 4).expect_err("empty manifest").detail()["reason"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(reason.contains("lists no cases"), "{reason}");
let three = root.join("three.json");
std::fs::write(
&three,
br#"[{"image":"a.png","text":"a"},{"image":"b.png","text":"b"},{"image":"c.png","text":"c"}]"#,
)
.expect("write");
let cases = load_cases(&three, 3).expect("exactly the limit is allowed");
assert_eq!(cases.len(), 3);
let reason = load_cases(&three, 2).expect_err("over the limit").detail()["reason"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(reason.contains("--max-eval-cases"), "{reason}");
assert!(reason.contains('3'), "{reason}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn case_images_resolve_relative_to_the_manifest() {
let manifest = Path::new("D:\\models\\golden\\manifest.json");
let relative = EvaluationCase {
image: "01.png".to_string(),
text: "x".to_string(),
boxes: Vec::new(),
};
assert_eq!(
resolve_image(manifest, &relative),
Path::new("D:\\models\\golden\\01.png").to_path_buf()
);
let absolute = EvaluationCase {
image: "D:\\images\\02.png".to_string(),
text: "x".to_string(),
boxes: Vec::new(),
};
assert_eq!(
resolve_image(manifest, &absolute),
Path::new("D:\\images\\02.png").to_path_buf()
);
}
#[test]
fn the_iou_threshold_matches_the_cli_default() {
assert_eq!(IOU_THRESHOLD, 0.5);
}
}