1mod category;
22mod checksum;
23mod discovery;
24pub mod sparse;
25
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30pub use category::ImageCategory;
31pub use checksum::compute_checksum;
32pub use sparse::{SparseCheckout, SparseFilter, SparseStatus};
33
34use crate::error::Result;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Corpus {
39 pub name: String,
41
42 pub root_path: PathBuf,
44
45 pub images: Vec<CorpusImage>,
47
48 #[serde(default)]
50 pub metadata: CorpusMetadata,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct CorpusMetadata {
56 pub description: Option<String>,
58
59 pub license: Option<String>,
61
62 pub source_url: Option<String>,
64
65 #[serde(default)]
67 pub category_counts: std::collections::HashMap<String, usize>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CorpusImage {
73 pub relative_path: PathBuf,
75
76 pub category: Option<ImageCategory>,
78
79 pub width: u32,
81 pub height: u32,
82
83 pub file_size: u64,
85
86 pub checksum: Option<String>,
88
89 pub format: String,
91}
92
93impl CorpusImage {
94 #[must_use]
96 pub fn full_path(&self, root: &Path) -> PathBuf {
97 root.join(&self.relative_path)
98 }
99
100 #[must_use]
102 pub fn name(&self) -> &str {
103 self.relative_path
104 .file_name()
105 .and_then(|s| s.to_str())
106 .unwrap_or("")
107 }
108
109 #[must_use]
111 pub fn pixel_count(&self) -> u64 {
112 u64::from(self.width) * u64::from(self.height)
113 }
114}
115
116impl Corpus {
117 #[must_use]
119 pub fn new(name: impl Into<String>, root_path: impl Into<PathBuf>) -> Self {
120 Self {
121 name: name.into(),
122 root_path: root_path.into(),
123 images: Vec::new(),
124 metadata: CorpusMetadata::default(),
125 }
126 }
127
128 pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
133 discovery::discover_corpus(path.as_ref())
134 }
135
136 #[cfg(not(feature = "corpus"))]
138 pub const DEFAULT_CORPUS_URL: &'static str = "https://github.com/imazen/codec-corpus.git";
139
140 #[cfg(feature = "corpus")]
157 pub fn get_dataset(dataset: &str) -> Result<Self> {
158 let corpus_api = codec_corpus::Corpus::new()
159 .map_err(|e| crate::Error::Corpus(format!("Failed to initialize corpus: {e}")))?;
160
161 let path = corpus_api
162 .get(dataset)
163 .map_err(|e| crate::Error::Corpus(format!("Failed to get dataset '{dataset}': {e}")))?;
164
165 eprintln!("Using corpus dataset '{}' at {}", dataset, path.display());
166 Self::discover(&path)
167 }
168
169 #[cfg(feature = "corpus")]
179 pub fn discover_or_download(
180 path: impl AsRef<Path>,
181 _url: Option<&str>,
182 _subsets: Option<&[&str]>,
183 ) -> Result<Self> {
184 let path = path.as_ref();
185
186 if path.exists() && path.is_dir() && has_image_files(path) {
188 return Self::discover(path);
189 }
190
191 Err(crate::Error::Corpus(format!(
192 "Path {} not found. Use Corpus::get_dataset() to download datasets automatically.",
193 path.display()
194 )))
195 }
196
197 #[cfg(not(feature = "corpus"))]
202 pub fn discover_or_download(
203 path: impl AsRef<Path>,
204 url: Option<&str>,
205 subsets: Option<&[&str]>,
206 ) -> Result<Self> {
207 let path = path.as_ref();
208 let url = url.unwrap_or(Self::DEFAULT_CORPUS_URL);
209
210 if path.exists() && path.is_dir() && has_image_files(path) {
212 return Self::discover(path);
213 }
214
215 eprintln!(
217 "Corpus not found at {}, downloading from {}",
218 path.display(),
219 url
220 );
221
222 let sparse = if let Some(subsets) = subsets {
224 let checkout = SparseCheckout::clone_shallow(url, path, 1)?;
225 let paths: Vec<&str> = subsets.to_vec();
226 checkout.add_paths(&paths)?;
227 checkout.checkout()?;
228 checkout
229 } else {
230 let checkout = SparseCheckout::clone_shallow(url, path, 1)?;
231 checkout.set_paths(&["*"])?;
232 checkout.checkout()?;
233 checkout
234 };
235
236 eprintln!("Downloaded corpus to {}", sparse.path().display());
237 Self::discover(path)
238 }
239
240 #[cfg(feature = "corpus")]
249 pub fn download_dataset(dataset: &str) -> Result<Self> {
250 Self::get_dataset(dataset)
251 }
252
253 #[cfg(not(feature = "corpus"))]
260 pub fn download_subset(path: impl AsRef<Path>, subset: &str) -> Result<Self> {
261 Self::discover_or_download(path, None, Some(&[subset]))
262 }
263
264 pub fn get_or_download(preferred_path: impl AsRef<Path>) -> Result<Self> {
275 let preferred = preferred_path.as_ref();
276
277 let candidates = [
279 preferred.to_path_buf(),
280 PathBuf::from("./codec-corpus"),
281 PathBuf::from("../codec-corpus"),
282 PathBuf::from("../codec-comparison/codec-corpus"),
283 ];
284
285 for path in &candidates {
286 if path.exists() && has_image_files(path) {
287 eprintln!("Found corpus at {}", path.display());
288 return Self::discover(path);
289 }
290 }
291
292 #[cfg(feature = "corpus")]
294 {
295 Err(crate::Error::Corpus(
296 "Corpus not found at any common location. Use Corpus::get_dataset(\"kodak\") to download automatically.".to_string()
297 ))
298 }
299
300 #[cfg(not(feature = "corpus"))]
301 {
302 Self::discover_or_download(preferred, None, None)
304 }
305 }
306
307 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
309 let content = std::fs::read_to_string(path.as_ref())?;
310 let corpus: Corpus = serde_json::from_str(&content)?;
311 Ok(corpus)
312 }
313
314 pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
316 let content = serde_json::to_string_pretty(self)?;
317 std::fs::write(path.as_ref(), content)?;
318 Ok(())
319 }
320
321 #[must_use]
323 pub fn len(&self) -> usize {
324 self.images.len()
325 }
326
327 #[must_use]
329 pub fn is_empty(&self) -> bool {
330 self.images.is_empty()
331 }
332
333 #[must_use]
335 pub fn filter_category(&self, category: ImageCategory) -> Vec<&CorpusImage> {
336 self.images
337 .iter()
338 .filter(|img| img.category == Some(category))
339 .collect()
340 }
341
342 #[must_use]
344 pub fn filter_format(&self, format: &str) -> Vec<&CorpusImage> {
345 let format_lower = format.to_lowercase();
346 self.images
347 .iter()
348 .filter(|img| img.format.to_lowercase() == format_lower)
349 .collect()
350 }
351
352 #[must_use]
354 pub fn filter_min_size(&self, min_width: u32, min_height: u32) -> Vec<&CorpusImage> {
355 self.images
356 .iter()
357 .filter(|img| img.width >= min_width && img.height >= min_height)
358 .collect()
359 }
360
361 #[must_use]
369 pub fn split(&self, train_ratio: f64) -> (Vec<&CorpusImage>, Vec<&CorpusImage>) {
370 let train_ratio = train_ratio.clamp(0.0, 1.0);
371 let mut train = Vec::new();
372 let mut val = Vec::new();
373
374 for (i, img) in self.images.iter().enumerate() {
375 let hash = img.checksum.as_ref().map_or(i, |s| {
377 s.bytes()
378 .fold(0usize, |acc, b| acc.wrapping_add(b as usize))
379 });
380
381 if (hash % 1000) < (train_ratio * 1000.0) as usize {
382 train.push(img);
383 } else {
384 val.push(img);
385 }
386 }
387
388 (train, val)
389 }
390
391 pub fn compute_checksums(&mut self) -> Result<usize> {
393 let mut computed = 0;
394
395 for img in &mut self.images {
396 if img.checksum.is_none() {
397 let path = self.root_path.join(&img.relative_path);
398 if path.exists() {
399 img.checksum = Some(compute_checksum(&path)?);
400 computed += 1;
401 }
402 }
403 }
404
405 Ok(computed)
406 }
407
408 #[must_use]
410 pub fn find_duplicates(&self) -> Vec<Vec<&CorpusImage>> {
411 use std::collections::HashMap;
412
413 let mut by_checksum: HashMap<&str, Vec<&CorpusImage>> = HashMap::new();
414
415 for img in &self.images {
416 if let Some(ref checksum) = img.checksum {
417 by_checksum.entry(checksum).or_default().push(img);
418 }
419 }
420
421 by_checksum.into_values().filter(|v| v.len() > 1).collect()
422 }
423
424 pub fn update_category_counts(&mut self) {
426 self.metadata.category_counts.clear();
427
428 for img in &self.images {
429 if let Some(cat) = img.category {
430 *self
431 .metadata
432 .category_counts
433 .entry(cat.to_string())
434 .or_insert(0) += 1;
435 }
436 }
437 }
438
439 #[must_use]
441 pub fn stats(&self) -> CorpusStats {
442 let total_pixels: u64 = self.images.iter().map(|img| img.pixel_count()).sum();
443 let total_bytes: u64 = self.images.iter().map(|img| img.file_size).sum();
444
445 let widths: Vec<u32> = self.images.iter().map(|img| img.width).collect();
446 let heights: Vec<u32> = self.images.iter().map(|img| img.height).collect();
447
448 CorpusStats {
449 image_count: self.images.len(),
450 total_pixels,
451 total_bytes,
452 min_width: widths.iter().copied().min().unwrap_or(0),
453 max_width: widths.iter().copied().max().unwrap_or(0),
454 min_height: heights.iter().copied().min().unwrap_or(0),
455 max_height: heights.iter().copied().max().unwrap_or(0),
456 }
457 }
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct CorpusStats {
463 pub image_count: usize,
465 pub total_pixels: u64,
467 pub total_bytes: u64,
469 pub min_width: u32,
471 pub max_width: u32,
473 pub min_height: u32,
475 pub max_height: u32,
477}
478
479fn has_image_files(path: &Path) -> bool {
481 const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "avif", "jxl"];
482
483 if let Ok(entries) = std::fs::read_dir(path) {
484 for entry in entries.flatten() {
485 let entry_path = entry.path();
486 if entry_path.is_file() {
487 if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) {
488 if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
489 return true;
490 }
491 }
492 } else if entry_path.is_dir() {
493 if let Ok(sub_entries) = std::fs::read_dir(&entry_path) {
495 for sub_entry in sub_entries.flatten() {
496 let sub_path = sub_entry.path();
497 if sub_path.is_file() {
498 if let Some(ext) = sub_path.extension().and_then(|e| e.to_str()) {
499 if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
500 return true;
501 }
502 }
503 }
504 }
505 }
506 }
507 }
508 }
509 false
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn test_corpus_new() {
518 let corpus = Corpus::new("test", "/tmp/images");
519 assert_eq!(corpus.name, "test");
520 assert!(corpus.is_empty());
521 }
522
523 #[test]
524 fn test_corpus_image_name() {
525 let img = CorpusImage {
526 relative_path: PathBuf::from("subdir/image.png"),
527 category: None,
528 width: 100,
529 height: 100,
530 file_size: 1000,
531 checksum: None,
532 format: "png".to_string(),
533 };
534 assert_eq!(img.name(), "image.png");
535 }
536
537 #[test]
538 fn test_corpus_split() {
539 let mut corpus = Corpus::new("test", "/tmp");
540 for i in 0..100 {
541 corpus.images.push(CorpusImage {
542 relative_path: PathBuf::from(format!("img{i}.png")),
543 category: None,
544 width: 100,
545 height: 100,
546 file_size: 1000,
547 checksum: Some(format!("{i:016x}")),
549 format: "png".to_string(),
550 });
551 }
552
553 let (train, val) = corpus.split(0.8);
554 assert_eq!(train.len() + val.len(), 100);
556 }
557}