mod category;
mod checksum;
mod discovery;
pub mod sparse;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use category::ImageCategory;
pub use checksum::compute_checksum;
pub use sparse::{SparseCheckout, SparseFilter, SparseStatus};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Corpus {
pub name: String,
pub root_path: PathBuf,
pub images: Vec<CorpusImage>,
#[serde(default)]
pub metadata: CorpusMetadata,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CorpusMetadata {
pub description: Option<String>,
pub license: Option<String>,
pub source_url: Option<String>,
#[serde(default)]
pub category_counts: std::collections::HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorpusImage {
pub relative_path: PathBuf,
pub category: Option<ImageCategory>,
pub width: u32,
pub height: u32,
pub file_size: u64,
pub checksum: Option<String>,
pub format: String,
}
impl CorpusImage {
#[must_use]
pub fn full_path(&self, root: &Path) -> PathBuf {
root.join(&self.relative_path)
}
#[must_use]
pub fn name(&self) -> &str {
self.relative_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
}
#[must_use]
pub fn pixel_count(&self) -> u64 {
u64::from(self.width) * u64::from(self.height)
}
}
impl Corpus {
#[must_use]
pub fn new(name: impl Into<String>, root_path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
root_path: root_path.into(),
images: Vec::new(),
metadata: CorpusMetadata::default(),
}
}
pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
discovery::discover_corpus(path.as_ref())
}
#[cfg(not(feature = "corpus"))]
pub const DEFAULT_CORPUS_URL: &'static str = "https://github.com/imazen/codec-corpus.git";
#[cfg(feature = "corpus")]
pub fn get_dataset(dataset: &str) -> Result<Self> {
let corpus_api = codec_corpus::Corpus::new()
.map_err(|e| crate::Error::Corpus(format!("Failed to initialize corpus: {e}")))?;
let path = corpus_api
.get(dataset)
.map_err(|e| crate::Error::Corpus(format!("Failed to get dataset '{dataset}': {e}")))?;
eprintln!("Using corpus dataset '{}' at {}", dataset, path.display());
Self::discover(&path)
}
#[cfg(feature = "corpus")]
pub fn discover_or_download(
path: impl AsRef<Path>,
_url: Option<&str>,
_subsets: Option<&[&str]>,
) -> Result<Self> {
let path = path.as_ref();
if path.exists() && path.is_dir() && has_image_files(path) {
return Self::discover(path);
}
Err(crate::Error::Corpus(format!(
"Path {} not found. Use Corpus::get_dataset() to download datasets automatically.",
path.display()
)))
}
#[cfg(not(feature = "corpus"))]
pub fn discover_or_download(
path: impl AsRef<Path>,
url: Option<&str>,
subsets: Option<&[&str]>,
) -> Result<Self> {
let path = path.as_ref();
let url = url.unwrap_or(Self::DEFAULT_CORPUS_URL);
if path.exists() && path.is_dir() && has_image_files(path) {
return Self::discover(path);
}
eprintln!(
"Corpus not found at {}, downloading from {}",
path.display(),
url
);
let sparse = if let Some(subsets) = subsets {
let checkout = SparseCheckout::clone_shallow(url, path, 1)?;
let paths: Vec<&str> = subsets.to_vec();
checkout.add_paths(&paths)?;
checkout.checkout()?;
checkout
} else {
let checkout = SparseCheckout::clone_shallow(url, path, 1)?;
checkout.set_paths(&["*"])?;
checkout.checkout()?;
checkout
};
eprintln!("Downloaded corpus to {}", sparse.path().display());
Self::discover(path)
}
#[cfg(feature = "corpus")]
pub fn download_dataset(dataset: &str) -> Result<Self> {
Self::get_dataset(dataset)
}
#[cfg(not(feature = "corpus"))]
pub fn download_subset(path: impl AsRef<Path>, subset: &str) -> Result<Self> {
Self::discover_or_download(path, None, Some(&[subset]))
}
pub fn get_or_download(preferred_path: impl AsRef<Path>) -> Result<Self> {
let preferred = preferred_path.as_ref();
let candidates = [
preferred.to_path_buf(),
PathBuf::from("./codec-corpus"),
PathBuf::from("../codec-corpus"),
PathBuf::from("../codec-comparison/codec-corpus"),
];
for path in &candidates {
if path.exists() && has_image_files(path) {
eprintln!("Found corpus at {}", path.display());
return Self::discover(path);
}
}
#[cfg(feature = "corpus")]
{
Err(crate::Error::Corpus(
"Corpus not found at any common location. Use Corpus::get_dataset(\"kodak\") to download automatically.".to_string()
))
}
#[cfg(not(feature = "corpus"))]
{
Self::discover_or_download(preferred, None, None)
}
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())?;
let corpus: Corpus = serde_json::from_str(&content)?;
Ok(corpus)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path.as_ref(), content)?;
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
self.images.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
#[must_use]
pub fn filter_category(&self, category: ImageCategory) -> Vec<&CorpusImage> {
self.images
.iter()
.filter(|img| img.category == Some(category))
.collect()
}
#[must_use]
pub fn filter_format(&self, format: &str) -> Vec<&CorpusImage> {
let format_lower = format.to_lowercase();
self.images
.iter()
.filter(|img| img.format.to_lowercase() == format_lower)
.collect()
}
#[must_use]
pub fn filter_min_size(&self, min_width: u32, min_height: u32) -> Vec<&CorpusImage> {
self.images
.iter()
.filter(|img| img.width >= min_width && img.height >= min_height)
.collect()
}
#[must_use]
pub fn split(&self, train_ratio: f64) -> (Vec<&CorpusImage>, Vec<&CorpusImage>) {
let train_ratio = train_ratio.clamp(0.0, 1.0);
let mut train = Vec::new();
let mut val = Vec::new();
for (i, img) in self.images.iter().enumerate() {
let hash = img.checksum.as_ref().map_or(i, |s| {
s.bytes()
.fold(0usize, |acc, b| acc.wrapping_add(b as usize))
});
if (hash % 1000) < (train_ratio * 1000.0) as usize {
train.push(img);
} else {
val.push(img);
}
}
(train, val)
}
pub fn compute_checksums(&mut self) -> Result<usize> {
let mut computed = 0;
for img in &mut self.images {
if img.checksum.is_none() {
let path = self.root_path.join(&img.relative_path);
if path.exists() {
img.checksum = Some(compute_checksum(&path)?);
computed += 1;
}
}
}
Ok(computed)
}
#[must_use]
pub fn find_duplicates(&self) -> Vec<Vec<&CorpusImage>> {
use std::collections::HashMap;
let mut by_checksum: HashMap<&str, Vec<&CorpusImage>> = HashMap::new();
for img in &self.images {
if let Some(ref checksum) = img.checksum {
by_checksum.entry(checksum).or_default().push(img);
}
}
by_checksum.into_values().filter(|v| v.len() > 1).collect()
}
pub fn update_category_counts(&mut self) {
self.metadata.category_counts.clear();
for img in &self.images {
if let Some(cat) = img.category {
*self
.metadata
.category_counts
.entry(cat.to_string())
.or_insert(0) += 1;
}
}
}
#[must_use]
pub fn stats(&self) -> CorpusStats {
let total_pixels: u64 = self.images.iter().map(|img| img.pixel_count()).sum();
let total_bytes: u64 = self.images.iter().map(|img| img.file_size).sum();
let widths: Vec<u32> = self.images.iter().map(|img| img.width).collect();
let heights: Vec<u32> = self.images.iter().map(|img| img.height).collect();
CorpusStats {
image_count: self.images.len(),
total_pixels,
total_bytes,
min_width: widths.iter().copied().min().unwrap_or(0),
max_width: widths.iter().copied().max().unwrap_or(0),
min_height: heights.iter().copied().min().unwrap_or(0),
max_height: heights.iter().copied().max().unwrap_or(0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorpusStats {
pub image_count: usize,
pub total_pixels: u64,
pub total_bytes: u64,
pub min_width: u32,
pub max_width: u32,
pub min_height: u32,
pub max_height: u32,
}
fn has_image_files(path: &Path) -> bool {
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "avif", "jxl"];
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_file() {
if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) {
if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
return true;
}
}
} else if entry_path.is_dir() {
if let Ok(sub_entries) = std::fs::read_dir(&entry_path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_file() {
if let Some(ext) = sub_path.extension().and_then(|e| e.to_str()) {
if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
return true;
}
}
}
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_corpus_new() {
let corpus = Corpus::new("test", "/tmp/images");
assert_eq!(corpus.name, "test");
assert!(corpus.is_empty());
}
#[test]
fn test_corpus_image_name() {
let img = CorpusImage {
relative_path: PathBuf::from("subdir/image.png"),
category: None,
width: 100,
height: 100,
file_size: 1000,
checksum: None,
format: "png".to_string(),
};
assert_eq!(img.name(), "image.png");
}
#[test]
fn test_corpus_split() {
let mut corpus = Corpus::new("test", "/tmp");
for i in 0..100 {
corpus.images.push(CorpusImage {
relative_path: PathBuf::from(format!("img{i}.png")),
category: None,
width: 100,
height: 100,
file_size: 1000,
checksum: Some(format!("{i:016x}")),
format: "png".to_string(),
});
}
let (train, val) = corpus.split(0.8);
assert_eq!(train.len() + val.len(), 100);
}
}