Files
Dansori_Characters/Haruka_Profile/Build-HarukaRigParts.ps1
T
2026-07-04 10:34:46 +09:00

419 lines
14 KiB
PowerShell

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<string, Bitmap>();
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<Bitmap>();
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"