using SixLabors.Fonts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Theriapolis.Tools.Commands; /// /// 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 Content/Gfx/tactical/. /// /// What it checks: /// /// • Border edges (0–4): 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. /// /// • Opaque %: 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%. /// /// • Shadow scores (top/bot/lef/rig): brightness drop on each edge /// versus the interior average. Positive = edge is darker than interior. /// The pseudo-3D shading that tile_view: "low top-down" 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 theriapolis-tile-generation-handoff.md for the full Pixellab /// MCP workflow this tool slots into. /// public static class TileAnalyze { public static int Run(string[] args) { string? dir = null; string? sheetOut = null; var explicitFiles = new List(); 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 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(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(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 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); } /// /// "Border" pixel: alpha ≥ 128 AND max channel ≤ 95 AND (max - min) ≤ 25. /// Catches the dark-uniform frames Pixellab bakes around tiles. /// 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; } /// Number of edges (0–4) where ≥80% of perimeter pixels are border-pixels. public static int CountBorderEdges(Image 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; } /// Percent (0–100) of pixels with alpha ≥ 128. public static int CountOpaqueFraction(Image 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); } /// /// 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. /// public static (int interior, int topDiff, int botDiff, int lefDiff, int rigDiff) ShadowScores(Image 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 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 files, List 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(sheetW, sheetH, new Rgba32(40, 40, 40)); for (int i = 0; i < n; i++) { using var t = Image.Load(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 [--sheet ] [--shadow-threshold N] [--upscale N]"); Console.WriteLine(" tile-analyze --files ... [--sheet ]"); Console.WriteLine(); Console.WriteLine("Reports border edges, opaque %, and shadow gradients per tile,"); Console.WriteLine("optionally writing a labeled contact sheet for visual review."); } }