Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
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>
2026-04-30 20:40:51 -07:00

289 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &lt;path&gt; [--sheet &lt;out.png&gt;]
/// tile-analyze --files &lt;f1.png&gt; &lt;f2.png&gt; ... [--sheet &lt;out.png&gt;]
///
/// 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 (04)</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 3070%.
///
/// • <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 &gt; 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 &gt; 0, opaque &lt; 95%, or shadow &gt; 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 (04) 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 (0100) 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.");
}
}