Rewrote quantizer

This commit is contained in:
2025-01-25 20:28:51 +01:00
parent 3f238c619e
commit 9ddafca929
10 changed files with 357 additions and 548 deletions

163
Runtime/ImageProcessing.cs Normal file
View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace PSXSplash.RuntimeCode
{
public class ImageQuantizer
{
private int _maxColors;
private Color[] _pixels;
private Color[] _centroids;
private int[] _assignments;
private List<Color> _uniqueColors;
public Color[] Palette
{
get => _centroids;
}
public int[] Pixels
{
get => _assignments;
}
public void Quantize(Texture2D texture2D, int maxColors)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
_pixels = texture2D.GetPixels();
_maxColors = maxColors;
_centroids = new Color[_maxColors];
_uniqueColors = new List<Color>();
FillRandomCentroids();
bool hasChanged;
_assignments = new int[_pixels.Count()];
do
{
hasChanged = false;
Parallel.For(0, _pixels.Count(), i =>
{
int newAssignment = GetNearestCentroid(_pixels[i]);
if (_assignments[i] != newAssignment)
{
lock (_assignments)
{
_assignments[i] = newAssignment;
}
lock (this)
{
hasChanged = true;
}
}
});
RecalculateCentroids();
} while (hasChanged);
stopwatch.Stop();
UnityEngine.Debug.Log($"Quantization completed in {stopwatch.ElapsedMilliseconds} ms");
}
private void FillRandomCentroids()
{
foreach (Color pixel in _pixels)
{
if (!_uniqueColors.Contains(pixel))
{
_uniqueColors.Add(pixel);
}
}
for (int i = 0; i < _maxColors; i++)
{
_centroids[i] = _uniqueColors[UnityEngine.Random.Range(0, _uniqueColors.Count - 1)];
}
}
private double CalculateColorDistance(Color color1, Color color2)
{
float rDiff = color1.r - color2.r;
float gDiff = color1.g - color2.g;
float bDiff = color1.b - color2.b;
return Math.Sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
}
private int GetNearestCentroid(Color color)
{
double minDistance = double.MaxValue;
int closestCentroidIndex = 0;
for (int i = 0; i < _maxColors; i++)
{
double distance = CalculateColorDistance(_centroids[i], color);
if (distance < minDistance)
{
minDistance = distance;
closestCentroidIndex = i;
}
}
return closestCentroidIndex;
}
private void RecalculateCentroids()
{
Color[] newCentroids = new Color[_maxColors];
Parallel.For(0, _maxColors, i =>
{
List<Color> clusterColors = new List<Color>();
for (int j = 0; j < _pixels.Length; j++)
{
if (_assignments[j] == i)
{
clusterColors.Add(_pixels[j]);
}
}
Color newCentroid;
try
{
newCentroid = AverageColor(clusterColors);
}
catch (InvalidOperationException)
{
System.Random random = new System.Random();
newCentroid = _uniqueColors[random.Next(0, _uniqueColors.Count - 1)];
}
newCentroids[i] = newCentroid;
});
_centroids = newCentroids;
}
private Color AverageColor(List<Color> colors)
{
float r = colors.Average(c => c.r);
float g = colors.Average(c => c.g);
float b = colors.Average(c => c.b);
float a = colors.Average(c => c.a);
return new Color(r, g, b, a);
}
}
}

View File

