visual_cortex_ocr_onnx/
models.rs1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use sha2::{Digest, Sha256};
5
6use crate::error::OcrError;
7
8const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
11
12#[derive(Debug, Clone, Copy)]
14pub struct ModelFile {
15 pub filename: &'static str,
16 pub url: &'static str,
17 pub sha256: &'static str,
18}
19
20pub const DET_MODEL: ModelFile = ModelFile {
22 filename: "ch_PP-OCRv4_det_infer.onnx",
23 url: "https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv4/ch_PP-OCRv4_det_infer.onnx",
24 sha256: "d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9",
25};
26
27pub const REC_MODEL: ModelFile = ModelFile {
29 filename: "en_PP-OCRv3_rec_infer.onnx",
30 url: "https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/en_PP-OCRv3_rec_infer.onnx",
31 sha256: "ef7abd8bd3629ae57ea2c28b425c1bd258a871b93fd2fe7c433946ade9b5d9ea",
32};
33
34pub const REC_DICT: ModelFile = ModelFile {
36 filename: "en_dict.txt",
37 url: "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/v2.7.0/ppocr/utils/en_dict.txt",
38 sha256: "5662df9d2d03f0e8ca0d3b0649d6acbab904b6a14b3d3521463c71c37c668ce3",
39};
40
41pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
42 let digest = Sha256::digest(bytes);
43 digest.iter().map(|b| format!("{b:02x}")).collect()
44}
45
46pub(crate) fn default_cache_dir() -> PathBuf {
48 dirs::cache_dir()
49 .unwrap_or_else(std::env::temp_dir)
50 .join("visual-cortex")
51}
52
53pub(crate) async fn ensure_cached(file: &ModelFile, dir: &Path) -> Result<PathBuf, OcrError> {
56 let path = dir.join(file.filename);
57 if path.exists() {
58 let bytes = tokio::fs::read(&path)
59 .await
60 .map_err(|e| OcrError::Download {
61 url: file.url.to_string(),
62 reason: format!("cache read failed: {e}"),
63 })?;
64 if sha256_hex(&bytes) == file.sha256 {
65 return Ok(path);
66 }
67 tracing::warn!(
68 "checksum mismatch for cached {}; re-downloading",
69 path.display()
70 );
71 let _ = tokio::fs::remove_file(&path).await;
72 }
73
74 tokio::fs::create_dir_all(dir)
75 .await
76 .map_err(|e| OcrError::Download {
77 url: file.url.to_string(),
78 reason: format!("cache dir creation failed: {e}"),
79 })?;
80 let client = reqwest::Client::builder()
81 .timeout(DOWNLOAD_TIMEOUT)
82 .build()
83 .map_err(|e| OcrError::Download {
84 url: file.url.to_string(),
85 reason: format!("http client build failed: {e}"),
86 })?;
87 let response = client
88 .get(file.url)
89 .send()
90 .await
91 .map_err(|e| OcrError::Download {
92 url: file.url.to_string(),
93 reason: e.to_string(),
94 })?;
95 if !response.status().is_success() {
96 return Err(OcrError::Download {
97 url: file.url.to_string(),
98 reason: format!("HTTP {}", response.status()),
99 });
100 }
101 let bytes = response.bytes().await.map_err(|e| OcrError::Download {
102 url: file.url.to_string(),
103 reason: e.to_string(),
104 })?;
105 let actual = sha256_hex(&bytes);
106 if actual != file.sha256 {
107 return Err(OcrError::ChecksumMismatch {
108 path: path.display().to_string(),
109 expected: file.sha256.to_string(),
110 actual,
111 });
112 }
113 let tmp = path.with_extension("tmp");
116 tokio::fs::write(&tmp, &bytes)
117 .await
118 .map_err(|e| OcrError::Download {
119 url: file.url.to_string(),
120 reason: format!("cache write failed: {e}"),
121 })?;
122 tokio::fs::rename(&tmp, &path)
123 .await
124 .map_err(|e| OcrError::Download {
125 url: file.url.to_string(),
126 reason: format!("cache rename failed: {e}"),
127 })?;
128 Ok(path)
129}
130
131pub async fn ensure_all_cached() -> Result<(PathBuf, PathBuf, PathBuf), OcrError> {
133 let dir = default_cache_dir();
134 Ok((
135 ensure_cached(&DET_MODEL, &dir).await?,
136 ensure_cached(&REC_MODEL, &dir).await?,
137 ensure_cached(&REC_DICT, &dir).await?,
138 ))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
146
147 #[test]
148 fn sha256_hex_computes_known_digest() {
149 assert_eq!(sha256_hex(b"hello"), HELLO_SHA256);
150 }
151
152 #[tokio::test]
153 async fn ensure_cached_accepts_preseeded_valid_file() {
154 let dir = tempfile::tempdir().unwrap();
155 let file = ModelFile {
156 filename: "hello.bin",
157 url: "https://invalid.invalid/hello.bin",
159 sha256: HELLO_SHA256,
160 };
161 std::fs::write(dir.path().join("hello.bin"), b"hello").unwrap();
162 let path = ensure_cached(&file, dir.path()).await.unwrap();
163 assert_eq!(path, dir.path().join("hello.bin"));
164 }
165
166 #[tokio::test]
167 async fn ensure_cached_deletes_corrupt_file_and_errors() {
168 let dir = tempfile::tempdir().unwrap();
169 let file = ModelFile {
170 filename: "hello.bin",
171 url: "http://127.0.0.1:1/hello.bin",
174 sha256: HELLO_SHA256,
175 };
176 std::fs::write(dir.path().join("hello.bin"), b"corrupted").unwrap();
177 let err = ensure_cached(&file, dir.path()).await.unwrap_err();
180 assert!(matches!(err, OcrError::Download { .. }), "got {err:?}");
181 assert!(
182 !dir.path().join("hello.bin").exists(),
183 "corrupt file must be deleted"
184 );
185 }
186}