use std::collections::HashMap;
use anyhow::Result;
use versatiles_core::{GeoBBox, ProbeDepth, TileBBox, TileCompression, TileFormat, TileJSON, TileSize, TileType};
use versatiles_geometry::vector_tile::{
DegenerateReason, GeomType, IssueKind, LayerStats, ValidationIssue, layer_stats, validate_tile,
};
use super::sampling::build_scan_plan;
use crate::{Tile, TileSource, TilesRuntime};
pub const VALIDATION_SAMPLE_LIMIT: usize = 10;
pub const BIGGEST_TILES_LIMIT: usize = 10;
#[derive(Clone, Debug)]
pub struct ProbeReport {
pub source_type: String,
pub tile_format: TileFormat,
pub tile_compression: TileCompression,
pub tilejson: TileJSON,
pub pyramid: Vec<PyramidLevel>,
pub measured_tile_size: Option<TileSize>,
pub tile_sizes: Option<TileSizeReport>,
pub contents: Option<ContentsReport>,
}
impl ProbeReport {
#[must_use]
pub fn zoom(&self) -> Option<std::ops::RangeInclusive<u8>> {
let first = self.pyramid.first()?;
let last = self.pyramid.last()?;
Some(first.level..=last.level)
}
#[must_use]
pub fn bbox(&self) -> Option<[f64; 4]> {
self.tilejson.bounds.as_ref().map(GeoBBox::as_array)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PyramidLevel {
pub level: u8,
pub x_min: u32,
pub x_max: u32,
pub y_min: u32,
pub y_max: u32,
pub tiles: u64,
pub coverage_percent: u64,
}
#[derive(Clone, Debug, Default)]
pub struct TileSizeReport {
pub tile_count: u64,
pub size_sum: u64,
pub biggest_tiles: Vec<TileSizeEntry>,
pub per_level: Vec<LevelSizeStats>,
}
impl TileSizeReport {
#[must_use]
pub fn average_size(&self) -> u64 {
self.size_sum.checked_div(self.tile_count).unwrap_or(0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileSizeEntry {
pub z: u8,
pub x: u32,
pub y: u32,
pub size: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelSizeStats {
pub level: u8,
pub count: u64,
pub size_sum: u64,
}
impl LevelSizeStats {
#[must_use]
pub fn average_size(&self) -> u64 {
self.size_sum.checked_div(self.count).unwrap_or(0)
}
}
#[derive(Clone, Debug)]
pub enum ContentsReport {
Unsupported,
Vector(VectorContentsReport),
}
#[derive(Clone, Debug, Default)]
pub struct VectorContentsReport {
pub sample_fraction: Option<f64>,
pub tiles_scanned: u64,
pub issues: ValidationCounters,
pub samples: Vec<IssueSample>,
pub layer_sizes: Vec<LayerSizeEntry>,
}
impl VectorContentsReport {
#[must_use]
pub fn layer_totals(&self) -> Vec<(String, LayerStats)> {
let mut per_layer: HashMap<&str, LayerStats> = HashMap::new();
for entry in &self.layer_sizes {
per_layer.entry(&entry.layer).or_default().add(&entry.stats);
}
let mut totals: Vec<(String, LayerStats)> = per_layer
.into_iter()
.map(|(name, stats)| (name.to_string(), stats))
.collect();
totals.sort_by(|a, b| b.1.encoded_bytes.cmp(&a.1.encoded_bytes).then(a.0.cmp(&b.0)));
totals
}
#[must_use]
pub fn total_bytes(&self) -> usize {
self.layer_sizes.iter().map(|e| e.stats.encoded_bytes).sum()
}
#[must_use]
pub fn has_feature_ids(&self) -> bool {
self.layer_sizes.iter().any(|e| e.stats.id_bytes > 0)
}
}
#[derive(Clone, Debug)]
pub struct LayerSizeEntry {
pub zoom: u8,
pub layer: String,
pub tiles: u64,
pub stats: LayerStats,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IssueSample {
pub z: u8,
pub x: u32,
pub y: u32,
pub layer: String,
pub feature_index: Option<usize>,
pub kind: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ValidationCounters {
pub missing_extent: u64,
pub missing_version: u64,
pub duplicate_layer_name: u64,
pub orphan_inner: u64,
pub degenerate_too_few: u64,
pub degenerate_sub_pixel: u64,
pub degenerate_collinear: u64,
pub unknown_geom: u64,
pub empty_geom_point: u64,
pub empty_geom_line: u64,
pub empty_geom_polygon: u64,
pub malformed_stream: u64,
pub decode_failures: u64,
pub tiles_with_issues: u64,
}
impl ValidationCounters {
#[must_use]
pub fn total_issues(&self) -> u64 {
self.missing_extent
+ self.missing_version
+ self.duplicate_layer_name
+ self.orphan_inner
+ self.degenerate_too_few
+ self.degenerate_sub_pixel
+ self.degenerate_collinear
+ self.unknown_geom
+ self.empty_geom_point
+ self.empty_geom_line
+ self.empty_geom_polygon
+ self.malformed_stream
}
#[must_use]
pub fn fixable_automatically(&self) -> u64 {
self.missing_extent
+ self.missing_version
+ self.duplicate_layer_name
+ self.orphan_inner
+ self.degenerate_too_few
+ self.degenerate_sub_pixel
+ self.degenerate_collinear
}
#[must_use]
pub fn needs_drop_offenders(&self) -> u64 {
self.unknown_geom + self.empty_geom_point + self.empty_geom_line + self.empty_geom_polygon + self.malformed_stream
}
#[must_use]
pub fn by_kind(&self) -> Vec<(&'static str, u64)> {
vec![
("MissingExtent", self.missing_extent),
("MissingVersion", self.missing_version),
("DuplicateLayerName", self.duplicate_layer_name),
("OrphanInnerRing", self.orphan_inner),
("DegenerateRing(TooFewVertices)", self.degenerate_too_few),
("DegenerateRing(SubPixel)", self.degenerate_sub_pixel),
("DegenerateRing(Collinear)", self.degenerate_collinear),
("UnknownGeometryType", self.unknown_geom),
("EmptyGeometryForType(MultiPoint)", self.empty_geom_point),
("EmptyGeometryForType(MultiLineString)", self.empty_geom_line),
("EmptyGeometryForType(MultiPolygon)", self.empty_geom_polygon),
("MalformedCommandStream", self.malformed_stream),
]
}
fn record(&mut self, kind: &IssueKind) {
match kind {
IssueKind::MissingExtent => self.missing_extent += 1,
IssueKind::MissingVersion => self.missing_version += 1,
IssueKind::DuplicateLayerName => self.duplicate_layer_name += 1,
IssueKind::OrphanInnerRing => self.orphan_inner += 1,
IssueKind::DegenerateRing(DegenerateReason::TooFewVertices) => self.degenerate_too_few += 1,
IssueKind::DegenerateRing(DegenerateReason::SubPixel) => self.degenerate_sub_pixel += 1,
IssueKind::DegenerateRing(DegenerateReason::Collinear) => self.degenerate_collinear += 1,
IssueKind::UnknownGeometryType | IssueKind::EmptyGeometryForType(GeomType::Unknown) => self.unknown_geom += 1,
IssueKind::EmptyGeometryForType(GeomType::MultiPoint) => self.empty_geom_point += 1,
IssueKind::EmptyGeometryForType(GeomType::MultiLineString) => self.empty_geom_line += 1,
IssueKind::EmptyGeometryForType(GeomType::MultiPolygon) => self.empty_geom_polygon += 1,
IssueKind::MalformedCommandStream(_) => self.malformed_stream += 1,
}
}
}
#[must_use]
pub fn describe_issue_kind(kind: &IssueKind) -> String {
match kind {
IssueKind::MissingExtent => "MissingExtent".to_string(),
IssueKind::MissingVersion => "MissingVersion".to_string(),
IssueKind::DuplicateLayerName => "DuplicateLayerName".to_string(),
IssueKind::OrphanInnerRing => "OrphanInnerRing".to_string(),
IssueKind::DegenerateRing(reason) => format!("DegenerateRing({reason:?})"),
IssueKind::UnknownGeometryType => "UnknownGeometryType".to_string(),
IssueKind::EmptyGeometryForType(geom_type) => format!("EmptyGeometryForType({geom_type:?})"),
IssueKind::MalformedCommandStream(_) => "MalformedCommandStream".to_string(),
}
}
pub async fn probe_report(
source: &dyn TileSource,
depth: ProbeDepth,
runtime: &TilesRuntime,
sample: Option<f64>,
) -> Result<ProbeReport> {
use ProbeDepth::{TileContents, TileSizes};
let metadata = source.metadata();
let tile_pyramid = source.tile_pyramid().await?;
let pyramid = tile_pyramid
.iter()
.filter(|level| !level.is_empty())
.map(|level| {
let bbox = level.to_bbox();
let tiles = level.count_tiles();
PyramidLevel {
level: level.level(),
x_min: bbox.x_min().unwrap_or(0),
x_max: bbox.x_max().unwrap_or(0),
y_min: bbox.y_min().unwrap_or(0),
y_max: bbox.y_max().unwrap_or(0),
tiles,
coverage_percent: coverage_percent(tiles, bbox.count_tiles()),
}
})
.collect();
let tilejson = source.tilejson().clone();
let measured_tile_size = if tilejson.tile_size.is_none() {
source.measure_tile_size().await?
} else {
None
};
let mut report = ProbeReport {
source_type: source.source_type().to_string(),
tile_format: *metadata.tile_format(),
tile_compression: *metadata.tile_compression(),
tilejson,
pyramid,
measured_tile_size,
tile_sizes: None,
contents: None,
};
if matches!(depth, TileSizes | TileContents) {
report.tile_sizes = Some(scan_tile_sizes(source, runtime).await?);
}
if matches!(depth, TileContents) {
report.contents = Some(if metadata.tile_format().to_type() == TileType::Vector {
ContentsReport::Vector(scan_vector_contents(source, runtime, sample).await?)
} else {
ContentsReport::Unsupported
});
}
Ok(report)
}
fn coverage_percent(tiles: u64, total: u64) -> u64 {
if total == 0 {
return 0;
}
let percent = u128::from(tiles) * 100 / u128::from(total);
u64::try_from(percent).unwrap_or(100)
}
async fn scan_tile_sizes(source: &dyn TileSource, runtime: &TilesRuntime) -> Result<TileSizeReport> {
let mut report = TileSizeReport::default();
let mut min_size: u64 = 0;
let tile_pyramid = source.tile_pyramid().await?;
let progress = runtime.create_progress("scanning tiles", tile_pyramid.count_tiles());
for bbox in tile_pyramid.to_iter_bboxes().filter(|b| !b.is_empty()) {
let mut level = LevelSizeStats {
level: bbox.level(),
count: 0,
size_sum: 0,
};
let mut stream = source.tile_size_stream(bbox).await?;
while let Some((coord, size_u32)) = stream.next().await {
let size = u64::from(size_u32);
report.tile_count += 1;
report.size_sum += size;
level.count += 1;
level.size_sum += size;
progress.inc(1);
if size < min_size {
continue;
}
let pos = report
.biggest_tiles
.binary_search_by(|e| e.size.cmp(&size).reverse())
.unwrap_or_else(|p| p);
report.biggest_tiles.insert(
pos,
TileSizeEntry {
size,
x: coord.x,
y: coord.y,
z: coord.level,
},
);
if report.biggest_tiles.len() > BIGGEST_TILES_LIMIT {
report.biggest_tiles.pop();
}
min_size = report.biggest_tiles.last().expect("biggest_tiles is non-empty").size;
}
report.per_level.push(level);
}
progress.finish();
Ok(report)
}
enum TileCheck {
DecodeFailed,
Validated {
issues: Vec<ValidationIssue>,
layers: Vec<LayerStats>,
},
}
fn check_tile(mut tile: Tile) -> TileCheck {
match tile.as_vector() {
Ok(vt) => TileCheck::Validated {
issues: validate_tile(vt),
layers: layer_stats(vt).unwrap_or_default(),
},
Err(_) => TileCheck::DecodeFailed,
}
}
#[derive(Default)]
struct LayerAgg {
tiles: u64,
stats: LayerStats,
}
async fn scan_vector_contents(
source: &dyn TileSource,
runtime: &TilesRuntime,
sample: Option<f64>,
) -> Result<VectorContentsReport> {
let mut report = VectorContentsReport {
sample_fraction: sample,
..VectorContentsReport::default()
};
let mut size_agg: HashMap<(u8, String), LayerAgg> = HashMap::new();
let tile_pyramid = source.tile_pyramid().await?;
let plan = build_scan_plan(tile_pyramid.to_iter_bboxes(), sample)?;
let total_in_plan: u64 = plan.iter().map(TileBBox::count_tiles).sum();
let progress = runtime.create_progress("validating tile contents", total_in_plan);
for bbox in plan {
let mut stream = source
.tile_stream(bbox)
.await?
.map_parallel(|_coord, tile| check_tile(tile));
while let Some((coord, check)) = stream.next().await {
report.tiles_scanned += 1;
progress.inc(1);
let (issues, layers) = match check {
TileCheck::DecodeFailed => {
report.issues.decode_failures += 1;
continue;
}
TileCheck::Validated { issues, layers } => (issues, layers),
};
for layer in &layers {
let entry = size_agg.entry((coord.level, layer.name.clone())).or_default();
entry.tiles += 1;
entry.stats.add(layer);
}
if issues.is_empty() {
continue;
}
report.issues.tiles_with_issues += 1;
for issue in &issues {
report.issues.record(&issue.kind);
if report.samples.len() < VALIDATION_SAMPLE_LIMIT {
report.samples.push(IssueSample {
z: coord.level,
x: coord.x,
y: coord.y,
layer: issue.layer.clone(),
feature_index: issue.feature_index,
kind: describe_issue_kind(&issue.kind),
});
}
}
}
}
progress.finish();
report.layer_sizes = size_agg
.into_iter()
.map(|((zoom, layer), agg)| LayerSizeEntry {
zoom,
layer,
tiles: agg.tiles,
stats: agg.stats,
})
.collect();
report.layer_sizes.sort_by(|a, b| {
a.zoom
.cmp(&b.zoom)
.then(b.stats.encoded_bytes.cmp(&a.stats.encoded_bytes))
.then(a.layer.cmp(&b.layer))
});
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TilesRuntime;
async fn berlin_report(depth: ProbeDepth) -> Result<ProbeReport> {
let runtime = TilesRuntime::new_silent();
let reader = runtime.reader_from_str("../testdata/berlin.mbtiles").await?;
probe_report(&**reader, depth, &runtime, None).await
}
#[tokio::test]
async fn shallow_report_describes_the_container() -> Result<()> {
let report = berlin_report(ProbeDepth::Shallow).await?;
assert_eq!(report.tile_format, TileFormat::MVT);
assert_eq!(report.tile_compression, TileCompression::Gzip);
assert_eq!(report.zoom(), Some(0..=14));
assert!(report.bbox().is_some());
assert!(report.source_type.contains("mbtiles"));
assert!(report.tile_sizes.is_none());
assert!(report.contents.is_none());
Ok(())
}
#[tokio::test]
async fn a_raster_source_that_declares_no_size_gets_one_measured() -> Result<()> {
use versatiles_core::{TileCompression, TilePyramid};
use crate::{MockReader, TileSourceMetadata, Traversal};
let runtime = TilesRuntime::new_silent();
let metadata = TileSourceMetadata::new(TileFormat::PNG, TileCompression::Uncompressed, Traversal::ANY, None);
let reader = MockReader::new_mock(TilePyramid::new_full_up_to(3), metadata)?;
let report = probe_report(&reader, ProbeDepth::Shallow, &runtime, None).await?;
assert_eq!(report.tilejson.tile_size, None, "the source still declares nothing");
assert_eq!(report.measured_tile_size.map(|s| s.size()), Some(256));
let metadata = TileSourceMetadata::new(TileFormat::PNG, TileCompression::Uncompressed, Traversal::ANY, None);
let mut reader = MockReader::new_mock(TilePyramid::new_full_up_to(3), metadata)?;
reader.tilejson_mut().set_tile_size(512)?;
let report = probe_report(&reader, ProbeDepth::Shallow, &runtime, None).await?;
assert_eq!(report.tilejson.tile_size.map(|s| s.size()), Some(512));
assert_eq!(report.measured_tile_size, None);
let report = berlin_report(ProbeDepth::Shallow).await?;
assert_eq!(report.measured_tile_size, None);
Ok(())
}
#[tokio::test]
async fn pyramid_levels_are_ascending_and_non_empty() -> Result<()> {
let report = berlin_report(ProbeDepth::Shallow).await?;
assert!(!report.pyramid.is_empty());
for pair in report.pyramid.windows(2) {
assert!(pair[0].level < pair[1].level, "levels must ascend");
}
for level in &report.pyramid {
assert!(level.tiles > 0, "empty levels must be filtered out");
assert!(level.coverage_percent <= 100);
assert!(
level.coverage_percent > 0,
"a level with tiles cannot cover 0% — a wrapped multiplication reads like this"
);
assert!(level.x_min <= level.x_max);
assert!(level.y_min <= level.y_max);
}
Ok(())
}
#[tokio::test]
async fn tile_sizes_are_consistent_with_their_levels() -> Result<()> {
let report = berlin_report(ProbeDepth::TileSizes).await?;
let sizes = report.tile_sizes.expect("TileSizes depth populates tile_sizes");
assert!(sizes.tile_count > 0);
assert_eq!(sizes.tile_count, sizes.per_level.iter().map(|l| l.count).sum::<u64>());
assert_eq!(sizes.size_sum, sizes.per_level.iter().map(|l| l.size_sum).sum::<u64>());
assert_eq!(sizes.average_size(), sizes.size_sum / sizes.tile_count);
assert!(sizes.biggest_tiles.len() <= BIGGEST_TILES_LIMIT);
for pair in sizes.biggest_tiles.windows(2) {
assert!(pair[0].size >= pair[1].size, "biggest tiles must descend");
}
Ok(())
}
#[tokio::test]
async fn contents_report_orders_layers_and_rolls_them_up() -> Result<()> {
let report = berlin_report(ProbeDepth::TileContents).await?;
let ContentsReport::Vector(contents) = report.contents.expect("populated at TileContents") else {
panic!("berlin.mbtiles is a vector source");
};
assert!(contents.tiles_scanned > 0);
assert!(!contents.layer_sizes.is_empty());
assert_eq!(contents.sample_fraction, None);
for pair in contents.layer_sizes.windows(2) {
let ok = pair[0].zoom < pair[1].zoom
|| (pair[0].zoom == pair[1].zoom && pair[0].stats.encoded_bytes >= pair[1].stats.encoded_bytes);
assert!(ok, "unordered: {:?} then {:?}", pair[0], pair[1]);
}
let totals = contents.layer_totals();
assert_eq!(
totals.iter().map(|(_, s)| s.encoded_bytes).sum::<usize>(),
contents.total_bytes()
);
for pair in totals.windows(2) {
assert!(pair[0].1.encoded_bytes >= pair[1].1.encoded_bytes);
}
Ok(())
}
#[tokio::test]
async fn sample_fraction_is_recorded_and_reduces_the_scan() -> Result<()> {
let runtime = TilesRuntime::new_silent();
let reader = runtime.reader_from_str("../testdata/berlin.mbtiles").await?;
let full = probe_report(&**reader, ProbeDepth::TileContents, &runtime, None).await?;
let sampled = probe_report(&**reader, ProbeDepth::TileContents, &runtime, Some(0.1)).await?;
let ContentsReport::Vector(full) = full.contents.unwrap() else {
panic!("vector source")
};
let ContentsReport::Vector(sampled) = sampled.contents.unwrap() else {
panic!("vector source")
};
assert_eq!(sampled.sample_fraction, Some(0.1));
assert!(
sampled.tiles_scanned <= full.tiles_scanned,
"sampling read {} but a full scan read {}",
sampled.tiles_scanned,
full.tiles_scanned
);
Ok(())
}
#[test]
fn counters_split_issues_by_how_they_are_fixed() {
let mut counters = ValidationCounters::default();
counters.record(&IssueKind::MissingExtent);
counters.record(&IssueKind::UnknownGeometryType);
counters.decode_failures = 5;
assert_eq!(counters.total_issues(), 2, "decode failures are not spec issues");
assert_eq!(counters.fixable_automatically(), 1);
assert_eq!(counters.needs_drop_offenders(), 1);
assert_eq!(
counters.by_kind().iter().map(|(_, n)| n).sum::<u64>(),
counters.total_issues()
);
}
#[test]
fn coverage_survives_a_full_deep_level() {
for level in 0..=30u8 {
let tiles = 1u64 << (2 * u32::from(level));
assert_eq!(coverage_percent(tiles, tiles), 100, "full level {level}");
if level > 0 {
assert_eq!(coverage_percent(tiles / 2, tiles), 50, "half of level {level}");
}
}
}
#[test]
fn coverage_of_nothing_is_zero_rather_than_a_division_by_zero() {
assert_eq!(coverage_percent(0, 0), 0);
}
}