param( [Parameter(Mandatory=$true)] [string]$Source, [string]$OutputDir = "03_Assets\Parts\Images", [int]$Width = 520, [int]$Height = 900 ) $ErrorActionPreference = "Stop" Add-Type -AssemblyName System.Drawing $code = @" using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.IO; public static class HarukaRigBuilder { static readonly string[] PartNames = new string[] { "head", "neck", "chest", "pelvis", "upperarm_r", "forearm_r", "hand_r", "upperarm_l", "forearm_l", "hand_l", "thigh_r", "shin_r", "foot_r", "thigh_l", "shin_l", "foot_l" }; public static void Build(string sourcePath, string outputDir, int width, int height) { Directory.CreateDirectory(outputDir); using (var src = new Bitmap(sourcePath)) using (var keyed = RemoveGreenKey(src)) { Rectangle bbox = FindAlphaBounds(keyed, 8); using (var master = NormalizeToCanvas(keyed, bbox, width, height)) { RemoveTinyComponents(master, 24); string masterPath = Path.Combine(outputDir, "haruka_part_master_apose.png"); SavePng(master, masterPath); SliceParts(master, outputDir); Validate(outputDir, master, width, height); } } } static Bitmap RemoveGreenKey(Bitmap src) { var dst = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb); for (int y = 0; y < src.Height; y++) { for (int x = 0; x < src.Width; x++) { Color c = src.GetPixel(x, y); int r = c.R, g = c.G, b = c.B; int maxRB = Math.Max(r, b); int delta = g - maxRB; int alpha = 255; if (g > 90 && delta > 35) { if (delta >= 120) alpha = 0; else alpha = Clamp((120 - delta) * 255 / 85, 0, 255); if (alpha < 255) { int despill = Math.Max(r, b); g = Math.Min(g, despill + 8); } } if (alpha <= 2) { dst.SetPixel(x, y, Color.FromArgb(0, 0, 0, 0)); } else { dst.SetPixel(x, y, Color.FromArgb(alpha, r, g, b)); } } } return dst; } static Rectangle FindAlphaBounds(Bitmap bmp, int threshold) { int minX = bmp.Width, minY = bmp.Height, maxX = -1, maxY = -1; for (int y = 0; y < bmp.Height; y++) { for (int x = 0; x < bmp.Width; x++) { if (bmp.GetPixel(x, y).A > threshold) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX < minX || maxY < minY) return Rectangle.Empty; return Rectangle.FromLTRB(minX, minY, maxX + 1, maxY + 1); } static Bitmap NormalizeToCanvas(Bitmap src, Rectangle bbox, int width, int height) { var dst = new Bitmap(width, height, PixelFormat.Format32bppArgb); using (var g = Graphics.FromImage(dst)) { g.Clear(Color.Transparent); g.CompositingMode = CompositingMode.SourceOver; g.CompositingQuality = CompositingQuality.HighQuality; g.InterpolationMode = InterpolationMode.HighQualityBicubic; g.SmoothingMode = SmoothingMode.HighQuality; g.PixelOffsetMode = PixelOffsetMode.HighQuality; float maxW = width - 40f; float maxH = height - 40f; float scale = Math.Min(maxW / bbox.Width, maxH / bbox.Height); int drawW = (int)Math.Round(bbox.Width * scale); int drawH = (int)Math.Round(bbox.Height * scale); int drawX = (width - drawW) / 2; int drawY = 20; if (drawY + drawH > height - 20) drawY = (height - drawH) / 2; g.DrawImage(src, new Rectangle(drawX, drawY, drawW, drawH), bbox, GraphicsUnit.Pixel); } HardClearGreen(dst); DespillGreenBias(dst); return dst; } static void HardClearGreen(Bitmap bmp) { for (int y = 0; y < bmp.Height; y++) { for (int x = 0; x < bmp.Width; x++) { Color c = bmp.GetPixel(x, y); if (c.A == 0) continue; int maxRB = Math.Max(c.R, c.B); if (c.G > 70 && c.G - maxRB > 30) { int a = c.G - maxRB > 80 ? 0 : Math.Max(0, c.A - 140); int g = Math.Min(c.G, maxRB + 5); bmp.SetPixel(x, y, a == 0 ? Color.FromArgb(0, 0, 0, 0) : Color.FromArgb(a, c.R, g, c.B)); } } } } static void DespillGreenBias(Bitmap bmp) { for (int y = 0; y < bmp.Height; y++) { for (int x = 0; x < bmp.Width; x++) { Color c = bmp.GetPixel(x, y); if (c.A == 0) continue; if (c.G > c.R + 6 && c.G > c.B + 10) { int g = Math.Min(c.G, Math.Max(c.R, c.B) + 4); int a = c.A; if (c.A < 235 && c.G > Math.Max(c.R, c.B) + 18) a = Math.Max(0, c.A - 35); bmp.SetPixel(x, y, Color.FromArgb(a, c.R, g, c.B)); } } } } static void SliceParts(Bitmap master, string outputDir) { Rectangle bb = FindAlphaBounds(master, 8); var parts = new Dictionary(); foreach (string name in PartNames) parts[name] = new Bitmap(master.Width, master.Height, PixelFormat.Format32bppArgb); for (int y = 0; y < master.Height; y++) { for (int x = 0; x < master.Width; x++) { Color c = master.GetPixel(x, y); if (c.A == 0) continue; string part = ClassifyPixel(c, x, y, bb); parts[part].SetPixel(x, y, c); } } foreach (var kv in parts) { string path = Path.Combine(outputDir, "haruka_part_" + kv.Key + ".png"); SavePng(kv.Value, path); kv.Value.Dispose(); } } static void RemoveTinyComponents(Bitmap bmp, int minPixels) { int w = bmp.Width, h = bmp.Height; bool[] seen = new bool[w * h]; int[] stack = new int[w * h]; int[] comp = new int[w * h]; int[] dx = new int[] { 1, -1, 0, 0 }; int[] dy = new int[] { 0, 0, 1, -1 }; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int start = y * w + x; if (seen[start] || bmp.GetPixel(x, y).A == 0) continue; int top = 0, count = 0; stack[top++] = start; seen[start] = true; while (top > 0) { int id = stack[--top]; comp[count++] = id; int cx = id % w; int cy = id / w; for (int i = 0; i < 4; i++) { int nx = cx + dx[i], ny = cy + dy[i]; if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; int nid = ny * w + nx; if (seen[nid]) continue; seen[nid] = true; if (bmp.GetPixel(nx, ny).A > 0) stack[top++] = nid; } } if (count < minPixels) { for (int i = 0; i < count; i++) { int id = comp[i]; bmp.SetPixel(id % w, id / w, Color.FromArgb(0, 0, 0, 0)); } } } } } static string ClassifyPixel(Color c, int x, int y, Rectangle bb) { double nx = (x - bb.Left) / (double)Math.Max(1, bb.Width); double ny = (y - bb.Top) / (double)Math.Max(1, bb.Height); double cx = bb.Left + bb.Width * 0.5; bool screenLeft = x < cx; double dx = Math.Abs((x - cx) / Math.Max(1.0, bb.Width)); bool hair = IsHair(c); bool skin = IsSkin(c); if (hair && ny < 0.54) return "head"; if (ny < 0.255) return "head"; if (hair && dx > 0.18 && ny < 0.58) return "head"; if (skin && dx < 0.09 && ny >= 0.235 && ny < 0.335) return "neck"; bool sideArmZone = dx > 0.18 && ny >= 0.28 && ny < 0.65; if (sideArmZone) { string side = screenLeft ? "_r" : "_l"; bool farHand = (screenLeft && nx < 0.25 && ny >= 0.455) || (!screenLeft && nx > 0.75 && ny >= 0.455); if (skin && farHand) return "hand" + side; if (ny < 0.435) return "upperarm" + side; if (ny < 0.595) return "forearm" + side; return "hand" + side; } if (ny < 0.33) { if (dx < 0.085) return "neck"; return "head"; } if (ny < 0.505) { if (dx > 0.15) { string side = screenLeft ? "_r" : "_l"; return ny < 0.43 ? "upperarm" + side : "forearm" + side; } return "chest"; } if (ny < 0.625) { if (dx > 0.24 && skin) { string side = screenLeft ? "_r" : "_l"; return "hand" + side; } return "pelvis"; } if (ny < 0.755) return screenLeft ? "thigh_r" : "thigh_l"; if (ny < 0.895) return screenLeft ? "shin_r" : "shin_l"; return screenLeft ? "foot_r" : "foot_l"; } static bool IsHair(Color c) { return c.R > 95 && c.R > c.G + 22 && c.G > c.B - 8 && c.B < 155; } static bool IsSkin(Color c) { return c.R > 190 && c.G > 125 && c.B > 95 && c.R >= c.G && c.G >= c.B - 18; } static void Validate(string outputDir, Bitmap master, int width, int height) { string reportPath = Path.Combine(outputDir, "_validation.txt"); using (var sw = new StreamWriter(reportPath, false)) { sw.WriteLine("Haruka rig part build validation"); sw.WriteLine("Canvas: " + width + "x" + height); sw.WriteLine("Master: haruka_part_master_apose.png"); foreach (string name in PartNames) { string path = Path.Combine(outputDir, "haruka_part_" + name + ".png"); using (var img = new Bitmap(path)) { Rectangle b = FindAlphaBounds(img, 8); sw.WriteLine(Path.GetFileName(path) + " | " + img.Width + "x" + img.Height + " | " + img.PixelFormat + " | bbox=" + RectText(b)); } } int missing, extra, differing, multiCovered; DirectCoverageCheck(outputDir, master, out missing, out extra, out differing, out multiCovered); sw.WriteLine("Direct coverage check | missing=" + missing + " | extra=" + extra + " | differing=" + differing + " | multi_covered=" + multiCovered); } } static void DirectCoverageCheck(string outputDir, Bitmap master, out int missing, out int extra, out int differing, out int multiCovered) { missing = 0; extra = 0; differing = 0; multiCovered = 0; var loaded = new List(); try { foreach (string name in PartNames) loaded.Add(new Bitmap(Path.Combine(outputDir, "haruka_part_" + name + ".png"))); for (int y = 0; y < master.Height; y++) { for (int x = 0; x < master.Width; x++) { Color m = master.GetPixel(x, y); int hits = 0; Color last = Color.Transparent; foreach (var part in loaded) { Color c = part.GetPixel(x, y); if (c.A > 0) { hits++; last = c; } } if (m.A > 0 && hits == 0) missing++; if (m.A == 0 && hits > 0) extra++; if (hits > 1) multiCovered++; if (m.A > 0 && hits > 0) { int d = Math.Abs(m.A - last.A) + Math.Abs(m.R - last.R) + Math.Abs(m.G - last.G) + Math.Abs(m.B - last.B); if (d != 0) differing++; } } } } finally { foreach (var bmp in loaded) bmp.Dispose(); } } static string RectText(Rectangle r) { return r.Left + "," + r.Top + "," + r.Width + "," + r.Height; } static void SavePng(Bitmap bmp, string path) { bmp.Save(path, ImageFormat.Png); } static int Clamp(int v, int min, int max) { return v < min ? min : (v > max ? max : v); } } "@ Add-Type -TypeDefinition $code -ReferencedAssemblies "System.Drawing" $resolvedSource = (Resolve-Path -LiteralPath $Source).Path $resolvedOut = if (Test-Path -LiteralPath $OutputDir) { (Resolve-Path -LiteralPath $OutputDir).Path } else { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null (Resolve-Path -LiteralPath $OutputDir).Path } [HarukaRigBuilder]::Build($resolvedSource, $resolvedOut, $Width, $Height) Write-Output "Generated Haruka rig parts in $resolvedOut"