b451f83174
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
289 lines
12 KiB
C#
289 lines
12 KiB
C#
using SixLabors.Fonts;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.Drawing.Processing;
|
||
using SixLabors.ImageSharp.PixelFormats;
|
||
using SixLabors.ImageSharp.Processing;
|
||
|
||
namespace Theriapolis.Tools.Commands;
|
||
|
||
/// <summary>
|
||
/// tile-analyze --dir <path> [--sheet <out.png>]
|
||
/// tile-analyze --files <f1.png> <f2.png> ... [--sheet <out.png>]
|
||
///
|
||
/// Reports per-tile diagnostics for a folder of 32×32 PNG tiles
|
||
/// (typically a Pixellab tiles_pro download). Used to vet tile quality
|
||
/// before saving picks into <c>Content/Gfx/tactical/</c>.
|
||
///
|
||
/// What it checks:
|
||
///
|
||
/// • <b>Border edges (0–4)</b>: how many of the four perimeter rows/cols are
|
||
/// ≥80% "dark uniform" pixels (max channel ≤ 95 AND max - min ≤ 25). Catches
|
||
/// the hard near-black or dark-grey frames that the regular create_tiles_pro
|
||
/// path bakes around every tile. Should be 0 for a clean surface tile.
|
||
///
|
||
/// • <b>Opaque %</b>: fraction of pixels with α ≥ 128. Catches the failure
|
||
/// mode where a "marsh" or other prompt produces transparent decoration
|
||
/// sprites instead of edge-to-edge surface tiles. Surface tiles want ~100%;
|
||
/// decoration sprites are typically 30–70%.
|
||
///
|
||
/// • <b>Shadow scores (top/bot/lef/rig)</b>: brightness drop on each edge
|
||
/// versus the interior average. Positive = edge is darker than interior.
|
||
/// The pseudo-3D shading that <c>tile_view: "low top-down"</c> bakes in
|
||
/// shows up here as a +50 to +90 drop on the bottom and right edges that
|
||
/// the border detector misses (the colors aren't black-uniform, just
|
||
/// darker). Anything > 30 is a red flag for tiling: adjacent tiles will
|
||
/// show a visible diagonal grid of dark seams.
|
||
///
|
||
/// Optionally writes a labeled 4×-upscaled contact sheet so you can present
|
||
/// the batch to a user for picks. Labels turn ORANGE for any tile that fails
|
||
/// any check (borders > 0, opaque < 95%, or shadow > threshold).
|
||
///
|
||
/// See <c>theriapolis-tile-generation-handoff.md</c> for the full Pixellab
|
||
/// MCP workflow this tool slots into.
|
||
/// </summary>
|
||
public static class TileAnalyze
|
||
{
|
||
public static int Run(string[] args)
|
||
{
|
||
string? dir = null;
|
||
string? sheetOut = null;
|
||
var explicitFiles = new List<string>();
|
||
int shadowThreshold = 30;
|
||
int upscale = 4;
|
||
|
||
for (int i = 0; i < args.Length; i++)
|
||
{
|
||
switch (args[i].ToLowerInvariant())
|
||
{
|
||
case "--dir":
|
||
if (i + 1 < args.Length) dir = args[++i];
|
||
break;
|
||
case "--sheet":
|
||
if (i + 1 < args.Length) sheetOut = args[++i];
|
||
break;
|
||
case "--shadow-threshold":
|
||
if (i + 1 < args.Length) shadowThreshold = int.Parse(args[++i]);
|
||
break;
|
||
case "--upscale":
|
||
if (i + 1 < args.Length) upscale = int.Parse(args[++i]);
|
||
break;
|
||
case "--files":
|
||
while (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
|
||
explicitFiles.Add(args[++i]);
|
||
break;
|
||
}
|
||
}
|
||
|
||
List<string> files;
|
||
if (dir is not null)
|
||
{
|
||
if (!Directory.Exists(dir))
|
||
{
|
||
Console.Error.WriteLine($"Directory not found: {dir}");
|
||
return 1;
|
||
}
|
||
// Sort numerically when the filename is tile_N.png — common Pixellab layout.
|
||
files = Directory.EnumerateFiles(dir, "*.png")
|
||
.OrderBy(NumericOrderKey)
|
||
.ToList();
|
||
}
|
||
else if (explicitFiles.Count > 0)
|
||
{
|
||
files = explicitFiles;
|
||
}
|
||
else
|
||
{
|
||
PrintHelp();
|
||
return 1;
|
||
}
|
||
if (files.Count == 0) { Console.Error.WriteLine("No PNGs found."); return 1; }
|
||
|
||
var stats = new List<TileStat>(files.Count);
|
||
Console.WriteLine($"Analyzing {files.Count} tiles (shadow threshold {shadowThreshold})\n");
|
||
Console.WriteLine($"{"file",-32} | brd | op% | int | top bot lef rig | verdict");
|
||
Console.WriteLine(new string('-', 90));
|
||
foreach (var path in files)
|
||
{
|
||
using var img = Image.Load<Rgba32>(path);
|
||
var st = AnalyzeTile(path, img);
|
||
stats.Add(st);
|
||
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
|
||
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
|
||
string verdict = (st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold)
|
||
? "ok"
|
||
: "⚠";
|
||
Console.WriteLine(
|
||
$"{Path.GetFileNameWithoutExtension(path),-32} | {st.BorderEdges} | {st.OpaquePct,3}% | {st.InteriorBrightness,3} |" +
|
||
$" {st.TopDiff,4} {st.BotDiff,4} {st.LefDiff,4} {st.RigDiff,4} | {verdict}");
|
||
}
|
||
|
||
if (sheetOut is not null)
|
||
{
|
||
BuildContactSheet(files, stats, sheetOut, upscale, shadowThreshold);
|
||
Console.WriteLine($"\nContact sheet: {sheetOut}");
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Numeric key so "tile_2" sorts before "tile_10".
|
||
private static (int, string) NumericOrderKey(string path)
|
||
{
|
||
var name = Path.GetFileNameWithoutExtension(path);
|
||
int u = name.LastIndexOf('_');
|
||
if (u >= 0 && int.TryParse(name[(u + 1)..], out var n)) return (n, name);
|
||
return (int.MaxValue, name);
|
||
}
|
||
|
||
// ── Detector logic ────────────────────────────────────────────────────
|
||
|
||
public readonly record struct TileStat(
|
||
int BorderEdges,
|
||
int OpaquePct,
|
||
int InteriorBrightness,
|
||
int TopDiff,
|
||
int BotDiff,
|
||
int LefDiff,
|
||
int RigDiff);
|
||
|
||
public static TileStat AnalyzeTile(string path, Image<Rgba32> img)
|
||
{
|
||
int border = CountBorderEdges(img);
|
||
int opaque = CountOpaqueFraction(img);
|
||
var (interior, top, bot, lef, rig) = ShadowScores(img);
|
||
return new TileStat(border, opaque, interior, top, bot, lef, rig);
|
||
}
|
||
|
||
/// <summary>
|
||
/// "Border" pixel: alpha ≥ 128 AND max channel ≤ 95 AND (max - min) ≤ 25.
|
||
/// Catches the dark-uniform frames Pixellab bakes around tiles.
|
||
/// </summary>
|
||
private static bool IsBorderPixel(Rgba32 p)
|
||
{
|
||
if (p.A < 128) return false;
|
||
int max = Math.Max(p.R, Math.Max(p.G, p.B));
|
||
int min = Math.Min(p.R, Math.Min(p.G, p.B));
|
||
return max <= 95 && (max - min) <= 25;
|
||
}
|
||
|
||
/// <summary>Number of edges (0–4) where ≥80% of perimeter pixels are border-pixels.</summary>
|
||
public static int CountBorderEdges(Image<Rgba32> img)
|
||
{
|
||
int w = img.Width, h = img.Height;
|
||
int top = 0, bot = 0, lef = 0, rig = 0;
|
||
for (int x = 0; x < w; x++)
|
||
{
|
||
if (IsBorderPixel(img[x, 0])) top++;
|
||
if (IsBorderPixel(img[x, h - 1])) bot++;
|
||
}
|
||
for (int y = 0; y < h; y++)
|
||
{
|
||
if (IsBorderPixel(img[0, y])) lef++;
|
||
if (IsBorderPixel(img[w - 1, y])) rig++;
|
||
}
|
||
int edges = 0;
|
||
if (top >= w * 0.8) edges++;
|
||
if (bot >= w * 0.8) edges++;
|
||
if (lef >= h * 0.8) edges++;
|
||
if (rig >= h * 0.8) edges++;
|
||
return edges;
|
||
}
|
||
|
||
/// <summary>Percent (0–100) of pixels with alpha ≥ 128.</summary>
|
||
public static int CountOpaqueFraction(Image<Rgba32> img)
|
||
{
|
||
int total = img.Width * img.Height, opaque = 0;
|
||
for (int y = 0; y < img.Height; y++)
|
||
for (int x = 0; x < img.Width; x++)
|
||
if (img[x, y].A >= 128) opaque++;
|
||
return (int)(opaque * 100.0 / total);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Edge-vs-interior brightness drop for each side. Positive value = edge
|
||
/// is darker than interior (= bad: baked-in shadow gradient).
|
||
/// Interior excludes a 4-pixel margin so it's the "true" middle.
|
||
/// </summary>
|
||
public static (int interior, int topDiff, int botDiff, int lefDiff, int rigDiff) ShadowScores(Image<Rgba32> img)
|
||
{
|
||
int w = img.Width, h = img.Height;
|
||
long ir = 0, ig = 0, ib = 0, ic = 0;
|
||
for (int y = 4; y < h - 4; y++)
|
||
for (int x = 4; x < w - 4; x++)
|
||
{
|
||
var p = img[x, y]; if (p.A < 128) continue;
|
||
ir += p.R; ig += p.G; ib += p.B; ic++;
|
||
}
|
||
if (ic == 0) return (0, 0, 0, 0, 0);
|
||
int avgR = (int)(ir / ic), avgG = (int)(ig / ic), avgB = (int)(ib / ic);
|
||
int interior = (avgR + avgG + avgB) / 3;
|
||
|
||
int EdgeBrightness(Func<int, (int x, int y)> sel, int len)
|
||
{
|
||
long er = 0, eg = 0, eb = 0;
|
||
for (int i = 0; i < len; i++)
|
||
{
|
||
var (x, y) = sel(i);
|
||
var p = img[x, y];
|
||
er += p.R; eg += p.G; eb += p.B;
|
||
}
|
||
return ((int)(er / len) + (int)(eg / len) + (int)(eb / len)) / 3;
|
||
}
|
||
int top = EdgeBrightness(i => (i, 0), w);
|
||
int bot = EdgeBrightness(i => (i, h - 1), w);
|
||
int lef = EdgeBrightness(i => (0, i), h);
|
||
int rig = EdgeBrightness(i => (w - 1, i), h);
|
||
return (interior, interior - top, interior - bot, interior - lef, interior - rig);
|
||
}
|
||
|
||
// ── Contact sheet ─────────────────────────────────────────────────────
|
||
|
||
private static void BuildContactSheet(
|
||
List<string> files, List<TileStat> stats, string outPath,
|
||
int upscale, int shadowThreshold)
|
||
{
|
||
int n = files.Count;
|
||
int cols = (int)Math.Ceiling(Math.Sqrt(n));
|
||
int rows = (int)Math.Ceiling((double)n / cols);
|
||
int cell = 32, gap = 6, label = 30;
|
||
int side = cell * upscale;
|
||
int sheetW = side * cols + (cols + 1) * gap;
|
||
int sheetH = (side + label) * rows + (rows + 1) * gap;
|
||
var family = SystemFonts.Get("Consolas");
|
||
var font = family.CreateFont(10f, FontStyle.Regular);
|
||
|
||
using var sheet = new Image<Rgba32>(sheetW, sheetH, new Rgba32(40, 40, 40));
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
using var t = Image.Load<Rgba32>(files[i]);
|
||
t.Mutate(x => x.Resize(side, side, KnownResamplers.NearestNeighbor));
|
||
int cx = (i % cols) * (side + gap) + gap;
|
||
int cy = (i / cols) * (side + label + gap) + gap;
|
||
sheet.Mutate(x => x.DrawImage(t, new Point(cx, cy), 1f));
|
||
|
||
var st = stats[i];
|
||
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
|
||
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
|
||
bool ok = st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold;
|
||
var col = ok ? Color.LightGreen : Color.Orange;
|
||
string idx = Path.GetFileNameWithoutExtension(files[i]);
|
||
sheet.Mutate(x => x.DrawText(
|
||
$"{idx} b:{st.BorderEdges} op:{st.OpaquePct}%",
|
||
font, col, new PointF(cx + 4, cy + side + 2)));
|
||
sheet.Mutate(x => x.DrawText(
|
||
$"sh t/b/l/r: {st.TopDiff,3} {st.BotDiff,3} {st.LefDiff,3} {st.RigDiff,3}",
|
||
font, col, new PointF(cx + 4, cy + side + 14)));
|
||
}
|
||
sheet.Save(outPath);
|
||
}
|
||
|
||
private static void PrintHelp()
|
||
{
|
||
Console.WriteLine("Usage:");
|
||
Console.WriteLine(" tile-analyze --dir <path> [--sheet <out.png>] [--shadow-threshold N] [--upscale N]");
|
||
Console.WriteLine(" tile-analyze --files <a.png> <b.png> ... [--sheet <out.png>]");
|
||
Console.WriteLine();
|
||
Console.WriteLine("Reports border edges, opaque %, and shadow gradients per tile,");
|
||
Console.WriteLine("optionally writing a labeled contact sheet for visual review.");
|
||
}
|
||
}
|