@@ -1,175 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
public class ImageQuantizer
{
public static (ushort[], ushort[]) Quantize(Texture2D image, int bpp, int maxIterations = 10)
{
int width = image.width;
int height = image.height;
int maxColors = (int)Math.Pow(bpp, 2);
List<Vector3> centroids = InitializeCentroids(image, maxColors);
Color[] pixels = image.GetPixels();
Vector3[] pixelColors = new Vector3[pixels.Length];
for (int i = 0; i < pixels.Length; i++)
{
pixelColors[i] = new Vector3(pixels[i].r, pixels[i].g, pixels[i].b);
}
ushort[] assignments = new ushort[pixelColors.Length];
// Perform k-means clustering
for (int iteration = 0; iteration < maxIterations; iteration++)
{
bool centroidsChanged = false;
for (int i = 0; i < pixelColors.Length; i++)
{
ushort closestCentroid = (ushort)GetClosestCentroid(pixelColors[i], centroids);
if (assignments[i] != closestCentroid)
{
assignments[i] = closestCentroid;
centroidsChanged = true;
}
}
Vector3[] newCentroids = new Vector3[centroids.Count];
int[] centroidCounts = new int[centroids.Count];
for (int i = 0; i < assignments.Length; i++)
{
int centroidIndex = assignments[i];
newCentroids[centroidIndex] += pixelColors[i];
centroidCounts[centroidIndex]++;
}
for (int i = 0; i < centroids.Count; i++)
{
if (centroidCounts[i] > 0)
{
newCentroids[i] /= centroidCounts[i];
}
else
{
newCentroids[i] = RandomizeCentroid(image);
}
}
if (!centroidsChanged) break;
centroids = new List<Vector3>(newCentroids);
}
int pixelSize = bpp == 4 ? 4 : bpp == 8 ? 2 : 1;
int adjustedWidth = width / pixelSize;
ushort[] pixelArray = new ushort[adjustedWidth * height];
ushort packIndex = 0;
int bitShift = 0;
// Loop through pixels and pack the data, flipping along the Y-axis
for (int y = height - 1; y >= 0; y--)
{
for (int x = 0; x < width; x++)
{
int pixelIndex = y * width + x;
ushort centroidIndex = assignments[pixelIndex];
// For 4bpp, we need to pack 4 indices into a single integer
if (bpp == 4)
{
pixelArray[packIndex] |= (ushort)(centroidIndex << (bitShift * 4));
bitShift++;
if (bitShift == 4)
{
bitShift = 0;
packIndex++;
}
}
// For 8bpp, we need to pack 2 indices into a single integer
else if (bpp == 8)
{
pixelArray[packIndex] |= (ushort)(centroidIndex << (bitShift * 8));
bitShift++;
if (bitShift == 2)
{
bitShift = 0;
packIndex++;
}
}
// For 15bpp, just place each index directly (no packing)
else
{
pixelArray[packIndex] = centroidIndex;
packIndex++;
}
}
}
int actualColors = centroids.Count;
ushort[] clut = new ushort[actualColors];
for (int i = 0; i < actualColors; i++)
{
int red = Mathf.Clamp(Mathf.RoundToInt(centroids[i].x * 31), 0, 31); // 5 bits
int green = Mathf.Clamp(Mathf.RoundToInt(centroids[i].y * 31), 0, 31); // 5 bits
int blue = Mathf.Clamp(Mathf.RoundToInt(centroids[i].z * 31), 0, 31); // 5 bits
clut[i] = (ushort)((blue << 10) | (green << 5) | red);
}
return (pixelArray, clut);
}
private static List<Vector3> InitializeCentroids(Texture2D image, int maxColors)
{
List<Vector3> centroids = new List<Vector3>();
Color[] pixels = image.GetPixels();
HashSet<Vector3> uniqueColors = new HashSet<Vector3>();
foreach (Color pixel in pixels)
{
Vector3 color = new Vector3(pixel.r, pixel.g, pixel.b);
if (!uniqueColors.Contains(color))
{
uniqueColors.Add(color);
centroids.Add(color);
if (centroids.Count >= maxColors) break;
}
}
return centroids;
}
private static Vector3 RandomizeCentroid(Texture2D image)
{
Color randomPixel = image.GetPixel(Random.Range(0, image.width), Random.Range(0, image.height));
return new Vector3(randomPixel.r, randomPixel.g, randomPixel.b);
}
private static int GetClosestCentroid(Vector3 color, List<Vector3> centroids)
{
int closestCentroid = 0;
float minDistanceSq = float.MaxValue;
for (int i = 0; i < centroids.Count; i++)
{
float distanceSq = (color - centroids[i]).sqrMagnitude;
if (distanceSq < minDistanceSq)
{
minDistanceSq = distanceSq;
closestCentroid = i;
}
}
return closestCentroid;
}
}

View File

