1use std::collections::BTreeMap;
8use std::fs::File;
9use std::io::{Cursor, Read, Seek};
10use std::path::Path;
11
12use super::error::invalid;
13use super::ModelConfigError;
14
15pub const EDGEFIRST_JSON: &str = "edgefirst.json";
17pub const LABELS_TXT: &str = "labels.txt";
19pub const HF_CONFIG: &str = "config.json";
21pub const HF_PREPROCESSOR: &str = "preprocessor_config.json";
23
24const MAX_SIDECAR: u64 = 16 << 20;
26
27pub const ONNX_EDGEFIRST: &str = "edgefirst";
29pub const ONNX_LABELS: &str = "labels";
31pub const ONNX_HF_CONFIG: &str = "hf_config";
33pub const ONNX_HF_PREPROCESSOR: &str = "hf_preprocessor_config";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct ConfigFile {
40 pub name: String,
42 pub text: String,
44}
45
46impl ConfigFile {
47 pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
49 Self {
50 name: name.into(),
51 text: text.into(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct ModelFiles {
61 pub edgefirst_json: Option<ConfigFile>,
63 pub labels_txt: Option<ConfigFile>,
65 pub hf_config: Option<ConfigFile>,
67 pub hf_preprocessor: Option<ConfigFile>,
69 pub metadata: BTreeMap<String, String>,
72 pub payload_len: Option<u64>,
75}
76
77impl ModelFiles {
78 pub fn is_empty(&self) -> bool {
80 self.edgefirst_json.is_none()
81 && self.labels_txt.is_none()
82 && self.hf_config.is_none()
83 && self.hf_preprocessor.is_none()
84 && self.metadata.is_empty()
85 }
86
87 fn set(slot: &mut Option<ConfigFile>, name: &str, text: &str) {
88 if slot.is_none() {
89 *slot = Some(ConfigFile {
90 name: name.to_owned(),
91 text: text.to_owned(),
92 });
93 }
94 }
95
96 fn add_onnx_props(&mut self, props: BTreeMap<String, String>) {
97 for (key, value) in &props {
98 match key.as_str() {
99 ONNX_EDGEFIRST | EDGEFIRST_JSON => Self::set(&mut self.edgefirst_json, key, value),
100 ONNX_LABELS => Self::set(&mut self.labels_txt, key, value),
101 ONNX_HF_CONFIG => Self::set(&mut self.hf_config, key, value),
102 ONNX_HF_PREPROCESSOR => Self::set(&mut self.hf_preprocessor, key, value),
103 _ => {}
104 }
105 }
106 self.metadata.extend(props);
107 }
108
109 #[cfg(feature = "model-files")]
110 fn add_entries(&mut self, entries: Vec<(String, String)>) {
111 for (name, text) in entries {
112 let base = name.rsplit('/').next().unwrap_or(&name);
113 match base {
114 EDGEFIRST_JSON => Self::set(&mut self.edgefirst_json, base, &text),
115 LABELS_TXT => Self::set(&mut self.labels_txt, base, &text),
116 HF_CONFIG => Self::set(&mut self.hf_config, base, &text),
117 HF_PREPROCESSOR => Self::set(&mut self.hf_preprocessor, base, &text),
118 _ => {}
119 }
120 self.metadata.insert(name, text);
121 }
122 }
123}
124
125pub fn read_embedded(path: impl AsRef<Path>) -> Result<ModelFiles, ModelConfigError> {
135 let path = path.as_ref();
136 let io = |source| ModelConfigError::Io {
137 path: path.to_owned(),
138 source,
139 };
140 let mut file = File::open(path).map_err(|e| {
142 io(if path.is_dir() {
143 std::io::ErrorKind::IsADirectory.into()
144 } else {
145 e
146 })
147 })?;
148 let meta = file.metadata().map_err(io)?;
149 if meta.is_dir() {
150 return Err(io(std::io::ErrorKind::IsADirectory.into()));
151 }
152 let len = meta.len();
153 let onnx = path
154 .extension()
155 .is_some_and(|e| e.eq_ignore_ascii_case("onnx"));
156 read_from(&mut file, len, onnx)
157}
158
159pub fn read_embedded_bytes(bytes: &[u8], onnx: bool) -> Result<ModelFiles, ModelConfigError> {
168 read_from(&mut Cursor::new(bytes), bytes.len() as u64, onnx)
169}
170
171fn read_from<R: Read + Seek>(
172 r: &mut R,
173 len: u64,
174 onnx: bool,
175) -> Result<ModelFiles, ModelConfigError> {
176 let mut files = ModelFiles::default();
177 #[cfg(feature = "model-files")]
178 if let Some(trailer) = super::ziptrailer::read_trailer(r, len)? {
179 files.payload_len = Some(trailer.payload_len);
180 files.add_entries(trailer.entries);
181 }
182 let model_len = files.payload_len.unwrap_or(len);
184 let mut first = [0u8; 1];
185 r.seek(std::io::SeekFrom::Start(0))
186 .map_err(|e| ModelConfigError::Container(e.to_string()))?;
187 let looks_onnx = model_len > 0 && r.read_exact(&mut first).is_ok() && first[0] == 0x08;
188 if onnx || looks_onnx {
189 match super::onnx::metadata_props(r, model_len) {
190 Ok(props) => files.add_onnx_props(props),
191 Err(e) if onnx => return Err(e),
192 Err(e) => log::debug!("not an ONNX model: {e}"),
193 }
194 }
195 Ok(files)
196}
197
198pub fn read_sidecars(dir: impl AsRef<Path>) -> Result<ModelFiles, ModelConfigError> {
206 let dir = dir.as_ref();
207 let mut files = ModelFiles::default();
208 for (name, slot) in [
209 (EDGEFIRST_JSON, &mut files.edgefirst_json),
210 (LABELS_TXT, &mut files.labels_txt),
211 (HF_CONFIG, &mut files.hf_config),
212 (HF_PREPROCESSOR, &mut files.hf_preprocessor),
213 ] {
214 let path = dir.join(name);
215 match read_capped(&path) {
216 Ok(text) => {
217 *slot = Some(ConfigFile {
218 name: name.to_owned(),
219 text,
220 })
221 }
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
223 Err(source) => return Err(ModelConfigError::Io { path, source }),
224 }
225 }
226 Ok(files)
227}
228
229fn read_capped(path: &Path) -> std::io::Result<String> {
230 let mut text = String::new();
231 File::open(path)?
232 .take(MAX_SIDECAR + 1)
233 .read_to_string(&mut text)?;
234 if text.len() as u64 > MAX_SIDECAR {
235 return Err(std::io::Error::new(
236 std::io::ErrorKind::FileTooLarge,
237 "larger than 16 MiB",
238 ));
239 }
240 Ok(text)
241}
242
243pub(crate) fn parse_labels(text: &str) -> Vec<String> {
246 text.lines()
247 .map(str::trim)
248 .filter(|l| !l.is_empty())
249 .map(str::to_owned)
250 .collect()
251}
252
253pub(crate) fn trim_labels(
257 file: &str,
258 field: &str,
259 labels: Vec<String>,
260) -> Result<Vec<String>, ModelConfigError> {
261 let mut trimmed = 0;
262 let labels = labels
263 .into_iter()
264 .enumerate()
265 .map(|(i, name)| match name.trim() {
266 "" => Err(invalid(
267 file,
268 &format!("{field}[{i}]"),
269 "is empty after trimming whitespace",
270 )),
271 t if t.len() == name.len() => Ok(name),
272 t => {
273 trimmed += 1;
274 Ok(t.to_owned())
275 }
276 })
277 .collect::<Result<Vec<_>, _>>()?;
278 if trimmed > 0 {
279 log::debug!("{file} {field}: trimmed whitespace from {trimmed} class names");
280 }
281 Ok(labels)
282}
283
284pub(crate) fn usable_labels(
287 parsed: Result<Vec<String>, ModelConfigError>,
288 notes: &mut Vec<String>,
289) -> Option<Vec<String>> {
290 parsed
291 .map_err(|e| {
292 let note = format!("{e}; its class names are ignored");
293 log::warn!("{note}");
294 notes.push(note);
295 })
296 .ok()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn onnx_both_key_sets_map_to_fields() {
305 let bytes = super::super::onnx::tests::model_proto(
306 &[
307 ("edgefirst.json", "{\"legacy\":1}"),
308 ("labels", "cat\ndog"),
309 ("hf_config", "{}"),
310 ("hf_preprocessor_config", "{\"size\":1}"),
311 ("names", "{0: 'cat'}"),
312 ],
313 32,
314 );
315 let f = read_embedded_bytes(&bytes, true).unwrap();
316 assert_eq!(f.edgefirst_json.unwrap().name, "edgefirst.json");
317 assert_eq!(parse_labels(&f.labels_txt.unwrap().text), ["cat", "dog"]);
318 assert_eq!(f.hf_config.unwrap().name, "hf_config");
319 assert_eq!(f.hf_preprocessor.unwrap().text, "{\"size\":1}");
320 assert_eq!(f.metadata["names"], "{0: 'cat'}");
321
322 let bytes = super::super::onnx::tests::model_proto(&[("edgefirst", "{}")], 0);
323 let f = read_embedded_bytes(&bytes, true).unwrap();
324 assert_eq!(f.edgefirst_json.unwrap().name, "edgefirst");
325 }
326
327 #[test]
328 fn malformed_onnx_is_an_error_only_when_named_onnx() {
329 let bytes = [0x08, 0x08, 0x3a, 0x7f];
330 assert!(read_embedded_bytes(&bytes, true).is_err());
331 assert!(read_embedded_bytes(&bytes, false).unwrap().is_empty());
332 }
333
334 #[cfg(feature = "model-files")]
335 #[test]
336 fn tflite_associated_files_read() {
337 let mut payload = vec![0u8; 4];
338 payload.extend_from_slice(b"TFL3");
339 payload.resize(512, 0x5a);
340 let bytes = super::super::ziptrailer::tests::with_trailer(
341 &payload,
342 &[
343 ("config.json", "{\"model_type\":\"segformer\"}"),
344 ("preprocessor_config.json", "{}"),
345 ("metadata.json", "{\"names\":{}}"),
346 ],
347 );
348 let f = read_embedded_bytes(&bytes, false).unwrap();
349 assert_eq!(f.payload_len, Some(512));
350 assert_eq!(f.hf_config.unwrap().name, "config.json");
351 assert!(f.hf_preprocessor.is_some());
352 assert!(f.metadata.contains_key("metadata.json"));
353 }
354
355 #[cfg(feature = "model-files")]
356 #[test]
357 fn onnx_with_appended_zip_reads_both() {
358 let model = super::super::onnx::tests::model_proto(&[("labels", "a\nb")], 64);
359 let bytes =
360 super::super::ziptrailer::tests::with_trailer(&model, &[("edgefirst.json", "{}")]);
361 let f = read_embedded_bytes(&bytes, true).unwrap();
362 assert_eq!(f.payload_len, Some(model.len() as u64));
363 assert!(f.edgefirst_json.is_some());
364 assert_eq!(f.labels_txt.unwrap().name, "labels");
365 }
366
367 #[test]
368 fn a_directory_is_an_io_error() {
369 let e = read_embedded(std::env::temp_dir()).unwrap_err();
370 assert!(
371 matches!(e, ModelConfigError::Io { ref source, .. } if source.kind() == std::io::ErrorKind::IsADirectory),
372 "{e}"
373 );
374 }
375
376 #[test]
377 fn sidecars_read_from_directory() {
378 let dir = std::env::temp_dir().join(format!("ef-sidecar-{}", std::process::id()));
379 std::fs::create_dir_all(&dir).unwrap();
380 std::fs::write(dir.join("edgefirst.json"), "{}").unwrap();
381 std::fs::write(dir.join("labels.txt"), " a \n\nb\n").unwrap();
382 let f = read_sidecars(&dir).unwrap();
383 std::fs::remove_dir_all(&dir).unwrap();
384 assert_eq!(parse_labels(&f.labels_txt.unwrap().text), ["a", "b"]);
385 assert!(f.edgefirst_json.is_some());
386 assert!(f.hf_config.is_none());
387 }
388
389 #[test]
390 fn labels_trim_outer_whitespace_only() {
391 let names = ["bed ", "\u{3000}wall", "chest of drawers", "\tsky\n"];
392 let got = trim_labels("f", "id2label", names.map(String::from).to_vec()).unwrap();
393 assert_eq!(got, ["bed", "wall", "chest of drawers", "sky"]);
394 }
395
396 #[test]
397 fn label_empty_after_trim_names_source_and_index() {
398 let names = vec!["a".to_owned(), " \u{a0}".to_owned()];
399 let e = trim_labels("config.json", "id2label", names).unwrap_err();
400 assert!(
401 matches!(&e, ModelConfigError::InvalidField { file, field, .. }
402 if file == "config.json" && field == "id2label[1]"),
403 "{e}"
404 );
405 }
406}