sprite / scoring.py
Cnass's picture
Upload 15 files
d708e6c verified
Raw History Blame Contribute Delete
10.5 kB
"""
scoring.py
----------
Per-sprite quality analysis, run AFTER segmentation/extraction.
All metrics are CPU-only (numpy / scipy / basic stats), no ML models, per
the hardware constraints (8 vCPU / 32GB RAM, GPU not guaranteed).
Every sub-score is 0-100. The overall score is a weighted combination.
Small sprites are explicitly NOT penalized for being small -- resolution
is only one factor among several, as required by the spec.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy import ndimage
@dataclass
class QualityReport:
overall: float
pixel_art_probability: float
sharpness: float
color_consistency: float
compression_artifact_score: float
transparency_quality: float
resolution_suitability: float
pixel_grid_consistency: float
noise_score: float
unique_colors: int
has_alpha: bool
def to_dict(self) -> dict:
return {
"overall_quality_score": round(self.overall, 1),
"pixel_art_probability": round(self.pixel_art_probability, 1),
"sharpness_score": round(self.sharpness, 1),
"color_consistency": round(self.color_consistency, 1),
"compression_artifact_score": round(self.compression_artifact_score, 1),
"transparency_quality": round(self.transparency_quality, 1),
"resolution_suitability": round(self.resolution_suitability, 1),
"pixel_grid_consistency": round(self.pixel_grid_consistency, 1),
"noise_score": round(self.noise_score, 1),
"unique_colors": self.unique_colors,
"has_alpha": self.has_alpha,
}
def _unique_colors(rgba: np.ndarray) -> int:
if rgba.size == 0:
return 0
flat = rgba.reshape(-1, 4)
return int(np.unique(flat, axis=0).shape[0])
def _sharpness_score(rgb: np.ndarray) -> float:
"""
Laplacian-variance based sharpness. Pixel art is inherently sharp
(hard edges); a low score suggests blur (e.g. from a bad scrape/resize
upstream, or JPEG blur).
"""
if rgb.shape[0] < 3 or rgb.shape[1] < 3:
return 100.0 # too small to meaningfully measure; don't penalize
gray = rgb.mean(axis=2)
lap = ndimage.laplace(gray)
variance = float(lap.var())
# Empirically, pixel art laplacian variance is high (hard edges).
# Normalize with a soft cap.
score = min(100.0, (variance / 40.0) * 100.0)
return max(0.0, score)
def _color_consistency_score(rgb: np.ndarray, alpha: np.ndarray) -> float:
"""
Pixel art typically uses a small, flat palette (little to no gradient
noise within a single color region). We approximate "flatness" by
checking what fraction of colors are exact-duplicates of a small
palette vs. a huge spread of near-unique colors (a sign of photographic
content, JPEG ringing, or gradients).
"""
mask = alpha > 10
if mask.sum() == 0:
return 50.0
pixels = rgb[mask]
total = pixels.shape[0]
unique = np.unique(pixels, axis=0).shape[0]
ratio = unique / max(1, total)
# Lower ratio (few unique colors relative to pixel count) = more
# consistent/flat = higher score.
score = 100.0 * (1.0 - min(1.0, ratio * 3.0))
return max(0.0, min(100.0, score))
def _compression_artifact_score(rgb: np.ndarray, alpha: np.ndarray) -> float:
"""
Detects JPEG-style 8x8 blockiness / ringing near edges -- pixel art
should have none of this if it was saved as PNG. We look for small
high-frequency noise adjacent to hard edges, which flat pixel art does
not exhibit but re-compressed JPEG pixel art does.
"""
if rgb.shape[0] < 8 or rgb.shape[1] < 8:
return 100.0
gray = rgb.mean(axis=2)
gx = np.abs(np.diff(gray, axis=1))
gy = np.abs(np.diff(gray, axis=0))
# Ringing shows up as many small (1-10 intensity) differences rather
# than a few large, clean edge jumps typical of flat-color pixel art.
small_diffs = ((gx > 1) & (gx < 10)).mean() + ((gy > 1) & (gy < 10)).mean()
score = 100.0 - min(100.0, small_diffs * 150.0)
return max(0.0, score)
def _transparency_quality_score(alpha: np.ndarray) -> float:
"""
Good pixel art alpha is binary-ish (fully opaque or fully transparent)
or has small, deliberate anti-aliased edges. Large regions of
"mushy" partial alpha (e.g. 40-180 out of 255) suggest a bad cutout or
a blurred/resized source.
"""
if alpha.size == 0:
return 0.0
if (alpha == 255).all():
return 100.0 # fully opaque sprite, nothing to penalize
mushy = ((alpha > 20) & (alpha < 235)).mean()
score = 100.0 - min(100.0, mushy * 200.0)
return max(0.0, score)
def _resolution_suitability_score(width: int, height: int) -> float:
"""
Resolution is only ONE factor and small sprites are explicitly valid
(8x8, 16x16 etc. per spec). We only penalize truly degenerate sizes
(near-zero) or absurdly large "sprites" that are probably a
segmentation failure.
"""
longest = max(width, height)
if longest < 2:
return 0.0
if longest > 1024:
return 40.0 # plausible tileset/background slipped through, flag lightly
return 100.0
def _pixel_grid_consistency_score(rgb: np.ndarray, alpha: np.ndarray) -> float:
"""
Heuristic for "is this drawn on a clean pixel grid" by checking if
runs of identical adjacent pixels are chunky (pixel-art-like) rather
than constantly varying by 1-2 intensity levels (photographic/
gradient-like). We measure the average run-length of identical pixels
along rows.
"""
mask = alpha > 10
if mask.sum() < 4:
return 50.0
gray = (rgb.mean(axis=2)).astype(np.int16)
row_run_lengths = []
for row in gray:
if row.size < 2:
continue
changes = np.count_nonzero(np.diff(row))
run_estimate = row.size / max(1, changes + 1)
row_run_lengths.append(run_estimate)
if not row_run_lengths:
return 50.0
avg_run = float(np.mean(row_run_lengths))
# Longer average runs = flatter/blockier = more pixel-art-like.
score = min(100.0, (avg_run / 3.0) * 100.0)
return max(0.0, score)
def _noise_score(rgb: np.ndarray, alpha: np.ndarray) -> float:
"""
High score = low noise. Pixel art has large flat-color regions with
essentially zero internal variance; noise/dithering/JPEG artifacts show
up as intensity jitter *within* those flat regions. Raw local variance
over the whole sprite is dominated by legitimate hard edges (a red
block next to a white block is a huge, correct jump, not noise), so we
first find "flat interior" pixels -- ones whose immediate neighbours
are all nearly identical to them -- and only measure variance among
those. A photographic/gradient sprite has almost no such flat interior
and will score low; clean pixel art is mostly flat interior and scores
high.
"""
if rgb.shape[0] < 3 or rgb.shape[1] < 3:
return 100.0
gray = rgb.mean(axis=2)
local_max = ndimage.maximum_filter(gray, size=3)
local_min = ndimage.minimum_filter(gray, size=3)
local_range = local_max - local_min
mask = alpha > 10
if mask.sum() == 0:
return 50.0
# "Flat interior" = pixels where the 3x3 neighbourhood barely varies at
# all (well inside a single flat-colored region, away from any edge).
flat_interior = mask & (local_range < 6)
if flat_interior.sum() < max(4, int(0.05 * mask.sum())):
# Almost no flat regions at all -- e.g. a 1-2px sprite, or genuinely
# noisy/gradient content with no flat areas. Fall back to a mild
# penalty based on overall local range rather than claiming 100.
avg_range = float(local_range[mask].mean()) if mask.sum() else 0.0
return max(0.0, 100.0 - min(100.0, avg_range * 2.0))
# Within the flat interior, real dithering/JPEG noise still causes
# small (1-4 intensity level) jitter even though the *range* filter
# above didn't catch it at the 6-level threshold. Measure that.
local_mean = ndimage.uniform_filter(gray, size=3)
local_sq_mean = ndimage.uniform_filter(gray ** 2, size=3)
local_var = np.clip(local_sq_mean - local_mean ** 2, 0, None)
avg_var = float(local_var[flat_interior].mean())
score = 100.0 - min(100.0, avg_var * 4.0)
return max(0.0, score)
def _pixel_art_probability(
sharpness: float,
color_consistency: float,
compression: float,
grid_consistency: float,
noise: float,
) -> float:
weights = {
"sharpness": 0.25,
"color_consistency": 0.25,
"compression": 0.2,
"grid_consistency": 0.2,
"noise": 0.1,
}
return (
sharpness * weights["sharpness"]
+ color_consistency * weights["color_consistency"]
+ compression * weights["compression"]
+ grid_consistency * weights["grid_consistency"]
+ noise * weights["noise"]
)
def analyze_sprite(rgba: np.ndarray) -> QualityReport:
if rgba.size == 0 or rgba.shape[0] == 0 or rgba.shape[1] == 0:
return QualityReport(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, False)
rgb = rgba[:, :, :3].astype(np.float32)
alpha = rgba[:, :, 3]
has_alpha = bool((alpha < 250).any())
sharpness = _sharpness_score(rgb)
color_consistency = _color_consistency_score(rgb, alpha)
compression = _compression_artifact_score(rgb, alpha)
transparency = _transparency_quality_score(alpha)
resolution = _resolution_suitability_score(rgba.shape[1], rgba.shape[0])
grid_consistency = _pixel_grid_consistency_score(rgb, alpha)
noise = _noise_score(rgb, alpha)
unique_colors = _unique_colors(rgba)
pixel_art_prob = _pixel_art_probability(sharpness, color_consistency, compression, grid_consistency, noise)
overall = (
pixel_art_prob * 0.35
+ sharpness * 0.15
+ color_consistency * 0.15
+ compression * 0.1
+ transparency * 0.1
+ resolution * 0.1
+ noise * 0.05
)
return QualityReport(
overall=overall,
pixel_art_probability=pixel_art_prob,
sharpness=sharpness,
color_consistency=color_consistency,
compression_artifact_score=compression,
transparency_quality=transparency,
resolution_suitability=resolution,
pixel_grid_consistency=grid_consistency,
noise_score=noise,
unique_colors=unique_colors,
has_alpha=has_alpha,
)