@@ -6,7 +6,7 @@ namespace PSXSplash.RuntimeCode
{
public PSXMesh Mesh;
public PSXTexture Texture;
//ublic PSXTexture Texture;
public void Export()
{

View File

@@ -1,196 +0,0 @@
using System.IO;
using UnityEditor;
using UnityEngine;
namespace PSXSplash.RuntimeCode
{
public enum PSXTextureType
{
TEX_4BPP = 4,
TEX_8BPP = 8,
TEX16_BPP = 16
}
[System.Serializable]
public class PSXTexture
{
public PSXTextureType TextureType = PSXTextureType.TEX_8BPP;
public bool Dithering = true;
[Range(1, 256)]
public int Width = 128;
[Range(1, 256)]
public int Height = 128;
public int MaxKMeans = 50;
public ushort[] ExportTexture(GameObject gameObject)
{
Debug.Log($"Export: {this}");
MeshRenderer meshRenderer = gameObject.GetComponent<MeshRenderer>();
if (meshRenderer != null)
{
Texture texture = meshRenderer.material.mainTexture;
if (texture is Texture2D)
{
Texture2D originalTexture = (Texture2D)texture;
Texture2D newTexture = ResizeTexture(originalTexture, Width, Height);
if (Dithering)
{
newTexture = DitherTexture(newTexture);
}
if (TextureType == PSXTextureType.TEX16_BPP)
{
ushort[] converted = ConvertTo16Bpp(newTexture);
return converted;
}
else
{
var (indexedPixels, _) = ImageQuantizer.Quantize(newTexture, (int)TextureType, MaxKMeans);
return indexedPixels;
}
}
}
return null;
}
public ushort[] ExportClut(GameObject gameObject)
{
Debug.Log($"Export: {this}");
MeshRenderer meshRenderer = gameObject.GetComponent<MeshRenderer>();
if (meshRenderer != null)
{
Texture texture = meshRenderer.material.mainTexture;
if (texture is Texture2D)
{
Texture2D originalTexture = (Texture2D)texture;
Texture2D newTexture = ResizeTexture(originalTexture, Width, Height);
if (TextureType == PSXTextureType.TEX16_BPP)
{
return null;
}
else
{
var (_, generatedClut) = ImageQuantizer.Quantize(newTexture, (int)TextureType, MaxKMeans);
return generatedClut;
}
}
}
return null;
}
public static Texture2D ResizeTexture(Texture2D source, int newWidth, int newHeight)
{
RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight);
rt.antiAliasing = 1;
Graphics.Blit(source, rt);
Texture2D resizedTexture = new Texture2D(newWidth, newHeight);
RenderTexture.active = rt;
resizedTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
resizedTexture.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
return resizedTexture;
}
public static ushort[] ConvertTo16Bpp(Texture2D source)
{
int width = source.width;
int height = source.height;
ushort[] packedData = new ushort[width * height];
Color[] originalPixels = source.GetPixels();
// Flip the image on the Y-axis
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int flippedY = height - y - 1;
int index = flippedY * width + x;
// Retrieve the pixel color
Color pixel = originalPixels[index];
// Convert to 5-bit components
int r = Mathf.Clamp(Mathf.RoundToInt(pixel.r * 31), 0, 31); // 5 bits for red
int g = Mathf.Clamp(Mathf.RoundToInt(pixel.g * 31), 0, 31); // 5 bits for green
int b = Mathf.Clamp(Mathf.RoundToInt(pixel.b * 31), 0, 31); // 5 bits for blue
// Pack into a ushort: R(0..4), G(5..9), B(10..14), Padding(15)
packedData[y * width + x] = (ushort)((b << 10) | (g << 5) | r);
}
}
return packedData;
}
public static Texture2D DitherTexture(Texture2D sourceTexture, float threshold = 0.2f, float errorDiffusionStrength = 0.1f)
{
int width = sourceTexture.width;
int height = sourceTexture.height;
Color[] pixels = sourceTexture.GetPixels();
Color[] ditheredPixels = new Color[pixels.Length];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int index = y * width + x;
Color pixel = pixels[index];
// Convert the pixel to grayscale
float gray = pixel.grayscale;
// Apply threshold to determine if it's black or white
int dithered = (gray > threshold) ? 1 : 0;
// Calculate the error as the difference between the grayscale value and the dithered result
float error = gray - dithered;
// Store the dithered pixel
ditheredPixels[index] = new Color(dithered, dithered, dithered);
// Spread the error to neighboring pixels with customizable error diffusion strength
if (x + 1 < width) pixels[(y * width) + (x + 1)] += new Color(error * 7f / 16f * errorDiffusionStrength, error * 7f / 16f * errorDiffusionStrength, error * 7f / 16f * errorDiffusionStrength);
if (y + 1 < height) pixels[((y + 1) * width) + x] += new Color(error * 3f / 16f * errorDiffusionStrength, error * 3f / 16f * errorDiffusionStrength, error * 3f / 16f * errorDiffusionStrength);
if (x - 1 >= 0 && y + 1 < height) pixels[((y + 1) * width) + (x - 1)] += new Color(error * 5f / 16f * errorDiffusionStrength, error * 5f / 16f * errorDiffusionStrength, error * 5f / 16f * errorDiffusionStrength);
if (x + 1 < width && y + 1 < height) pixels[((y + 1) * width) + (x + 1)] += new Color(error * 1f / 16f * errorDiffusionStrength, error * 1f / 16f * errorDiffusionStrength, error * 1f / 16f * errorDiffusionStrength);
}
}
// Clamp the final pixel values to ensure they are valid colors
for (int i = 0; i < pixels.Length; i++)
{
pixels[i].r = Mathf.Clamp01(pixels[i].r);
pixels[i].g = Mathf.Clamp01(pixels[i].g);
pixels[i].b = Mathf.Clamp01(pixels[i].b);
}
// Create the resulting dithered texture
Texture2D ditheredTexture = new Texture2D(width, height);
ditheredTexture.SetPixels(pixels);
ditheredTexture.Apply();
return ditheredTexture;
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6d64b6bb75da33720b928203b2780952

