pub mod html;
mod report;
pub use report::{
AnnotationDensityStats, AreaDistribution, AspectRatioBucket, AspectRatioDistribution,
BBoxStats, CooccurrencePair, CooccurrenceTopPairs, ImageResolutionStats, LabelCount,
LabelsSection, PerCategoryBBoxStats, StatsReport, SummarySection, TextReportStyle,
};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use crate::ir::{CategoryId, Dataset, ImageId};
#[derive(Clone, Debug)]
pub struct StatsOptions {
pub top_labels: usize,
pub top_pairs: usize,
pub oob_tolerance_px: f64,
pub bar_width: usize,
}
impl Default for StatsOptions {
fn default() -> Self {
Self {
top_labels: 10,
top_pairs: 10,
oob_tolerance_px: 0.5,
bar_width: 20,
}
}
}
pub fn stats_dataset(dataset: &Dataset, opts: &StatsOptions) -> StatsReport {
let image_dims: HashMap<ImageId, (u32, u32)> = dataset
.images
.iter()
.map(|img| (img.id, (img.width, img.height)))
.collect();
let category_names: HashMap<CategoryId, String> = dataset
.categories
.iter()
.map(|cat| (cat.id, cat.name.clone()))
.collect();
let summary = compute_summary(dataset);
let labels = compute_labels(dataset, &category_names, opts.top_labels);
let bboxes = compute_bbox_stats(dataset, &image_dims, opts.oob_tolerance_px);
let image_resolutions = compute_image_resolution_stats(dataset);
let annotation_density = compute_annotation_density(dataset);
let area_distribution = compute_area_distribution(dataset);
let aspect_ratios = compute_aspect_ratio_distribution(dataset);
let per_category_bbox =
compute_per_category_bbox_stats(dataset, &category_names, opts.top_labels);
let cooccurrence_top_pairs =
compute_cooccurrence_top_pairs(dataset, &category_names, opts.top_pairs);
StatsReport {
summary,
labels,
bboxes,
image_resolutions,
annotation_density,
area_distribution,
aspect_ratios,
per_category_bbox,
cooccurrence_top_pairs,
bar_width: opts.bar_width,
}
}
fn compute_summary(dataset: &Dataset) -> SummarySection {
let annotated_image_ids: HashSet<ImageId> =
dataset.annotations.iter().map(|ann| ann.image_id).collect();
SummarySection {
images: dataset.images.len(),
categories: dataset.categories.len(),
annotations: dataset.annotations.len(),
licenses: dataset.licenses.len(),
annotated_images: annotated_image_ids.len(),
}
}
fn compute_labels(
dataset: &Dataset,
category_names: &HashMap<CategoryId, String>,
top_n: usize,
) -> LabelsSection {
let mut counts: HashMap<String, usize> = HashMap::new();
for ann in &dataset.annotations {
let label = category_names
.get(&ann.category_id)
.cloned()
.unwrap_or_else(|| format!("<missing cat {}>", ann.category_id));
*counts.entry(label).or_insert(0) += 1;
}
let mut sorted: Vec<(String, usize)> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let total_distinct = sorted.len();
let total_annotations = dataset.annotations.len();
let (top_entries, rest): (Vec<_>, Vec<_>) = sorted
.into_iter()
.enumerate()
.partition(|(i, _)| *i < top_n);
let entries: Vec<LabelCount> = top_entries
.into_iter()
.map(|(_, (label, count))| LabelCount { label, count })
.collect();
let other_count: usize = rest.into_iter().map(|(_, (_, count))| count).sum();
LabelsSection {
top_n,
total_distinct,
total_annotations,
entries,
other_count,
}
}
fn compute_bbox_stats(
dataset: &Dataset,
image_dims: &HashMap<ImageId, (u32, u32)>,
tolerance: f64,
) -> BBoxStats {
let mut stats = BBoxStats {
total: dataset.annotations.len(),
..Default::default()
};
let mut min_width: Option<f64> = None;
let mut max_width: Option<f64> = None;
let mut min_height: Option<f64> = None;
let mut max_height: Option<f64> = None;
for ann in &dataset.annotations {
let bbox = &ann.bbox;
let xmin = bbox.min.x;
let ymin = bbox.min.y;
let xmax = bbox.max.x;
let ymax = bbox.max.y;
let is_finite =
xmin.is_finite() && ymin.is_finite() && xmax.is_finite() && ymax.is_finite();
if is_finite {
stats.finite += 1;
let is_ordered = xmin <= xmax && ymin <= ymax;
if is_ordered {
stats.ordered += 1;
let width = xmax - xmin;
let height = ymax - ymin;
min_width = Some(min_width.map_or(width, |m| m.min(width)));
max_width = Some(max_width.map_or(width, |m| m.max(width)));
min_height = Some(min_height.map_or(height, |m| m.min(height)));
max_height = Some(max_height.map_or(height, |m| m.max(height)));
let area = width * height;
if area <= 0.0 {
stats.degenerate_area += 1;
}
}
if let Some(&(img_w, img_h)) = image_dims.get(&ann.image_id) {
stats.oob_checked += 1;
let img_w = img_w as f64;
let img_h = img_h as f64;
let is_oob = xmin < -tolerance
|| ymin < -tolerance
|| xmax > img_w + tolerance
|| ymax > img_h + tolerance;
if is_oob {
stats.out_of_bounds += 1;
}
} else {
stats.missing_image_ref += 1;
}
} else if !image_dims.contains_key(&ann.image_id) {
stats.missing_image_ref += 1;
}
}
stats.min_width = min_width;
stats.max_width = max_width;
stats.min_height = min_height;
stats.max_height = max_height;
stats
}
fn compute_image_resolution_stats(dataset: &Dataset) -> ImageResolutionStats {
if dataset.images.is_empty() {
return ImageResolutionStats::default();
}
let mut min_w = u32::MAX;
let mut max_w = 0u32;
let mut sum_w = 0u64;
let mut min_h = u32::MAX;
let mut max_h = 0u32;
let mut sum_h = 0u64;
for image in &dataset.images {
min_w = min_w.min(image.width);
max_w = max_w.max(image.width);
sum_w += image.width as u64;
min_h = min_h.min(image.height);
max_h = max_h.max(image.height);
sum_h += image.height as u64;
}
let count = dataset.images.len() as f64;
ImageResolutionStats {
min_w,
max_w,
mean_w: sum_w as f64 / count,
min_h,
max_h,
mean_h: sum_h as f64 / count,
}
}
fn compute_annotation_density(dataset: &Dataset) -> AnnotationDensityStats {
if dataset.images.is_empty() {
return AnnotationDensityStats::default();
}
let mut counts: HashMap<ImageId, usize> = dataset
.images
.iter()
.map(|image| (image.id, 0usize))
.collect();
for ann in &dataset.annotations {
if let Some(count) = counts.get_mut(&ann.image_id) {
*count += 1;
}
}
let values: Vec<usize> = counts.values().copied().collect();
let min_per_image = *values.iter().min().unwrap_or(&0);
let max_per_image = *values.iter().max().unwrap_or(&0);
let sum: usize = values.iter().sum();
let mean_per_image = sum as f64 / values.len() as f64;
let zero_annotation_images = values.iter().filter(|&&v| v == 0).count();
AnnotationDensityStats {
min_per_image,
max_per_image,
mean_per_image,
zero_annotation_images,
}
}
fn compute_area_distribution(dataset: &Dataset) -> AreaDistribution {
let mut stats = AreaDistribution::default();
for ann in &dataset.annotations {
let bbox = &ann.bbox;
if !bbox.is_finite() || !bbox.is_ordered() {
stats.invalid += 1;
continue;
}
let area = bbox.area();
if !area.is_finite() || area <= 0.0 {
stats.invalid += 1;
continue;
}
if area < 1024.0 {
stats.small += 1;
} else if area < 9216.0 {
stats.medium += 1;
} else {
stats.large += 1;
}
}
stats
}
fn compute_aspect_ratio_distribution(dataset: &Dataset) -> AspectRatioDistribution {
let names = ["<0.5", "0.5-1", "1-2", "2-5", ">=5"];
let mut counts = [0usize; 5];
let mut invalid = 0usize;
for ann in &dataset.annotations {
let bbox = &ann.bbox;
if !bbox.is_finite() || !bbox.is_ordered() {
invalid += 1;
continue;
}
let width = bbox.width();
let height = bbox.height();
let area = bbox.area();
if !width.is_finite()
|| !height.is_finite()
|| !area.is_finite()
|| width <= 0.0
|| height <= 0.0
|| area <= 0.0
{
invalid += 1;
continue;
}
let ratio = width / height;
if !ratio.is_finite() {
invalid += 1;
continue;
}
let idx = if ratio < 0.5 {
0
} else if ratio < 1.0 {
1
} else if ratio < 2.0 {
2
} else if ratio < 5.0 {
3
} else {
4
};
counts[idx] += 1;
}
AspectRatioDistribution {
buckets: names
.iter()
.zip(counts)
.map(|(name, count)| AspectRatioBucket {
name: (*name).to_string(),
count,
})
.collect(),
invalid,
}
}
fn compute_per_category_bbox_stats(
dataset: &Dataset,
category_names: &HashMap<CategoryId, String>,
top_n: usize,
) -> Vec<PerCategoryBBoxStats> {
#[derive(Default)]
struct Agg {
annotations: usize,
valid_count: usize,
min_area: f64,
max_area: f64,
sum_area: f64,
}
let mut per_category: BTreeMap<String, Agg> = BTreeMap::new();
for ann in &dataset.annotations {
let category = category_names
.get(&ann.category_id)
.cloned()
.unwrap_or_else(|| format!("<missing cat {}>", ann.category_id));
let entry = per_category.entry(category).or_default();
entry.annotations += 1;
let bbox = &ann.bbox;
if !bbox.is_finite() || !bbox.is_ordered() {
continue;
}
let area = bbox.area();
if !area.is_finite() || area <= 0.0 {
continue;
}
if entry.valid_count == 0 {
entry.min_area = area;
entry.max_area = area;
} else {
entry.min_area = entry.min_area.min(area);
entry.max_area = entry.max_area.max(area);
}
entry.valid_count += 1;
entry.sum_area += area;
}
let mut rows: Vec<PerCategoryBBoxStats> = per_category
.into_iter()
.map(|(category, agg)| PerCategoryBBoxStats {
category,
annotations: agg.annotations,
min_area: if agg.valid_count > 0 {
Some(agg.min_area)
} else {
None
},
max_area: if agg.valid_count > 0 {
Some(agg.max_area)
} else {
None
},
mean_area: if agg.valid_count > 0 {
Some(agg.sum_area / agg.valid_count as f64)
} else {
None
},
})
.collect();
rows.sort_by(|a, b| {
b.annotations
.cmp(&a.annotations)
.then_with(|| a.category.cmp(&b.category))
});
if top_n < rows.len() {
rows.truncate(top_n);
}
rows
}
fn compute_cooccurrence_top_pairs(
dataset: &Dataset,
category_names: &HashMap<CategoryId, String>,
top_n: usize,
) -> CooccurrenceTopPairs {
if top_n == 0 {
return CooccurrenceTopPairs {
top_n,
pairs: Vec::new(),
};
}
let mut per_image_categories: HashMap<ImageId, BTreeSet<String>> = HashMap::new();
for ann in &dataset.annotations {
let category = category_names
.get(&ann.category_id)
.cloned()
.unwrap_or_else(|| format!("<missing cat {}>", ann.category_id));
per_image_categories
.entry(ann.image_id)
.or_default()
.insert(category);
}
let mut pair_counts: BTreeMap<(String, String), usize> = BTreeMap::new();
for categories in per_image_categories.values() {
let labels: Vec<&String> = categories.iter().collect();
for i in 0..labels.len() {
for j in (i + 1)..labels.len() {
let key = (labels[i].clone(), labels[j].clone());
*pair_counts.entry(key).or_insert(0) += 1;
}
}
}
let mut pairs: Vec<CooccurrencePair> = pair_counts
.into_iter()
.map(|((a, b), count)| CooccurrencePair { a, b, count })
.collect();
pairs.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.a.cmp(&b.a))
.then_with(|| a.b.cmp(&b.b))
});
if top_n < pairs.len() {
pairs.truncate(top_n);
}
CooccurrenceTopPairs { top_n, pairs }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Annotation, BBoxXYXY, Category, Image, Pixel};
fn make_test_dataset() -> Dataset {
Dataset {
images: vec![
Image::new(1u64, "img1.jpg", 640, 480),
Image::new(2u64, "img2.jpg", 800, 600),
Image::new(3u64, "img3.jpg", 1920, 1080),
],
categories: vec![
Category::new(1u64, "person"),
Category::new(2u64, "car"),
Category::new(3u64, "dog"),
],
annotations: vec![
Annotation::new(
1u64,
1u64,
1u64,
BBoxXYXY::<Pixel>::from_xyxy(10.0, 10.0, 100.0, 100.0),
),
Annotation::new(
2u64,
1u64,
1u64,
BBoxXYXY::<Pixel>::from_xyxy(200.0, 200.0, 300.0, 300.0),
),
Annotation::new(
3u64,
2u64,
2u64,
BBoxXYXY::<Pixel>::from_xyxy(50.0, 50.0, 150.0, 150.0),
),
Annotation::new(
4u64,
2u64,
3u64,
BBoxXYXY::<Pixel>::from_xyxy(100.0, 100.0, 200.0, 200.0),
),
],
..Default::default()
}
}
#[test]
fn test_summary_counts() {
let dataset = make_test_dataset();
let opts = StatsOptions::default();
let report = stats_dataset(&dataset, &opts);
assert_eq!(report.summary.images, 3);
assert_eq!(report.summary.categories, 3);
assert_eq!(report.summary.annotations, 4);
assert_eq!(report.summary.annotated_images, 2);
}
#[test]
fn test_label_histogram() {
let dataset = make_test_dataset();
let opts = StatsOptions::default();
let report = stats_dataset(&dataset, &opts);
assert_eq!(report.labels.total_distinct, 3);
assert_eq!(report.labels.entries.len(), 3);
assert_eq!(report.labels.entries[0].label, "person");
assert_eq!(report.labels.entries[0].count, 2);
}
#[test]
fn test_bbox_stats() {
let dataset = make_test_dataset();
let opts = StatsOptions::default();
let report = stats_dataset(&dataset, &opts);
assert_eq!(report.bboxes.total, 4);
assert_eq!(report.bboxes.finite, 4);
assert_eq!(report.bboxes.ordered, 4);
assert_eq!(report.bboxes.out_of_bounds, 0);
assert_eq!(report.bboxes.degenerate_area, 0);
assert_eq!(report.bboxes.min_width, Some(90.0));
assert_eq!(report.bboxes.max_width, Some(100.0));
}
#[test]
fn test_extra_sections() {
let dataset = make_test_dataset();
let opts = StatsOptions::default();
let report = stats_dataset(&dataset, &opts);
assert_eq!(report.image_resolutions.min_w, 640);
assert_eq!(report.image_resolutions.max_w, 1920);
assert_eq!(report.annotation_density.min_per_image, 0);
assert_eq!(report.annotation_density.max_per_image, 2);
assert_eq!(report.area_distribution.small, 0);
assert_eq!(report.area_distribution.medium, 1);
assert_eq!(report.area_distribution.large, 3);
assert_eq!(report.cooccurrence_top_pairs.pairs.len(), 1);
assert_eq!(report.cooccurrence_top_pairs.pairs[0].a, "car");
assert_eq!(report.cooccurrence_top_pairs.pairs[0].b, "dog");
assert_eq!(report.cooccurrence_top_pairs.pairs[0].count, 1);
}
#[test]
fn test_display_output() {
let dataset = make_test_dataset();
let opts = StatsOptions::default();
let report = stats_dataset(&dataset, &opts);
let output = format!("{}", report);
assert!(output.contains("Dataset Stats Report"));
assert!(output.contains("Summary"));
assert!(output.contains("Labels"));
assert!(output.contains("Bounding Boxes"));
assert!(output.contains("person"));
}
}