Spaces:
Paused
Paused
Download scoring.py from Cnass/sprite: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/Cnass/sprite/resolve/main/scoring.py
- Command line
-
hf download hf://spaces/Cnass/sprite/scoring.py
-
curl -L -o scoring.py https://huggingface.co/spaces/Cnass/sprite/resolve/main/scoring.py
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 | |
| 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, | |
| ) | |