From 68cb8c4a7e4e90b79fe3b928bbeed85f456a308b Mon Sep 17 00:00:00 2001 From: "Anthony C." Date: Thu, 16 Jul 2026 18:34:20 +0200 Subject: [PATCH 1/3] Different textures can be considered identical if they are similar enough. - The command line argument "-m" can be used to set a tolerance threshold above which 2 textures are considered identical (threshold between 0.0 and 100.0). NOTE: The full length argument "--texture-merge-threshold" does not work for now. Probably because of an issue in the argument parsin system. - Textures reuse also works for triangle polygons (without breaking the vertex2 = vertex3 rule). - Whether texture are identical is detected even if they are mirrored vertically, horizontally, or both (also works for triangle polygons). - Texture comparison is based on the shapes traced by the UVs (and no longer on the UV coordinates themselves) and texture content within the shape. Texture content is compared with a custom algorithm. - Some old commented code contains 2 other texture content comparison methods that were rejected. --- Plugins/Nya/Mesh.cs | 345 ++++++++++++++++++++++++++++-------- Plugins/Nya/NyaArguments.cs | 9 + Plugins/Nya/Texture.cs | 345 ++++++++++++++++++++++++++++++++++++ 3 files changed, 622 insertions(+), 77 deletions(-) diff --git a/Plugins/Nya/Mesh.cs b/Plugins/Nya/Mesh.cs index c89de00..b706f40 100644 --- a/Plugins/Nya/Mesh.cs +++ b/Plugins/Nya/Mesh.cs @@ -78,7 +78,7 @@ private static (FaceFlags, Polygon) ConvertFace( faceFlag.NoLight = face.NoLight | settings.ModelType == NyaArguments.ModelTypes.NoLight; // Read polygon - Polygon polygon = Mesh.ConvertPolygon(face, faceFlag, group, modelTextures, !settings.NoUV, ref vertices, ref uvTextures); + Polygon polygon = Mesh.ConvertPolygon(face, faceFlag, group, modelTextures, !settings.NoUV, settings.TextureMergeThreshold, ref vertices, ref uvTextures); return (faceFlag, polygon); } @@ -91,6 +91,8 @@ private static (FaceFlags, Polygon) ConvertFace( /// Model object group /// Textures from model file textures /// Unwrap model textures by UV + /// Texture similarity threshold percentage (0.0 to 100.0) + /// above which 2 textures will be considered identical /// Model vertices /// Embed model vertices private static Polygon ConvertPolygon( @@ -99,6 +101,7 @@ private static Polygon ConvertPolygon( Group group, List modelTextures, bool unwrapTextures, + double textureMergeThreshold, ref List vertices, ref List uvTextures) { @@ -143,6 +146,10 @@ private static Polygon ConvertPolygon( if (texture != null) { + List finalUvs = new List(face.Uv); + List finalNormals = new List(face.Normals); + List finalVertices = new List(face.Vertices); + // Canonicalize quad UV ordering so GetUnwrap sees // [TL, TR, BR, BL] every time. Mirrored faces arrive here // with the same 4 UV points but traced in the opposite @@ -152,63 +159,50 @@ private static Polygon ConvertPolygon( // rotated 90° relative to the other side. // Padded triangles keep uv[2]==uv[3] and must not be // reordered by this pass. - if (wasQuad) - { - // Find corner closest to UV top-left (minU, maxV in V-up space). - double minU = face.Uv.Select(i => group.Uv[i].X).Min(); - double maxV = face.Uv.Select(i => group.Uv[i].Y).Max(); - - int topLeft = 0; - double bestDistSq = double.MaxValue; + List rawUvs = face.Uv.Select(i => group.Uv[i]).ToList(); + var canonicalizationResult = CanonicalizeFace(rawUvs, wasQuad); - for (int i = 0; i < 4; i++) - { - Vector3D c = group.Uv[face.Uv[i]]; - double du = c.X - minU; - double dv = maxV - c.Y; - double d = (du * du) + (dv * dv); - - if (d < bestDistSq) - { - bestDistSq = d; - topLeft = i; - } - } + // Reorder everything according to the canonical indices + finalUvs.Clear(); + finalNormals.Clear(); + finalVertices.Clear(); - // Cyclic shift so topLeft lands at index 0. - List rotatedUvs = new List(4); - List rotatedNormals = new List(4); - List rotatedVertices = new List(4); + for (int i = 0; i < 4; i++) + { + int originalIndex = canonicalizationResult.NewToOldIndices[i]; + finalUvs.Add(face.Uv[originalIndex]); + finalNormals.Add(face.Normals[originalIndex]); + finalVertices.Add(face.Vertices[originalIndex]); + } - for (int i = 0; i < 4; i++) - { - rotatedUvs.Add(face.Uv[(i + topLeft) % 4]); - rotatedNormals.Add(face.Normals[(i + topLeft) % 4]); - rotatedVertices.Add(face.Vertices[(i + topLeft) % 4]); - } + // Generate texture + TextureResult result = Mesh.GetUvMappedTexture(texture, finalUvs, group.Uv, wasQuad, textureMergeThreshold, ref uvTextures); + faceFlag.TextureId = result.TextureId; - // Check UV winding. For a CW quad [TL, TR, BR, BL] in - // V-up UV space, (uv[1]-uv[0]) × (uv[3]-uv[0]) has - // negative Z. Positive Z means CCW — swap indices 1↔3 - // to convert [TL, BL, BR, TR] → [TL, TR, BR, BL]. - Vector3D e01 = group.Uv[rotatedUvs[1]] - group.Uv[rotatedUvs[0]]; - Vector3D e03 = group.Uv[rotatedUvs[3]] - group.Uv[rotatedUvs[0]]; - double signedArea = (e01.X * e03.Y) - (e01.Y * e03.X); + // 1. Reorder vertices depending on how the texture matched + if (result.VertexPermutation != null) + { + List newUvs = new List(4); + List newNormals = new List(4); + List newVertices = new List(4); - if (signedArea > 0.0) + for (int i = 0; i < 4; i++) { - (rotatedUvs[1], rotatedUvs[3]) = (rotatedUvs[3], rotatedUvs[1]); - (rotatedNormals[1], rotatedNormals[3]) = (rotatedNormals[3], rotatedNormals[1]); - (rotatedVertices[1], rotatedVertices[3]) = (rotatedVertices[3], rotatedVertices[1]); + int srcIdx = result.VertexPermutation[i]; + newUvs.Add(finalUvs[srcIdx]); + newNormals.Add(finalNormals[srcIdx]); + newVertices.Add(finalVertices[srcIdx]); } - face.Uv = rotatedUvs; - face.Normals = rotatedNormals; - face.Vertices = rotatedVertices; + finalUvs = newUvs; + finalNormals = newNormals; + finalVertices = newVertices; } - // Generate texture - faceFlag.TextureId = Mesh.GetUvMappedTexture(texture, face.Uv, group.Uv, ref uvTextures); + // Réinjection + face.Uv = finalUvs; + face.Normals = finalNormals; + face.Vertices = finalVertices; } else { @@ -284,54 +278,251 @@ private static Polygon ConvertPolygon( return polygon; } + [Flags] + public enum UvTransform + { + None = 0, + HorizontalFlip = 1, + VerticalFlip = 2, + Both = HorizontalFlip | VerticalFlip + } + + public class TextureResult + { + public int TextureId { get; set; } + public int[]? VertexPermutation { get; set; } = null; + } + /// /// Get UV mapped texture from base texture /// /// Base texture /// UV coord indicies for quad /// All UV coords + /// Texture similarity threshold percentage (0.0 to 100.0) + /// above which 2 textures will be considered identical /// UV texture atlas /// Number of already existing or new texture - private static int GetUvMappedTexture(Texture baseTexture, List uv, List uvCoords, ref List uvTextures) + private static TextureResult GetUvMappedTexture( + Texture baseTexture, + List uv, + List uvCoords, + bool wasQuad, + double textureMergeThreshold, + ref List uvTextures) { - // Check if texture mapped to this region exists already (with a tolerance to a half pixel difference) - double uEpsilon = 0.5 / baseTexture.Width; - double vEpsilon = 0.5 / baseTexture.Height; - var createdFromBase = uvTextures.Select((texture, index) => new KeyValuePair(index, texture)).Where(texture => texture.Value.GetBaseName() == baseTexture.Name).ToList(); - var existing = createdFromBase - .Where(texture => texture.Value.UV.Select((id, i) => + List currentFaceUvs = uv.Select(coord => uvCoords[coord]).ToList(); + + // In order to detect which textures are the same we first check whether their shapes are similar (including mirror versions). + // Similar is defined as a % of their length/width + // (fall back to half a pixel tolerance to insure pixel perfect behavior in case of high merge threshold) + double minU = currentFaceUvs.Min(p => p.X); + double maxU = currentFaceUvs.Max(p => p.X); + double minV = currentFaceUvs.Min(p => p.Y); + double maxV = currentFaceUvs.Max(p => p.Y); + double faceWidth = maxU - minU; + double faceHeight = maxV - minV; + double toleranceFactor = (100.0 - textureMergeThreshold) / 100.0; + double uEpsilon = Math.Max(faceWidth * toleranceFactor, 0.5 / baseTexture.Width); + double vEpsilon = Math.Max(faceHeight * toleranceFactor, 0.5 / baseTexture.Height); + + int bestTextureId = -1; + double bestSimilarityScore = -1.0; + int[]? bestPermutation = null; + for (int i = 0; i < uvTextures.Count; i++) + { + Texture existingTexture = uvTextures[i]; + if (existingTexture.GetBaseName() != baseTexture.Name) continue; + + List existingUvs = existingTexture.UV.Select(id => uvCoords[id]).ToList(); + if (IsUvSameShape(existingUvs, currentFaceUvs, wasQuad, uEpsilon, vEpsilon, + out UvTransform detectedTransform, + out int[]? currentToCanonicalVertexOrder) && currentToCanonicalVertexOrder is not null) { - Vector3D currentUv = uvCoords[id]; - Vector3D targetUv = uvCoords[uv[i]]; - bool matchHorizontal = Math.Abs(currentUv.X - targetUv.X) < uEpsilon; - bool matchVertical = Math.Abs(currentUv.Y - targetUv.Y) < vEpsilon; - - return matchHorizontal && matchVertical; - }).All(val => val)) - .DefaultIfEmpty(new KeyValuePair(-1, baseTexture)) - .First().Key; - - // If not, generate new texture - if (existing < 0) + //The shapes are similar, now we reorder to vertices so the content of the textures can be compared + List reorderedCurrentUvs = new List(4); + for (int j = 0; j < 4; j++) + reorderedCurrentUvs.Add(currentFaceUvs[currentToCanonicalVertexOrder[j]]); + Texture currentUnwrap = Texture.GetUnwrap(baseTexture, reorderedCurrentUvs); + //And we compare the content of the textures + double currentScore = currentUnwrap.CalculateSimilarityTo(existingTexture); + if (currentScore >= textureMergeThreshold && currentScore > bestSimilarityScore) + { + bestSimilarityScore = currentScore; + bestTextureId = i; + bestPermutation = currentToCanonicalVertexOrder; + + if (bestSimilarityScore >= 100.0) break; + } + } + } + + if (bestTextureId >= 0) + { + return new TextureResult + { TextureId = bestTextureId, VertexPermutation = bestPermutation }; + } + + // No match with existing texture, we extract a new one + Texture newUnwrap = Texture.GetUnwrap(baseTexture, currentFaceUvs); + newUnwrap.UV = uv.ToArray(); + + int newId = uvTextures.Count; + uvTextures.Add(newUnwrap); + + return new TextureResult { TextureId = newId }; + } + + /// + /// Determines whether two sets of UV coordinates share the same geometric shape within a specified tolerance, + /// checking across multiple orientation configurations (default orientation, horizontal flip, vertical flip and both). + /// The reference list of UV coordinates to compare against. + /// The list of UV coordinates being evaluated for a potential match. + /// The maximum allowed absolute difference along the U (X) axis. + /// The maximum allowed absolute difference along the V (Y) axis. + /// When this method returns, contains the applied to achieve the match; otherwise, UvTransform.None. + /// When this method returns, contains an array mapping the current vertices to their canonical sequence if a match is found; otherwise, null. + /// true if matches the shape of under any tested transformation; otherwise, false. + private static bool IsUvSameShape( + List existingUvs, + List testedUvs, + bool wasQuad, + double uEpsilon, + double vEpsilon, + out UvTransform transform, + out int[]? currentToCanonicalVertexOrder) + { + transform = UvTransform.None; + currentToCanonicalVertexOrder = null; + + if (existingUvs.Count != testedUvs.Count) return false; + + Vector3D originExistingUvs = existingUvs[0]; + List centeredExisting = existingUvs.Select(p => + new Vector3D(p.X - originExistingUvs.X, p.Y - originExistingUvs.Y, p.Z)).ToList(); + + var configs = new[] { - List coords = uv.Select(coord => uvCoords[coord]).ToList(); - Texture unwrap = Texture.GetUnwrap(baseTexture, coords); - unwrap.UV = uv.ToArray(); - existing = uvTextures.Count; + (Transform: UvTransform.None, MirrorFunc: (Func)(p => p)), + (Transform: UvTransform.HorizontalFlip, MirrorFunc: (p => new Vector3D(-p.X, p.Y, p.Z))), + (Transform: UvTransform.VerticalFlip, MirrorFunc: (p => new Vector3D(p.X, -p.Y, p.Z))), + (Transform: UvTransform.Both, MirrorFunc: (p => new Vector3D(-p.X, -p.Y, p.Z))) + }; + + foreach (var cfg in configs) + { + List mirroredTestedUvs = testedUvs.Select(cfg.MirrorFunc).ToList(); + + var canonicalizationResult = CanonicalizeFace(mirroredTestedUvs, wasQuad); + List canonicalizedTestedUvs = canonicalizationResult.OrderedCoords; - var found = createdFromBase.FindIndex(pair => pair.Value.Hash == unwrap.Hash); + Vector3D originCanonicalizedTestedUvs = canonicalizedTestedUvs[0]; + List centeredCanonicalizedTestedUvs = canonicalizedTestedUvs.Select(p => + new Vector3D(p.X - originCanonicalizedTestedUvs.X, p.Y - originCanonicalizedTestedUvs.Y, p.Z)).ToList(); - if (found < 0) + bool match = true; + for (int i = 0; i < centeredExisting.Count; i++) { - uvTextures.Add(unwrap); + if (Math.Abs(centeredExisting[i].X - centeredCanonicalizedTestedUvs[i].X) > uEpsilon || + Math.Abs(centeredExisting[i].Y - centeredCanonicalizedTestedUvs[i].Y) > vEpsilon) + { + match = false; + break; + } } - else + + if (match) + { + transform = cfg.Transform; + currentToCanonicalVertexOrder = canonicalizationResult.NewToOldIndices; + return true; + } + } + + return false; + } + + + public record CanonicalizationResult(List OrderedCoords, int[] NewToOldIndices); + + /// + /// Canonicalizes a polygon face by enforcing a consistent vertex order. + /// + /// The initial list of 3D vector coordinates representing the face vertices. + /// + /// A tuple containing: + /// + /// orderedCoords: The newly ordered and normalized list of coordinates. + /// originalIndices: An array mapping each new position back to its original index in . + /// + /// + private static CanonicalizationResult CanonicalizeFace(List rawCoords, bool wasQuad) + { + if (rawCoords.Count != 4) + { + int[] identity = Enumerable.Range(0, rawCoords.Count).ToArray(); + return new CanonicalizationResult(new List(rawCoords), identity); + } + + int vertexCount = wasQuad? 4 : 3; + + // Find corner closest to UV top-left (minU, maxV in V-up space). + double minU = rawCoords.Min(p => p.X); + double maxV = rawCoords.Max(p => p.Y); + + // Cyclic shift so topLeft lands at index 0. + int topLeft = 0; + double bestDistSq = double.MaxValue; + + for (int i = 0; i < vertexCount; i++) + { + double du = rawCoords[i].X - minU; + double dv = maxV - rawCoords[i].Y; + double d = du * du + dv * dv; + + if (d < bestDistSq) { - return createdFromBase[found].Key; + bestDistSq = d; + topLeft = i; + } + } + + // Créer l'ordre cyclique + int[] indices = new int[4]; + for (int i = 0; i < vertexCount; i++) + { + indices[i] = (topLeft + i) % vertexCount; + } + + List ordered = new List(4); + for (int i = 0; i < vertexCount; i++) + ordered.Add(rawCoords[indices[i]]); + + if(!wasQuad) + { + indices[3] = indices[2]; + ordered.Add(ordered.Last()); + } + + // Check UV winding. For a CW quad [TL, TR, BR, BL] in + // V-up UV space, (uv[1]-uv[0]) × (uv[3]-uv[0]) has + // negative Z. Positive Z means CCW — swap indices 1↔3 + // to convert [TL, BL, BR, TR] → [TL, TR, BR, BL]. + Vector3D e01 = ordered[1] - ordered[0]; + Vector3D e03 = ordered[3] - ordered[0]; + double signedArea = (e01.X * e03.Y) - (e01.Y * e03.X); + + if (signedArea > 0.0) // CCW → swap 1 et 3 + { + (indices[1], indices[3]) = (indices[3], indices[1]); + (ordered[1], ordered[3]) = (ordered[3], ordered[1]); + if(!wasQuad) { + indices[2] = indices[3]; + ordered[2] = ordered[3]; } } - return existing; + return new CanonicalizationResult(ordered, indices); } /// diff --git a/Plugins/Nya/NyaArguments.cs b/Plugins/Nya/NyaArguments.cs index fb3f89b..7ed6ac7 100644 --- a/Plugins/Nya/NyaArguments.cs +++ b/Plugins/Nya/NyaArguments.cs @@ -44,5 +44,14 @@ public enum ModelTypes [CmdHelp("Makes exporter NOT generate new textures based on the UV map.")] [CmdArgument("no-unwrap", "w")] public bool NoUV { get; set; } + + /// + /// Gets or sets the texture similarity threshold percentage (0-100) above which 2 textures will be considered identical + /// as to reuse one texture in place of the other and therefore save space in memory. + /// + [CmdHelp("Texture similarity threshold percentage (0.0 to 100.0) above which 2 textures will be considered identical as to reuse one in place of the other and therefore save space in memory.\nDefault value is 100 (pixel perfect match).")] + [CmdArgument("texture-merge-threshold", "m")] + public double TextureMergeThreshold { get; set; } = 100.0; + } } \ No newline at end of file diff --git a/Plugins/Nya/Texture.cs b/Plugins/Nya/Texture.cs index 2ad5bb4..b8c6c9e 100644 --- a/Plugins/Nya/Texture.cs +++ b/Plugins/Nya/Texture.cs @@ -5,12 +5,17 @@ using ModelConverter.Graphics; using Nya.Serializer; using SLIS = SixLabors.ImageSharp; + // using ImageHash = CoenM.ImageHash; //Tried to compare textures with this, but my own method turned out to be better /// /// Catgirl texture /// public class Texture { + // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.PerceptualHash(); + // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.AverageHash(); + // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.DifferenceHash(); + /// /// Initializes a new instance of the class /// @@ -220,5 +225,345 @@ public string GetBaseName() return this.Name; } + + /// + /// Compares this texture with another one. + /// The similarity score is based on color similarity (via CIEDE2000) and gradient (average detail/edge strength) + /// between the 2 images and between sub parts of both images. + /// + /// The texture to compare to. + /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same). + public double CalculateSimilarityTo(Texture other) + { + if (other == null) return 0.0; + if (ReferenceEquals(this, other)) return 100.0; + + return CalculateRecursiveSimilarity(this, other, + 0, 0, this.Width, this.Height, + 0, 0, other.Width, other.Height); + } + + /// + /// Recursive similarity calculation using sliding windows. + /// + private static double CalculateRecursiveSimilarity( + Texture img1, Texture img2, + int x1, int y1, int w1, int h1, // région courante sur img1 + int x2, int y2, int w2, int h2) // région courante sur img2 + { + if (w1 * h1 < 4 || w2 * h2 < 4) + { + var avg1 = GetAverageColor(img1, x1, y1, w1, h1); + var avg2 = GetAverageColor(img2, x2, y2, w2, h2); + return ColorSimilarity(avg1, avg2); + } + + // Mean color similarity + var avgFullImg1 = GetAverageColor(img1, x1, y1, w1, h1); + var avgFullImg2 = GetAverageColor(img2, x2, y2, w2, h2); + double colorSimilarity = ColorSimilarity(avgFullImg1, avgFullImg2); + + // Structure similarity via mean gradient + double grad1 = GetMeanGradient(img1, x1, y1, w1, h1); + double grad2 = GetMeanGradient(img2, x2, y2, w2, h2); + double gradientSimilarity = GradientSimilarity(grad1, grad2); + + // The deeper we go, the less relevant is structure similarity + double depthWeight = (w1 / img1.Width)/2; + double fullImgSimilarity = colorSimilarity * (1-depthWeight) + gradientSimilarity * (depthWeight); + + int midW1 = (w1 + 1) / 2; + int midH1 = (h1 + 1) / 2; + int midW2 = (w2 + 1) / 2; + int midH2 = (h2 + 1) / 2; + + // We do the same for each quadrant of the img + double tl = CalculateRecursiveSimilarity(img1, img2, x1, y1, midW1, midH1, x2, y2, midW2, midH2); + double tr = CalculateRecursiveSimilarity(img1, img2, x1 + midW1, y1, w1 - midW1, midH1, x2 + midW2, y2, w2 - midW2, midH2); + double bl = CalculateRecursiveSimilarity(img1, img2, x1, y1 + midH1, midW1, h1 - midH1, x2, y2 + midH2, midW2, h2 - midH2); + double br = CalculateRecursiveSimilarity(img1, img2, x1 + midW1, y1 + midH1, w1 - midW1, h1 - midH1, x2 + midW2, y2 + midH2, w2 - midW2, h2 - midH2); + + double subImgSimilarity = (tl + tr + bl + br) / 4.0; + + // Weight can be adjusted + return (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); + } + //ça a marché avec maxdeltaE=10, depthWeight = (w1 / img1.Width); et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); + //ça a marché avec avgColor=8 depthWeight = (w1 / img1.Width); et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); + //ça a marché avec avgColor=8 depthWeight = (w1 / img1.Width)/2; et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold) + //ça a marché avec maxDeltaE = 8 depthWeight = (w1 / img1.Width) et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold 69) + //ça a marché avec maxDeltaE = 8 depthWeight = (w1 / img1.Width)/2 et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold 68.125) + + /// + /// Computes average RGB color of a rectangular region in the texture. + /// + private static (byte R, byte G, byte B) GetAverageColor(Texture tex, int startX, int startY, int width, int height) + { + long sumR = 0, sumG = 0, sumB = 0; + int count = 0; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int px = startX + x; + int py = startY + y; + + if (px < tex.Width && py < tex.Height) + { + ushort pixel = tex.Data[py * tex.Width + px]; + byte r = (byte)((pixel & 0x1F) << 3); + byte g = (byte)(((pixel >> 5) & 0x1F) << 3); + byte b = (byte)(((pixel >> 10) & 0x1F) << 3); + + sumR += r; + sumG += g; + sumB += b; + count++; + } + } + } + + if (count == 0) return (0, 0, 0); + + return ( + (byte)(sumR / count), + (byte)(sumG / count), + (byte)(sumB / count) + ); + } + + /// + /// Similarity between two RGB colors (0 to 100). + /// Uses a simple RGB difference between the colors. + /// + private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte G, byte B) c2) + { + double simR = (255.0 - Math.Abs(c1.R - c2.R)) / 255.0; + double simG = (255.0 - Math.Abs(c1.G - c2.G)) / 255.0; + double simB = (255.0 - Math.Abs(c1.B - c2.B)) / 255.0; + + double avgSim = (simR + simG + simB) / 3.0 * 100.0; + const double maxDelta = 8; //Difference above which we consider 0% similarity + + return Math.Max(0, 100 * ((avgSim - (100 - maxDelta)) / maxDelta)); + } + + /// + /// Perceptual similarity between two RGB colors (0 to 100). + /// Uses RGB → LAB conversion + CIEDE2000 formula. + /// + // private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte G, byte B) c2) + // { + // if (c1.R == c2.R && c1.G == c2.G && c1.B == c2.B) + // return 100.0; + + // // Conversion RGB → XYZ → LAB + // var lab1 = RgbToLab(c1.R, c1.G, c1.B); + // var lab2 = RgbToLab(c2.R, c2.G, c2.B); + + // double deltaE = CieDE2000(lab1.L, lab1.a, lab1.b, lab2.L, lab2.a, lab2.b); + + // // Mapping Delta E to 0-100 score + // // Delta E < 1 = imperceptible + // // Delta E ~ 2-4 = very close + // // Delta E 10+ = clearly different colors + // const double maxDeltaE = 8.0; // Value beyond which we consider 0% similarity + + // double similarity = Math.Max(0.0, 100.0 * (1.0 - deltaE / maxDeltaE)); + // return similarity; + // } + + + private static (double L, double a, double b) RgbToLab(byte r, byte g, byte blue) + { + // 1. RGB → sRGB linear + double rr = r / 255.0; + double gg = g / 255.0; + double bb = blue / 255.0; + + rr = rr > 0.04045 ? Math.Pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92; + gg = gg > 0.04045 ? Math.Pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92; + bb = bb > 0.04045 ? Math.Pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92; + + // 2. sRGB → XYZ (D65) + double x = 0.4124 * rr + 0.3576 * gg + 0.1805 * bb; + double y = 0.2126 * rr + 0.7152 * gg + 0.0722 * bb; + double z = 0.0193 * rr + 0.1192 * gg + 0.9505 * bb; + + // 3. XYZ → LAB (D65) + x /= 0.95047; + y /= 1.00000; + z /= 1.08883; + + x = x > 0.008856 ? Math.Pow(x, 1.0 / 3.0) : (7.787 * x) + (16.0 / 116.0); + y = y > 0.008856 ? Math.Pow(y, 1.0 / 3.0) : (7.787 * y) + (16.0 / 116.0); + z = z > 0.008856 ? Math.Pow(z, 1.0 / 3.0) : (7.787 * z) + (16.0 / 116.0); + + double L = 116.0 * y - 16.0; + double aValue = 500.0 * (x - y); + double bValue = 200.0 * (y - z); + + return (L, aValue, bValue); + } + + private static double CieDE2000(double L1, double a1, double b1, double L2, double a2, double b2) + { + const double kL = 1.0, kC = 1.0, kH = 1.0; + + double dL = L2 - L1; + double C1 = Math.Sqrt(a1 * a1 + b1 * b1); + double C2 = Math.Sqrt(a2 * a2 + b2 * b2); + double dC = C2 - C1; + + double h1 = Math.Atan2(b1, a1); + double h2 = Math.Atan2(b2, a2); + double dh = h2 - h1; + + if (dh > Math.PI) dh -= 2 * Math.PI; + if (dh < -Math.PI) dh += 2 * Math.PI; + + double dH = 2 * Math.Sqrt(C1 * C2) * Math.Sin(dh / 2.0); + + double Lm = (L1 + L2) / 2.0; + double Cm = (C1 + C2) / 2.0; + double hm = (h1 + h2) / 2.0; + + double T = 1 - 0.17 * Math.Cos(hm - Math.PI / 6) + + 0.24 * Math.Cos(2 * hm) + + 0.32 * Math.Cos(3 * hm + Math.PI / 30) + - 0.20 * Math.Cos(4 * hm - 63 * Math.PI / 180); + + double SL = 1 + (0.015 * (Lm - 50) * (Lm - 50)) / Math.Sqrt(20 + (Lm - 50) * (Lm - 50)); + double SC = 1 + 0.045 * Cm; + double SH = 1 + 0.015 * Cm * T; + + double dTheta = 30 * Math.Exp(-((hm - 275 * Math.PI / 180) * (hm - 275 * Math.PI / 180)) / (25 * 25)); + double RC = 2 * Math.Sqrt(Math.Pow(Cm, 7) / (Math.Pow(Cm, 7) + Math.Pow(25, 7))); + double RT = -Math.Sin(2 * dTheta) * RC; + + double dL_ = dL / (kL * SL); + double dC_ = dC / (kC * SC); + double dH_ = dH / (kH * SH); + + return Math.Sqrt(dL_ * dL_ + dC_ * dC_ + dH_ * dH_ + RT * dC_ * dH_); + } + + /// + /// Simple similarity between two gradient values. + /// + private static double GradientSimilarity(double g1, double g2) + { + if (g1 == 0 && g2 == 0) return 100.0; + if (g1 == 0 || g2 == 0) return 0.0; + + double ratio = Math.Min(g1, g2) / Math.Max(g1, g2); + return ratio * 100.0; + } + + /// + /// Calculates the mean gradient (average detail/edge strength) of a region. + /// Higher value = more details/texture variation. + /// + private static double GetMeanGradient(Texture tex, int startX, int startY, int width, int height) + { + if (width < 2 || height < 2) return 0.0; + + long totalGradient = 0; + int count = 0; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int px = startX + x; + int py = startY + y; + + if (px >= tex.Width - 1 || py >= tex.Height - 1) + continue; + + ushort p1 = tex.Data[py * tex.Width + px]; // current pixel + ushort p2 = tex.Data[py * tex.Width + (px + 1)]; // right + ushort p3 = tex.Data[(py + 1) * tex.Width + px]; // below + + byte r1 = (byte)((p1 & 0x1F) << 3); + byte g1 = (byte)(((p1 >> 5) & 0x1F) << 3); + byte b1 = (byte)(((p1 >> 10) & 0x1F) << 3); + + byte r2 = (byte)((p2 & 0x1F) << 3); + byte g2 = (byte)(((p2 >> 5) & 0x1F) << 3); + byte b2 = (byte)(((p2 >> 10) & 0x1F) << 3); + + byte r3 = (byte)((p3 & 0x1F) << 3); + byte g3 = (byte)(((p3 >> 5) & 0x1F) << 3); + byte b3 = (byte)(((p3 >> 10) & 0x1F) << 3); + + // Simple luminance approximation + int lum1 = (r1 * 299 + g1 * 587 + b1 * 114) / 1000; + int lum2 = (r2 * 299 + g2 * 587 + b2 * 114) / 1000; + int lum3 = (r3 * 299 + g3 * 587 + b3 * 114) / 1000; + + totalGradient += Math.Abs(lum1 - lum2) + Math.Abs(lum1 - lum3); + count++; + } + } + + return count == 0 ? 0.0 : (double)totalGradient / count; + } + + /// + /// Compare this texture with another one using CoenM.ImageHash's algorithm. + /// + /// The texture to compare to. + /// A similarity score between 0 (completely different) and 100 (exactly the same). + // public double CalculateSimilarityTo(Texture other) + // { + // if (other == null) return 0.0; + // if (ReferenceEquals(this, other)) return 100.0; // 100% si même objet (correction : base 0-100) + + // ulong myHash = this.ImageHashValue; + // ulong otherHash = other.ImageHashValue; + + // return CoenM.ImageHash.CompareHash.Similarity(myHash, otherHash); + // } + + public SLIS.Image ToImageSharp() + { + // 2. Changement du type d'instanciation ici : Rgba32 + var image = new SLIS.Image(this.Width, this.Height); + + for (int y = 0; y < this.Height; y++) + { + for (int x = 0; x < this.Width; x++) + { + ushort abgr555 = this.Data[(y * this.Width) + x]; + + // Décodage natif Saturn ABGR555 -> RGBA 32 bits + byte a = (byte)(((abgr555 >> 15) & 0x01) * 255); + byte b = (byte)(((abgr555 >> 10) & 0x1F) << 3); + byte g = (byte)(((abgr555 >> 5) & 0x1F) << 3); + byte r = (byte)((abgr555 & 0x1F) << 3); + + // 3. Remplissage avec le constructeur Rgba32 standard + image[x, y] = new SLIS.PixelFormats.Rgba32(r, g, b, a); + } + } + + return image; + } + + // private ulong? _imageHashCache; + + // private ulong ImageHashValue + // { + // get + // { + // if (!_imageHashCache.HasValue) + // { + // _imageHashCache = _hasher.Hash(ToImageSharp()); + // } + // return _imageHashCache.Value; + // } + // } } } \ No newline at end of file From c14224ea828baa41f0fac3f8445f0e4b2a837b5e Mon Sep 17 00:00:00 2001 From: "Anthony C." Date: Sat, 18 Jul 2026 00:39:57 +0200 Subject: [PATCH 2/3] Fixed code style, modified README to document new option --- Plugins/Nya/Mesh.cs | 86 ++++++++--- Plugins/Nya/NyaArguments.cs | 2 +- Plugins/Nya/Texture.cs | 281 ++++++++++-------------------------- README.MD | 1 + 4 files changed, 143 insertions(+), 227 deletions(-) diff --git a/Plugins/Nya/Mesh.cs b/Plugins/Nya/Mesh.cs index b706f40..a9c71e9 100644 --- a/Plugins/Nya/Mesh.cs +++ b/Plugins/Nya/Mesh.cs @@ -278,6 +278,9 @@ private static Polygon ConvertPolygon( return polygon; } + /// + /// Spécifie les transformations de coordonnées UV applicables à une texture. + /// [Flags] public enum UvTransform { @@ -287,9 +290,22 @@ public enum UvTransform Both = HorizontalFlip | VerticalFlip } + /// + /// Represents the detailed result of a UV mapping operation. + /// + /// + /// This object is returned by . + /// public class TextureResult { + /// + /// Gets or sets the identifier of the texture (either already existing or newly created) + /// within the UV texture atlas. + /// public int TextureId { get; set; } + /// + /// Gets or sets the vertex permutation array used to map vertices of a polygon to their canonical order. + /// public int[]? VertexPermutation { get; set; } = null; } @@ -299,6 +315,8 @@ public class TextureResult /// Base texture /// UV coord indicies for quad /// All UV coords + /// False if the polygon was a triangle before being converted to a quad. + /// True if the polygon always was a Quad /// Texture similarity threshold percentage (0.0 to 100.0) /// above which 2 textures will be considered identical /// UV texture atlas @@ -332,35 +350,47 @@ private static TextureResult GetUvMappedTexture( for (int i = 0; i < uvTextures.Count; i++) { Texture existingTexture = uvTextures[i]; - if (existingTexture.GetBaseName() != baseTexture.Name) continue; + + if (existingTexture.GetBaseName() != baseTexture.Name) + { + continue; + } List existingUvs = existingTexture.UV.Select(id => uvCoords[id]).ToList(); - if (IsUvSameShape(existingUvs, currentFaceUvs, wasQuad, uEpsilon, vEpsilon, + if (Mesh.IsUvSameShape(existingUvs, currentFaceUvs, wasQuad, uEpsilon, vEpsilon, out UvTransform detectedTransform, out int[]? currentToCanonicalVertexOrder) && currentToCanonicalVertexOrder is not null) { //The shapes are similar, now we reorder to vertices so the content of the textures can be compared List reorderedCurrentUvs = new List(4); + for (int j = 0; j < 4; j++) + { reorderedCurrentUvs.Add(currentFaceUvs[currentToCanonicalVertexOrder[j]]); + } + Texture currentUnwrap = Texture.GetUnwrap(baseTexture, reorderedCurrentUvs); + //And we compare the content of the textures double currentScore = currentUnwrap.CalculateSimilarityTo(existingTexture); + if (currentScore >= textureMergeThreshold && currentScore > bestSimilarityScore) { bestSimilarityScore = currentScore; bestTextureId = i; bestPermutation = currentToCanonicalVertexOrder; - if (bestSimilarityScore >= 100.0) break; + if (bestSimilarityScore >= 100.0) + { + break; + } } } } if (bestTextureId >= 0) { - return new TextureResult - { TextureId = bestTextureId, VertexPermutation = bestPermutation }; + return new TextureResult { TextureId = bestTextureId, VertexPermutation = bestPermutation }; } // No match with existing texture, we extract a new one @@ -378,10 +408,14 @@ private static TextureResult GetUvMappedTexture( /// checking across multiple orientation configurations (default orientation, horizontal flip, vertical flip and both). /// The reference list of UV coordinates to compare against. /// The list of UV coordinates being evaluated for a potential match. + /// False if the polygon was a triangle before being converted to a quad. + /// True if the polygon always was a Quad /// The maximum allowed absolute difference along the U (X) axis. /// The maximum allowed absolute difference along the V (Y) axis. - /// When this method returns, contains the applied to achieve the match; otherwise, UvTransform.None. - /// When this method returns, contains an array mapping the current vertices to their canonical sequence if a match is found; otherwise, null. + /// When this method returns, contains the applied to achieve the match; + /// otherwise, UvTransform.None. + /// When this method returns, contains an array mapping the current vertices + /// to their canonical sequence if a match is found; otherwise, null. /// true if matches the shape of under any tested transformation; otherwise, false. private static bool IsUvSameShape( List existingUvs, @@ -395,7 +429,10 @@ private static bool IsUvSameShape( transform = UvTransform.None; currentToCanonicalVertexOrder = null; - if (existingUvs.Count != testedUvs.Count) return false; + if (existingUvs.Count != testedUvs.Count) + { + return false; + } Vector3D originExistingUvs = existingUvs[0]; List centeredExisting = existingUvs.Select(p => @@ -443,12 +480,19 @@ private static bool IsUvSameShape( } + /// + /// Contains the result of a polygon face canonicalization operation. + /// + /// The newly ordered and normalized list of 3D coordinates. + /// An array mapping each new position index back to its original index in the source list. public record CanonicalizationResult(List OrderedCoords, int[] NewToOldIndices); /// /// Canonicalizes a polygon face by enforcing a consistent vertex order. /// /// The initial list of 3D vector coordinates representing the face vertices. + /// False if the polygon was a triangle before being converted to a quad. + /// True if the polygon always was a Quad /// /// A tuple containing: /// @@ -474,29 +518,32 @@ private static CanonicalizationResult CanonicalizeFace(List rawCoords, int topLeft = 0; double bestDistSq = double.MaxValue; - for (int i = 0; i < vertexCount; i++) + for (int vertexID = 0; vertexID < vertexCount; vertexID++) { - double du = rawCoords[i].X - minU; - double dv = maxV - rawCoords[i].Y; + double du = rawCoords[vertexID].X - minU; + double dv = maxV - rawCoords[vertexID].Y; double d = du * du + dv * dv; if (d < bestDistSq) { bestDistSq = d; - topLeft = i; + topLeft = vertexID; } } - // Créer l'ordre cyclique int[] indices = new int[4]; - for (int i = 0; i < vertexCount; i++) + + for (int vertexID = 0; vertexID < vertexCount; vertexID++) { - indices[i] = (topLeft + i) % vertexCount; + indices[vertexID] = (topLeft + vertexID) % vertexCount; } List ordered = new List(4); - for (int i = 0; i < vertexCount; i++) - ordered.Add(rawCoords[indices[i]]); + + for (int vertexID = 0; vertexID < vertexCount; vertexID++) + { + ordered.Add(rawCoords[indices[vertexID]]); + } if(!wasQuad) { @@ -516,7 +563,10 @@ private static CanonicalizationResult CanonicalizeFace(List rawCoords, { (indices[1], indices[3]) = (indices[3], indices[1]); (ordered[1], ordered[3]) = (ordered[3], ordered[1]); - if(!wasQuad) { + + //Update the duplicated last vertex when we are dealing with a triangle polygon + if(!wasQuad) + { indices[2] = indices[3]; ordered[2] = ordered[3]; } diff --git a/Plugins/Nya/NyaArguments.cs b/Plugins/Nya/NyaArguments.cs index 7ed6ac7..bba85c5 100644 --- a/Plugins/Nya/NyaArguments.cs +++ b/Plugins/Nya/NyaArguments.cs @@ -46,7 +46,7 @@ public enum ModelTypes public bool NoUV { get; set; } /// - /// Gets or sets the texture similarity threshold percentage (0-100) above which 2 textures will be considered identical + /// Gets or sets the texture similarity threshold percentage (0.0-100.0) above which 2 textures will be considered identical /// as to reuse one texture in place of the other and therefore save space in memory. /// [CmdHelp("Texture similarity threshold percentage (0.0 to 100.0) above which 2 textures will be considered identical as to reuse one in place of the other and therefore save space in memory.\nDefault value is 100 (pixel perfect match).")] diff --git a/Plugins/Nya/Texture.cs b/Plugins/Nya/Texture.cs index b8c6c9e..9abe07d 100644 --- a/Plugins/Nya/Texture.cs +++ b/Plugins/Nya/Texture.cs @@ -5,16 +5,12 @@ using ModelConverter.Graphics; using Nya.Serializer; using SLIS = SixLabors.ImageSharp; - // using ImageHash = CoenM.ImageHash; //Tried to compare textures with this, but my own method turned out to be better /// /// Catgirl texture /// public class Texture { - // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.PerceptualHash(); - // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.AverageHash(); - // private static readonly ImageHash.IImageHash _hasher = new ImageHash.HashAlgorithms.DifferenceHash(); /// /// Initializes a new instance of the class @@ -116,30 +112,6 @@ public Texture(string name, SLIS.Image bitmap) : this( [FieldOrder(0)] public ushort Width { get; set; } - /// - /// Image hash - /// - private string hash = string.Empty; - - /// - /// Gets image hash - /// - public string Hash - { - get - { - if (string.IsNullOrWhiteSpace(this.hash)) - { - using (var sha1 = System.Security.Cryptography.SHA1.Create()) - { - this.hash = string.Concat(sha1.ComputeHash(this.Data.SelectMany(pair => new byte[] { (byte)((pair >> 8) & 0xf), (byte)(pair & 0xf) }).ToArray()).Select(x => x.ToString("X2"))); - } - } - - return this.hash; - } - } - /// /// Get UV unwrap texture /// @@ -228,15 +200,22 @@ public string GetBaseName() /// /// Compares this texture with another one. - /// The similarity score is based on color similarity (via CIEDE2000) and gradient (average detail/edge strength) + /// The similarity score is based on color similarity and gradient (average detail/edge strength) /// between the 2 images and between sub parts of both images. /// - /// The texture to compare to. - /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same). + /// The texture to compare to + /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same) public double CalculateSimilarityTo(Texture other) { - if (other == null) return 0.0; - if (ReferenceEquals(this, other)) return 100.0; + if (other == null) + { + return 0.0; + } + + if (ReferenceEquals(this, other)) + { + return 100.0; + } return CalculateRecursiveSimilarity(this, other, 0, 0, this.Width, this.Height, @@ -245,6 +224,17 @@ public double CalculateSimilarityTo(Texture other) /// /// Recursive similarity calculation using sliding windows. + /// The first image from which a region is being compared + /// The second image from which a region is being compared + /// x coordinate of the top left corner of the region of the first image being compared + /// y coordinate of the top left corner of the region of the first image being compared + /// Width of the region of the first image being compared + /// Height of the region of the first image being compared + /// x coordinate of the top left corner of the region of the second image being compared + /// y coordinate of the top left corner of the region of the second image being compared + /// Width of the region of the second image being compared + /// Height of the region of the second image being compared + /// A similarity score between 0.0 (completely different) and 100.0 (exactly the same) /// private static double CalculateRecursiveSimilarity( Texture img1, Texture img2, @@ -285,19 +275,20 @@ private static double CalculateRecursiveSimilarity( double subImgSimilarity = (tl + tr + bl + br) / 4.0; - // Weight can be adjusted + // Weights can be adjusted as long as their sum is equal to 1.0 return (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); } - //ça a marché avec maxdeltaE=10, depthWeight = (w1 / img1.Width); et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); - //ça a marché avec avgColor=8 depthWeight = (w1 / img1.Width); et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); - //ça a marché avec avgColor=8 depthWeight = (w1 / img1.Width)/2; et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold) - //ça a marché avec maxDeltaE = 8 depthWeight = (w1 / img1.Width) et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold 69) - //ça a marché avec maxDeltaE = 8 depthWeight = (w1 / img1.Width)/2 et (fullImgSimilarity * 0.2 + subImgSimilarity * 0.8); (threshold 68.125) /// /// Computes average RGB color of a rectangular region in the texture. + /// The texture in which a region is being processed + /// x coordinate of the top left corner of the region of the image + /// y coordinate of the top left corner of the region of the image + /// width of the region of the image + /// height of the region of the image + /// The average color of the given region of the texture /// - private static (byte R, byte G, byte B) GetAverageColor(Texture tex, int startX, int startY, int width, int height) + private static (byte R, byte G, byte B) GetAverageColor(Texture texture, int startX, int startY, int width, int height) { long sumR = 0, sumG = 0, sumB = 0; int count = 0; @@ -309,9 +300,9 @@ private static (byte R, byte G, byte B) GetAverageColor(Texture tex, int startX, int px = startX + x; int py = startY + y; - if (px < tex.Width && py < tex.Height) + if (px < texture.Width && py < texture.Height) { - ushort pixel = tex.Data[py * tex.Width + px]; + ushort pixel = texture.Data[py * texture.Width + px]; byte r = (byte)((pixel & 0x1F) << 3); byte g = (byte)(((pixel >> 5) & 0x1F) << 3); byte b = (byte)(((pixel >> 10) & 0x1F) << 3); @@ -324,7 +315,10 @@ private static (byte R, byte G, byte B) GetAverageColor(Texture tex, int startX, } } - if (count == 0) return (0, 0, 0); + if (count == 0) + { + return (0, 0, 0); + } return ( (byte)(sumR / count), @@ -334,8 +328,11 @@ private static (byte R, byte G, byte B) GetAverageColor(Texture tex, int startX, } /// - /// Similarity between two RGB colors (0 to 100). + /// Similarity between two RGB colors (0.0 to 100.0). /// Uses a simple RGB difference between the colors. + /// The first color being compared + /// The second color being compared + /// A similarity score between 0.0 and 100.0 /// private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte G, byte B) c2) { @@ -349,125 +346,46 @@ private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte return Math.Max(0, 100 * ((avgSim - (100 - maxDelta)) / maxDelta)); } - /// - /// Perceptual similarity between two RGB colors (0 to 100). - /// Uses RGB → LAB conversion + CIEDE2000 formula. - /// - // private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte G, byte B) c2) - // { - // if (c1.R == c2.R && c1.G == c2.G && c1.B == c2.B) - // return 100.0; - - // // Conversion RGB → XYZ → LAB - // var lab1 = RgbToLab(c1.R, c1.G, c1.B); - // var lab2 = RgbToLab(c2.R, c2.G, c2.B); - - // double deltaE = CieDE2000(lab1.L, lab1.a, lab1.b, lab2.L, lab2.a, lab2.b); - - // // Mapping Delta E to 0-100 score - // // Delta E < 1 = imperceptible - // // Delta E ~ 2-4 = very close - // // Delta E 10+ = clearly different colors - // const double maxDeltaE = 8.0; // Value beyond which we consider 0% similarity - - // double similarity = Math.Max(0.0, 100.0 * (1.0 - deltaE / maxDeltaE)); - // return similarity; - // } - - - private static (double L, double a, double b) RgbToLab(byte r, byte g, byte blue) - { - // 1. RGB → sRGB linear - double rr = r / 255.0; - double gg = g / 255.0; - double bb = blue / 255.0; - - rr = rr > 0.04045 ? Math.Pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92; - gg = gg > 0.04045 ? Math.Pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92; - bb = bb > 0.04045 ? Math.Pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92; - - // 2. sRGB → XYZ (D65) - double x = 0.4124 * rr + 0.3576 * gg + 0.1805 * bb; - double y = 0.2126 * rr + 0.7152 * gg + 0.0722 * bb; - double z = 0.0193 * rr + 0.1192 * gg + 0.9505 * bb; - - // 3. XYZ → LAB (D65) - x /= 0.95047; - y /= 1.00000; - z /= 1.08883; - - x = x > 0.008856 ? Math.Pow(x, 1.0 / 3.0) : (7.787 * x) + (16.0 / 116.0); - y = y > 0.008856 ? Math.Pow(y, 1.0 / 3.0) : (7.787 * y) + (16.0 / 116.0); - z = z > 0.008856 ? Math.Pow(z, 1.0 / 3.0) : (7.787 * z) + (16.0 / 116.0); - - double L = 116.0 * y - 16.0; - double aValue = 500.0 * (x - y); - double bValue = 200.0 * (y - z); - - return (L, aValue, bValue); - } - - private static double CieDE2000(double L1, double a1, double b1, double L2, double a2, double b2) - { - const double kL = 1.0, kC = 1.0, kH = 1.0; - - double dL = L2 - L1; - double C1 = Math.Sqrt(a1 * a1 + b1 * b1); - double C2 = Math.Sqrt(a2 * a2 + b2 * b2); - double dC = C2 - C1; - - double h1 = Math.Atan2(b1, a1); - double h2 = Math.Atan2(b2, a2); - double dh = h2 - h1; - - if (dh > Math.PI) dh -= 2 * Math.PI; - if (dh < -Math.PI) dh += 2 * Math.PI; - - double dH = 2 * Math.Sqrt(C1 * C2) * Math.Sin(dh / 2.0); - - double Lm = (L1 + L2) / 2.0; - double Cm = (C1 + C2) / 2.0; - double hm = (h1 + h2) / 2.0; - - double T = 1 - 0.17 * Math.Cos(hm - Math.PI / 6) - + 0.24 * Math.Cos(2 * hm) - + 0.32 * Math.Cos(3 * hm + Math.PI / 30) - - 0.20 * Math.Cos(4 * hm - 63 * Math.PI / 180); - - double SL = 1 + (0.015 * (Lm - 50) * (Lm - 50)) / Math.Sqrt(20 + (Lm - 50) * (Lm - 50)); - double SC = 1 + 0.045 * Cm; - double SH = 1 + 0.015 * Cm * T; - - double dTheta = 30 * Math.Exp(-((hm - 275 * Math.PI / 180) * (hm - 275 * Math.PI / 180)) / (25 * 25)); - double RC = 2 * Math.Sqrt(Math.Pow(Cm, 7) / (Math.Pow(Cm, 7) + Math.Pow(25, 7))); - double RT = -Math.Sin(2 * dTheta) * RC; - - double dL_ = dL / (kL * SL); - double dC_ = dC / (kC * SC); - double dH_ = dH / (kH * SH); - - return Math.Sqrt(dL_ * dL_ + dC_ * dC_ + dH_ * dH_ + RT * dC_ * dH_); - } /// - /// Simple similarity between two gradient values. + /// Calculate a similarity score between 0.0 and 100.0 between two gradient values. + /// The first gradient being compared + /// The second gradient being compared + /// A similarity score between 0.0 (completely different) and 100.0 (identical) /// private static double GradientSimilarity(double g1, double g2) { - if (g1 == 0 && g2 == 0) return 100.0; - if (g1 == 0 || g2 == 0) return 0.0; + if (g1 == 0 && g2 == 0) + { + return 100.0; + } + + if (g1 == 0 || g2 == 0) + { + return 0.0; + } double ratio = Math.Min(g1, g2) / Math.Max(g1, g2); + return ratio * 100.0; } /// /// Calculates the mean gradient (average detail/edge strength) of a region. /// Higher value = more details/texture variation. + /// The texture in which a region is being processed + /// x coordinate of the top left corner of the region of the image + /// y coordinate of the top left corner of the region of the image + /// width of the region of the image + /// height of the region of the image + /// The mean gradient of the given region of the texture /// - private static double GetMeanGradient(Texture tex, int startX, int startY, int width, int height) + private static double GetMeanGradient(Texture texture, int startX, int startY, int width, int height) { - if (width < 2 || height < 2) return 0.0; + if (width < 2 || height < 2) + { + return 0.0; + } long totalGradient = 0; int count = 0; @@ -479,12 +397,14 @@ private static double GetMeanGradient(Texture tex, int startX, int startY, int w int px = startX + x; int py = startY + y; - if (px >= tex.Width - 1 || py >= tex.Height - 1) + if (px >= texture.Width - 1 || py >= texture.Height - 1) + { continue; + } - ushort p1 = tex.Data[py * tex.Width + px]; // current pixel - ushort p2 = tex.Data[py * tex.Width + (px + 1)]; // right - ushort p3 = tex.Data[(py + 1) * tex.Width + px]; // below + ushort p1 = texture.Data[py * texture.Width + px]; // current pixel + ushort p2 = texture.Data[py * texture.Width + (px + 1)]; // right + ushort p3 = texture.Data[(py + 1) * texture.Width + px]; // below byte r1 = (byte)((p1 & 0x1F) << 3); byte g1 = (byte)(((p1 >> 5) & 0x1F) << 3); @@ -508,62 +428,7 @@ private static double GetMeanGradient(Texture tex, int startX, int startY, int w } } - return count == 0 ? 0.0 : (double)totalGradient / count; + return count == 0 ? 0.0 : (double) totalGradient / count; } - - /// - /// Compare this texture with another one using CoenM.ImageHash's algorithm. - /// - /// The texture to compare to. - /// A similarity score between 0 (completely different) and 100 (exactly the same). - // public double CalculateSimilarityTo(Texture other) - // { - // if (other == null) return 0.0; - // if (ReferenceEquals(this, other)) return 100.0; // 100% si même objet (correction : base 0-100) - - // ulong myHash = this.ImageHashValue; - // ulong otherHash = other.ImageHashValue; - - // return CoenM.ImageHash.CompareHash.Similarity(myHash, otherHash); - // } - - public SLIS.Image ToImageSharp() - { - // 2. Changement du type d'instanciation ici : Rgba32 - var image = new SLIS.Image(this.Width, this.Height); - - for (int y = 0; y < this.Height; y++) - { - for (int x = 0; x < this.Width; x++) - { - ushort abgr555 = this.Data[(y * this.Width) + x]; - - // Décodage natif Saturn ABGR555 -> RGBA 32 bits - byte a = (byte)(((abgr555 >> 15) & 0x01) * 255); - byte b = (byte)(((abgr555 >> 10) & 0x1F) << 3); - byte g = (byte)(((abgr555 >> 5) & 0x1F) << 3); - byte r = (byte)((abgr555 & 0x1F) << 3); - - // 3. Remplissage avec le constructeur Rgba32 standard - image[x, y] = new SLIS.PixelFormats.Rgba32(r, g, b, a); - } - } - - return image; - } - - // private ulong? _imageHashCache; - - // private ulong ImageHashValue - // { - // get - // { - // if (!_imageHashCache.HasValue) - // { - // _imageHashCache = _hasher.Hash(ToImageSharp()); - // } - // return _imageHashCache.Value; - // } - // } } } \ No newline at end of file diff --git a/README.MD b/README.MD index 551019c..fd46ede 100644 --- a/README.MD +++ b/README.MD @@ -88,6 +88,7 @@ Argument | Description -------------|------------------------------------------------------ w, no-unwrap | Disable UV mapping preprocessor t, type | Specify object type
``-t NoLight`` = Not shaded
``-t Flat`` = Flat shaded
``-t Smooth`` = Smooth shaded model +m, texture-merge-threshold | Texture similarity threshold percentage (0.0 to 100.0) above which 2 textures will be considered identical as to reuse one in place of the other and therefore save space in memory. Defaults to pixel perfect threshold (100.0). ### Custom plugins Custom plugins can be also written by referencing the ModelConverter.dll and implementing the interfaces within. Custom plugin dll with its dependencies can than be put inside ``/plugins/[plugin name]/`` folder. From 498ab70a29f19a1ccb81fbc3b32ae0ad46c427a7 Mon Sep 17 00:00:00 2001 From: "Anthony C." Date: Mon, 20 Jul 2026 15:35:06 +0200 Subject: [PATCH 3/3] Fixed code style (added empty line, removed empty lines), fixed doc. --- Plugins/Nya/Mesh.cs | 23 +++++++++++++++++++---- Plugins/Nya/Texture.cs | 2 -- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Plugins/Nya/Mesh.cs b/Plugins/Nya/Mesh.cs index a9c71e9..eed4100 100644 --- a/Plugins/Nya/Mesh.cs +++ b/Plugins/Nya/Mesh.cs @@ -179,7 +179,7 @@ private static Polygon ConvertPolygon( TextureResult result = Mesh.GetUvMappedTexture(texture, finalUvs, group.Uv, wasQuad, textureMergeThreshold, ref uvTextures); faceFlag.TextureId = result.TextureId; - // 1. Reorder vertices depending on how the texture matched + // Reorder vertices depending on how the texture matched if (result.VertexPermutation != null) { List newUvs = new List(4); @@ -199,7 +199,7 @@ private static Polygon ConvertPolygon( finalVertices = newVertices; } - // Réinjection + // Reinjection face.Uv = finalUvs; face.Normals = finalNormals; face.Vertices = finalVertices; @@ -279,14 +279,29 @@ private static Polygon ConvertPolygon( } /// - /// Spécifie les transformations de coordonnées UV applicables à une texture. + /// Specifies transforms that can be applied to a texture. /// [Flags] public enum UvTransform { + /// + /// Original texture, no transform applied. + /// None = 0, + + /// + /// Texture is mirrored horizontally. + /// HorizontalFlip = 1, + + /// + /// Texture is mirrored vertically. + /// VerticalFlip = 2, + + /// + /// Texture is mirrored horizontally and vertically. + /// Both = HorizontalFlip | VerticalFlip } @@ -303,6 +318,7 @@ public class TextureResult /// within the UV texture atlas. ///
public int TextureId { get; set; } + /// /// Gets or sets the vertex permutation array used to map vertices of a polygon to their canonical order. /// @@ -479,7 +495,6 @@ private static bool IsUvSameShape( return false; } - /// /// Contains the result of a polygon face canonicalization operation. /// diff --git a/Plugins/Nya/Texture.cs b/Plugins/Nya/Texture.cs index 9abe07d..3679098 100644 --- a/Plugins/Nya/Texture.cs +++ b/Plugins/Nya/Texture.cs @@ -11,7 +11,6 @@ ///
public class Texture { - /// /// Initializes a new instance of the class /// @@ -346,7 +345,6 @@ private static double ColorSimilarity((byte R, byte G, byte B) c1, (byte R, byte return Math.Max(0, 100 * ((avgSim - (100 - maxDelta)) / maxDelta)); } - /// /// Calculate a similarity score between 0.0 and 100.0 between two gradient values. /// The first gradient being compared