173
Runtime/PSXTexture2D.cs Normal file
View File

@@ -0,0 +1,173 @@
using System.Collections.Generic;
using UnityEngine;
namespace PSXSplash.RuntimeCode
{
public enum PSXBPP
{
TEX_4BIT = 4,
TEX_8BIT = 8,
TEX_16BIT = 15
}
public struct VRAMPixel
{
private ushort r; // 0-4 bits
private ushort g; // 5-9 bits
private ushort b; // 10-14 bits
public ushort R
{
get => r;
set => r = (ushort)(value & 0b11111);
}
public ushort G
{
get => g;
set => g = (ushort)(value & 0b11111);
}
public ushort B
{
get => b;
set => b = (ushort)(value & 0b11111);
}
public bool SemiTransparent { get; set; } // 15th bit
public ushort Pack()
{
return (ushort)((r << 11) | (g << 6) | (b << 1) | (SemiTransparent ? 1 : 0));
}
public void Unpack(ushort packedValue)
{
r = (ushort)((packedValue >> 11) & 0b11111);
g = (ushort)((packedValue >> 6) & 0b11111);
b = (ushort)((packedValue >> 1) & 0b11111);
SemiTransparent = (packedValue & 0b1) != 0;
}
}
public class PSXTexture2D
{
public int Width { get; set; }
public int Height { get; set; }
public int[] Pixels { get; set; }
public List<VRAMPixel> ColorPalette = new List<VRAMPixel>();
public PSXBPP BitDepth;
private int _maxColors;
public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth, bool dither)
{
PSXTexture2D psxTex = new PSXTexture2D();
psxTex.Width = inputTexture.width;
psxTex.Height = inputTexture.height;
psxTex.BitDepth = bitDepth;
psxTex._maxColors = (int)Mathf.Pow((int)bitDepth, 2);
ImageQuantizer quantizer = new ImageQuantizer();
quantizer.Quantize(inputTexture, psxTex._maxColors);
foreach (Color pixel in quantizer.Palette)
{
VRAMPixel vramPixel = new VRAMPixel { R = (ushort)(pixel.r * 31), G = (ushort)(pixel.g * 31), B = (ushort)(pixel.b * 31) };
psxTex.ColorPalette.Add(vramPixel);
}
psxTex.Pixels = quantizer.Pixels;
return psxTex;
}
public Texture2D GeneratePreview()
{
Texture2D tex = new Texture2D(Width, Height);
List<Color> colors = new List<Color>();
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
int pixel = Pixels[y * Width + x];
VRAMPixel color = ColorPalette[pixel];
float r = color.R / 31f;
float g = color.G / 31f;
float b = color.B / 31f;
colors.Add(new Color(r, g, b));
}
}
tex.SetPixels(colors.ToArray());
tex.Apply();
return tex;
}
public Texture2D GenerateVramPreview()
{
int adjustedWidth = Width;
if (BitDepth == PSXBPP.TEX_4BIT)
{
adjustedWidth = Mathf.CeilToInt(Width / 4f);
}
else if (BitDepth == PSXBPP.TEX_8BIT)
{
adjustedWidth = Mathf.CeilToInt(Width / 2f);
}
Texture2D vramTexture = new Texture2D(adjustedWidth, Height);
List<ushort> packedValues = new List<ushort>();
if (BitDepth == PSXBPP.TEX_4BIT)
{
for (int i = 0; i < Pixels.Length; i += 4)
{
ushort packed = (ushort)((Pixels[i] << 12) | (Pixels[i + 1] << 8) | (Pixels[i + 2] << 4) | Pixels[i + 3]);
packedValues.Add(packed);
}
}
else if (BitDepth == PSXBPP.TEX_8BIT)
{
for (int i = 0; i < Pixels.Length; i += 2)
{
ushort packed = (ushort)((Pixels[i] << 8) | Pixels[i + 1]);
packedValues.Add(packed);
}
}
List<Color> colors = new List<Color>();
for (int i = 0; i < packedValues.Count; i++)
{
int index = packedValues[i];
float r = (index & 31) / 31.0f;
float g = ((index >> 5) & 31) / 31.0f;
float b = ((index >> 10) & 31) / 31.0f;
colors.Add(new Color(r, g, b));
}
vramTexture.SetPixels(colors.ToArray());
vramTexture.Apply();
return vramTexture;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac29cfb818d45b12dba84e61317c794b