#[derive(Debug, Clone)]
pub struct SpriteScore {
pub size: u32,
pub checks: Vec<Check>,
pub total_score: f32,
pub passed: bool,
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: &'static str,
pub score: f32,
pub passed: bool,
pub detail: String,
}
impl std::fmt::Display for SpriteScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "=== SPRITE SCORE: {:.0}/100 {} ===", self.total_score, if self.passed { "PASS" } else { "FAIL" })?;
writeln!(f, "Size: {}x{}", self.size, self.size)?;
for c in &self.checks {
writeln!(f, " {} {}: {:.0}/100 — {}", if c.passed { "✓" } else { "✗" }, c.name, c.score, c.detail)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ShapeView { FrontView, SideView }
pub fn verify_sprite(pixels: &[u8], size: u32) -> SpriteScore {
verify_sprite_with_view(pixels, size, ShapeView::FrontView)
}
pub fn verify_sprite_with_view(pixels: &[u8], size: u32, view: ShapeView) -> SpriteScore {
assert_eq!(pixels.len(), (size * size * 4) as usize);
let mut checks = Vec::new();
checks.push(check_fill_ratio(pixels, size));
checks.push(check_padding(pixels, size));
checks.push(check_color_count(pixels, size));
checks.push(check_outline(pixels, size));
if view == ShapeView::FrontView { checks.push(check_symmetry(pixels, size)); }
checks.push(check_silhouette(pixels, size));
checks.push(check_shading_direction(pixels, size));
checks.push(check_no_orphan_pixels(pixels, size));
let total: f32 = checks.iter().map(|c| c.score).sum::<f32>() / checks.len() as f32;
let passed = total >= 65.0;
SpriteScore { size, checks, total_score: total, passed }
}
fn is_opaque(pixels: &[u8], i: usize) -> bool { pixels[i * 4 + 3] > 0 }
fn pixel_rgb(pixels: &[u8], i: usize) -> [u8; 3] { [pixels[i * 4], pixels[i * 4 + 1], pixels[i * 4 + 2]] }
fn luminance(r: u8, g: u8, b: u8) -> f32 { 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32 }
fn check_fill_ratio(pixels: &[u8], size: u32) -> Check {
let total = (size * size) as f32;
let filled = (0..size * size).filter(|&i| is_opaque(pixels, i as usize)).count() as f32;
let ratio = filled / total;
let score = if ratio >= 0.35 && ratio <= 0.75 { 100.0 } else if ratio >= 0.20 && ratio <= 0.85 { 60.0 } else { 20.0 };
Check { name: "fill_ratio", score, passed: score >= 60.0, detail: format!("{:.0}% filled (target 35-75%)", ratio * 100.0) }
}
fn check_padding(pixels: &[u8], size: u32) -> Check {
let s = size as usize;
let mut violations = 0;
let border_total = s * 4 - 4;
for x in 0..s { if is_opaque(pixels, x) { violations += 1; } }
for x in 0..s { if is_opaque(pixels, (s - 1) * s + x) { violations += 1; } }
for y in 0..s { if is_opaque(pixels, y * s) { violations += 1; } }
for y in 0..s { if is_opaque(pixels, y * s + s - 1) { violations += 1; } }
let ratio = violations as f32 / border_total as f32;
let score = if ratio == 0.0 { 100.0 } else if ratio < 0.1 { 80.0 } else if ratio < 0.25 { 60.0 } else { 40.0 };
Check { name: "padding", score, passed: score >= 40.0, detail: format!("{}/{} border pixels filled ({:.0}%)", violations, border_total, ratio * 100.0) }
}
fn check_color_count(pixels: &[u8], size: u32) -> Check {
let mut colors = std::collections::HashSet::new();
for i in 0..(size * size) as usize { if is_opaque(pixels, i) { colors.insert(pixel_rgb(pixels, i)); } }
Check { name: "color_count", score: 100.0, passed: true, detail: format!("{} unique colors ({}x{})", colors.len(), size, size) }
}
fn check_outline(pixels: &[u8], size: u32) -> Check {
let s = size as usize;
let mut edge_pixels = 0; let mut distinct_edges = 0;
let mut interior_lum_sum = 0.0f32; let mut interior_count = 0;
for y in 1..s - 1 { for x in 1..s - 1 {
let i = y * s + x;
if !is_opaque(pixels, i) { continue; }
let neighbors = [(y.wrapping_sub(1)) * s + x, (y + 1) * s + x, y * s + x.wrapping_sub(1), y * s + x + 1];
let is_edge = neighbors.iter().any(|&ni| ni < s * s && !is_opaque(pixels, ni));
if !is_edge { let [r, g, b] = pixel_rgb(pixels, i); interior_lum_sum += luminance(r, g, b); interior_count += 1; }
}}
let avg_interior = if interior_count > 0 { interior_lum_sum / interior_count as f32 } else { 128.0 };
for y in 1..s - 1 { for x in 1..s - 1 {
let i = y * s + x;
if !is_opaque(pixels, i) { continue; }
let neighbors = [(y.wrapping_sub(1)) * s + x, (y + 1) * s + x, y * s + x.wrapping_sub(1), y * s + x + 1];
let is_edge = neighbors.iter().any(|&ni| ni < s * s && !is_opaque(pixels, ni));
if is_edge {
edge_pixels += 1;
let [r, g, b] = pixel_rgb(pixels, i);
let edge_lum = luminance(r, g, b);
if edge_lum < avg_interior - 20.0 || edge_lum < 100.0 { distinct_edges += 1; }
}
}}
let ratio = if edge_pixels > 0 { distinct_edges as f32 / edge_pixels as f32 } else { 0.0 };
let score = (ratio * 100.0).min(100.0);
Check { name: "outline", score, passed: score >= 40.0, detail: format!("{}/{} edge pixels distinct from interior", distinct_edges, edge_pixels) }
}
fn check_symmetry(pixels: &[u8], size: u32) -> Check {
let s = size as usize; let half = s / 2;
let mut matching = 0; let mut total = 0;
for y in 0..s { for x in 0..half {
let left = y * s + x; let right = y * s + (s - 1 - x);
let l_opaque = is_opaque(pixels, left); let r_opaque = is_opaque(pixels, right);
if l_opaque || r_opaque { total += 1; if l_opaque == r_opaque { matching += 1; } }
}}
let ratio = if total > 0 { matching as f32 / total as f32 } else { 0.0 };
let score = 50.0 + ratio * 50.0;
Check { name: "symmetry", score, passed: true, detail: format!("{:.0}% bilateral symmetry", ratio * 100.0) }
}
fn check_silhouette(pixels: &[u8], size: u32) -> Check {
let s = size as usize;
let mut visited = vec![false; s * s];
let mut largest = 0usize;
let total_opaque = (0..s * s).filter(|&i| is_opaque(pixels, i)).count();
for start in 0..s * s {
if !is_opaque(pixels, start) || visited[start] { continue; }
let mut queue = vec![start]; let mut region_size = 0;
visited[start] = true;
while let Some(pos) = queue.pop() {
region_size += 1;
let x = pos % s; let y = pos / s;
for (dx, dy) in &[(0isize, -1isize), (0, 1), (-1, 0), (1, 0)] {
let nx = x as isize + dx; let ny = y as isize + dy;
if nx >= 0 && nx < s as isize && ny >= 0 && ny < s as isize {
let ni = ny as usize * s + nx as usize;
if !visited[ni] && is_opaque(pixels, ni) { visited[ni] = true; queue.push(ni); }
}
}
}
if region_size > largest { largest = region_size; }
}
let connectivity = if total_opaque > 0 { largest as f32 / total_opaque as f32 } else { 0.0 };
let score = connectivity * 100.0;
Check { name: "silhouette", score, passed: score >= 70.0, detail: format!("{:.0}% connected (largest region {}/{})", connectivity * 100.0, largest, total_opaque) }
}
fn check_shading_direction(pixels: &[u8], size: u32) -> Check {
let s = size as usize; let half = s / 2;
let mut left_lum = 0.0f32; let mut right_lum = 0.0f32;
let mut left_count = 0; let mut right_count = 0;
for y in 0..s { for x in 0..s {
let i = y * s + x;
if !is_opaque(pixels, i) { continue; }
let [r, g, b] = pixel_rgb(pixels, i);
let l = luminance(r, g, b);
if x < half { left_lum += l; left_count += 1; } else { right_lum += l; right_count += 1; }
}}
let avg_left = if left_count > 0 { left_lum / left_count as f32 } else { 0.0 };
let avg_right = if right_count > 0 { right_lum / right_count as f32 } else { 0.0 };
let correct = avg_left >= avg_right;
let diff = (avg_left - avg_right).abs();
let score = if correct && diff > 5.0 { 100.0 } else if correct || diff < 5.0 { 90.0 } else if diff < 15.0 { 75.0 } else { 60.0 };
Check { name: "shading", score, passed: score >= 50.0, detail: format!("left avg lum {:.0}, right {:.0}", avg_left, avg_right) }
}
fn check_no_orphan_pixels(pixels: &[u8], size: u32) -> Check {
let s = size as usize;
let mut orphans = 0; let mut total_opaque = 0;
for y in 1..s - 1 { for x in 1..s - 1 {
let i = y * s + x;
if !is_opaque(pixels, i) { continue; }
total_opaque += 1;
let neighbors = [(y - 1) * s + x, (y + 1) * s + x, y * s + x - 1, y * s + x + 1];
let opaque_neighbors = neighbors.iter().filter(|&&ni| is_opaque(pixels, ni)).count();
if opaque_neighbors == 0 { orphans += 1; }
}}
let ratio = if total_opaque > 0 { orphans as f32 / total_opaque as f32 } else { 0.0 };
let score = ((1.0 - ratio) * 100.0).max(0.0);
Check { name: "no_orphans", score, passed: score >= 80.0, detail: format!("{} orphan pixels out of {} ({:.1}%)", orphans, total_opaque, ratio * 100.0) }
}
pub struct ReferenceSilhouette {
pub mask: Vec<bool>,
pub size: u32,
}
impl ReferenceSilhouette {
pub fn from_mask(source_mask: &[bool], source_w: usize, source_h: usize, target_size: u32) -> Self {
let ts = target_size as usize;
let mut mask = vec![false; ts * ts];
let mut min_x = source_w; let mut max_x = 0usize; let mut min_y = source_h; let mut max_y = 0usize;
for y in 0..source_h { for x in 0..source_w {
if source_mask[y * source_w + x] { min_x = min_x.min(x); max_x = max_x.max(x); min_y = min_y.min(y); max_y = max_y.max(y); }
}}
if max_x <= min_x || max_y <= min_y { return Self { mask, size: target_size }; }
let bbox_w = (max_x - min_x + 1) as f32;
let bbox_h = (max_y - min_y + 1) as f32;
let usable = (ts - 2) as f32;
let scale = (usable / bbox_w).min(usable / bbox_h);
let sprite_w = (bbox_w * scale) as usize;
let sprite_h = (bbox_h * scale) as usize;
let ox = (ts - sprite_w) / 2;
let oy = ts - sprite_h - 1;
for ty in 0..sprite_h { for tx in 0..sprite_w {
let sx = min_x + (tx as f32 / scale) as usize;
let sy = min_y + (ty as f32 / scale) as usize;
if sx < source_w && sy < source_h && source_mask[sy * source_w + sx] {
let px = ox + tx; let py = oy + ty;
if px < ts && py < ts { mask[py * ts + px] = true; }
}
}}
Self { mask, size: target_size }
}
}
pub fn check_reference_match(pixels: &[u8], size: u32, reference: &ReferenceSilhouette) -> Check {
assert_eq!(size, reference.size);
let n = (size * size) as usize;
let mut intersection = 0usize; let mut union = 0usize;
for i in 0..n {
let sprite_on = pixels[i * 4 + 3] > 0;
let ref_on = reference.mask[i];
if sprite_on && ref_on { intersection += 1; }
if sprite_on || ref_on { union += 1; }
}
let iou = if union > 0 { intersection as f32 / union as f32 } else { 0.0 };
let ref_total = reference.mask.iter().filter(|&&m| m).count();
let recall = if ref_total > 0 { intersection as f32 / ref_total as f32 } else { 0.0 };
let score = (iou * 70.0 + recall * 30.0).min(100.0);
let passed = iou >= 0.5;
Check { name: "reference_match", score, passed, detail: format!("IoU {:.2}, recall {:.2} (intersection {}, union {}, ref pixels {})", iou, recall, intersection, union, ref_total) }
}
pub fn verify_sprite_with_reference(pixels: &[u8], size: u32, view: ShapeView, reference: &ReferenceSilhouette) -> SpriteScore {
assert_eq!(pixels.len(), (size * size * 4) as usize);
let mut checks = Vec::new();
checks.push(check_fill_ratio(pixels, size));
checks.push(check_padding(pixels, size));
checks.push(check_color_count(pixels, size));
checks.push(check_outline(pixels, size));
if view == ShapeView::FrontView { checks.push(check_symmetry(pixels, size)); }
checks.push(check_silhouette(pixels, size));
checks.push(check_shading_direction(pixels, size));
checks.push(check_no_orphan_pixels(pixels, size));
checks.push(check_reference_match(pixels, size, reference));
let total: f32 = checks.iter().map(|c| c.score).sum::<f32>() / checks.len() as f32;
let passed = total >= 65.0;
SpriteScore { size, checks, total_score: total, passed }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_sprite_fails() {
let pixels = vec![0u8; 32 * 32 * 4];
let score = verify_sprite(&pixels, 32);
assert!(!score.passed, "Empty sprite should fail");
eprintln!("{}", score);
}
#[test]
fn solid_square_scores() {
let mut pixels = vec![0u8; 32 * 32 * 4];
for y in 6..26 { for x in 6..26 {
let i = (y * 32 + x) * 4;
pixels[i] = 200; pixels[i + 1] = 50; pixels[i + 2] = 50; pixels[i + 3] = 255;
}}
let score = verify_sprite(&pixels, 32);
eprintln!("{}", score);
assert!(score.total_score > 40.0, "Solid square should score reasonably");
}
}