Skip to main content

codec_eval/corpus/
mod.rs

1//! Corpus management for test image collections.
2//!
3//! This module provides tools for managing collections of test images,
4//! including discovery, categorization, and checksum-based deduplication.
5//!
6//! ## Example
7//!
8//! ```rust,ignore
9//! use codec_eval::corpus::Corpus;
10//!
11//! // Discover images in a directory
12//! let corpus = Corpus::discover("./test_images")?;
13//!
14//! // Filter by category
15//! let photos = corpus.filter_category(ImageCategory::Photo);
16//!
17//! // Get training/validation split
18//! let (train, val) = corpus.split(0.8);
19//! ```
20
21mod 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/// A corpus of test images.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Corpus {
39    /// Name of the corpus.
40    pub name: String,
41
42    /// Root path of the corpus.
43    pub root_path: PathBuf,
44
45    /// Images in the corpus.
46    pub images: Vec<CorpusImage>,
47
48    /// Metadata about the corpus.
49    #[serde(default)]
50    pub metadata: CorpusMetadata,
51}
52
53/// Metadata about a corpus.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct CorpusMetadata {
56    /// Description of the corpus.
57    pub description: Option<String>,
58
59    /// License information.
60    pub license: Option<String>,
61
62    /// Source URL.
63    pub source_url: Option<String>,
64
65    /// Number of images by category.
66    #[serde(default)]
67    pub category_counts: std::collections::HashMap<String, usize>,
68}
69
70/// An image in the corpus.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CorpusImage {
73    /// Relative path from corpus root.
74    pub relative_path: PathBuf,
75
76    /// Image category (if classified).
77    pub category: Option<ImageCategory>,
78
79    /// Image dimensions.
80    pub width: u32,
81    pub height: u32,
82
83    /// File size in bytes.
84    pub file_size: u64,
85
86    /// Content checksum (for deduplication).
87    pub checksum: Option<String>,
88
89    /// Format detected from file extension.
90    pub format: String,
91}
92
93impl CorpusImage {
94    /// Get the full path to the image.
95    #[must_use]
96    pub fn full_path(&self, root: &Path) -> PathBuf {
97        root.join(&self.relative_path)
98    }
99
100    /// Get the image name (filename without path).
101    #[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    /// Get pixel count.
110    #[must_use]
111    pub fn pixel_count(&self) -> u64 {
112        u64::from(self.width) * u64::from(self.height)
113    }
114}
115
116impl Corpus {
117    /// Create a new empty corpus.
118    #[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    /// Discover images in a directory.
129    ///
130    /// Recursively scans the directory for supported image formats
131    /// (PNG, JPEG, WebP, AVIF).
132    pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
133        discovery::discover_corpus(path.as_ref())
134    }
135
136    /// Default corpus repository URL (used when corpus feature is disabled).
137    #[cfg(not(feature = "corpus"))]
138    pub const DEFAULT_CORPUS_URL: &'static str = "https://github.com/imazen/codec-corpus.git";
139
140    /// Get corpus dataset, downloading if necessary.
141    ///
142    /// When the `corpus` feature is enabled (default), uses the codec-corpus crate
143    /// for automatic download and caching. Otherwise, checks if the path exists locally.
144    ///
145    /// # Arguments
146    /// * `path` - Dataset path (e.g., "kodak", "clic2025/training")
147    ///
148    /// # Example
149    /// ```rust,ignore
150    /// // With corpus feature (default): downloads and caches automatically
151    /// let corpus = Corpus::get_dataset("kodak")?;
152    ///
153    /// // Discovers images in the cached directory
154    /// println!("Found {} images", corpus.len());
155    /// ```
156    #[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    /// Discover or download a corpus on demand (legacy, requires local path).
170    ///
171    /// If the path exists, discovers images. Otherwise returns an error.
172    /// Use `get_dataset()` when the `corpus` feature is enabled for automatic downloads.
173    ///
174    /// # Arguments
175    /// * `path` - Local path for the corpus
176    /// * `_url` - Ignored (for backward compatibility)
177    /// * `_subsets` - Ignored (for backward compatibility)
178    #[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 and has images, discover
187        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    /// Discover or download a corpus on demand (sparse checkout fallback).
198    ///
199    /// This version uses git sparse checkout when the `corpus` feature is disabled.
200    /// For most users, the `corpus` feature (enabled by default) is recommended.
201    #[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 and has images, just discover
211        if path.exists() && path.is_dir() && has_image_files(path) {
212            return Self::discover(path);
213        }
214
215        // Need to download
216        eprintln!(
217            "Corpus not found at {}, downloading from {}",
218            path.display(),
219            url
220        );
221
222        // Use sparse checkout for efficiency
223        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    /// Download a specific dataset (replaces download_subset).
241    ///
242    /// With the `corpus` feature enabled, this uses codec-corpus for caching.
243    ///
244    /// # Example
245    /// ```rust,ignore
246    /// let corpus = Corpus::download_dataset("kodak")?;
247    /// ```
248    #[cfg(feature = "corpus")]
249    pub fn download_dataset(dataset: &str) -> Result<Self> {
250        Self::get_dataset(dataset)
251    }
252
253    /// Download a specific subset of the corpus (sparse checkout fallback).
254    ///
255    /// # Example
256    /// ```rust,ignore
257    /// let corpus = Corpus::download_subset("./corpus", "kodak")?;
258    /// ```
259    #[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    /// Get corpus from local paths (legacy method).
265    ///
266    /// Checks common locations for existing corpus:
267    /// 1. The specified path
268    /// 2. ./codec-corpus
269    /// 3. ../codec-corpus
270    /// 4. ../codec-comparison/codec-corpus
271    ///
272    /// When the `corpus` feature is enabled, use `get_dataset()` instead
273    /// for automatic download and caching.
274    pub fn get_or_download(preferred_path: impl AsRef<Path>) -> Result<Self> {
275        let preferred = preferred_path.as_ref();
276
277        // Check common locations
278        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        // Not found
293        #[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            // Fallback to sparse checkout
303            Self::discover_or_download(preferred, None, None)
304        }
305    }
306
307    /// Load a corpus from a JSON manifest file.
308    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    /// Save the corpus to a JSON manifest file.
315    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    /// Get the number of images in the corpus.
322    #[must_use]
323    pub fn len(&self) -> usize {
324        self.images.len()
325    }
326
327    /// Check if the corpus is empty.
328    #[must_use]
329    pub fn is_empty(&self) -> bool {
330        self.images.is_empty()
331    }
332
333    /// Filter images by category.
334    #[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    /// Filter images by format.
343    #[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    /// Filter images by minimum dimensions.
353    #[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    /// Split the corpus into training and validation sets.
362    ///
363    /// Uses a deterministic split based on checksum to ensure reproducibility.
364    ///
365    /// # Arguments
366    ///
367    /// * `train_ratio` - Fraction of images to include in training set (0.0-1.0).
368    #[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            // Use checksum if available, otherwise use index
376            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    /// Compute checksums for all images that don't have them.
392    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    /// Find duplicate images by checksum.
409    #[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    /// Update category counts in metadata.
425    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    /// Get statistics about the corpus.
440    #[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/// Statistics about a corpus.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct CorpusStats {
463    /// Number of images.
464    pub image_count: usize,
465    /// Total pixels across all images.
466    pub total_pixels: u64,
467    /// Total file size in bytes.
468    pub total_bytes: u64,
469    /// Minimum image width.
470    pub min_width: u32,
471    /// Maximum image width.
472    pub max_width: u32,
473    /// Minimum image height.
474    pub min_height: u32,
475    /// Maximum image height.
476    pub max_height: u32,
477}
478
479/// Check if a directory contains any image files.
480fn 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                // Check subdirectories recursively (but only one level deep for performance)
494                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                // Use varied checksums to get good distribution
548                checksum: Some(format!("{i:016x}")),
549                format: "png".to_string(),
550            });
551        }
552
553        let (train, val) = corpus.split(0.8);
554        // Should split all images
555        assert_eq!(train.len() + val.len(), 100);
556    }
557}