V0.1.4, added Floyd Steinberg algorithm and revisited UI

This commit is contained in:
ewen 2026-01-14 23:39:55 +01:00
parent d2b78668c3
commit cddbbec233
5 changed files with 379 additions and 68 deletions

View file

@ -9,7 +9,7 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting) SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
.navigationSplitViewColumnWidth(min: 260, ideal: 300) .navigationSplitViewColumnWidth(min: 280, ideal: 300)
} detail: { } detail: {
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders) DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
} }
@ -100,66 +100,115 @@ struct SidebarView: View {
@Binding var isExporting: Bool @Binding var isExporting: Bool
var body: some View { var body: some View {
Form { ScrollView {
Section("Dithering Algorithm") { VStack(alignment: .leading, spacing: 16) {
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
ForEach(DitherAlgorithm.allCases) { algo in // --- ALGORITHM SECTION ---
Text(algo.name).tag(algo) VStack(alignment: .leading, spacing: 12) {
Text("ALGORITHM")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.top, 8)
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
ForEach(DitherAlgorithm.allCases) { algo in
Text(algo.name).tag(algo)
}
}
.labelsHidden() // Native look: just the dropdown
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
.toggleStyle(.switch)
.padding(.top, 4)
}
Divider()
.padding(.vertical, 8)
// --- PRE-PROCESSING SECTION ---
VStack(alignment: .leading, spacing: 16) {
Text("PRE-PROCESSING")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
// Brightness slider
VStack(spacing: 6) {
HStack {
Text("Brightness")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.brightness))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.brightness, in: -1.0...1.0)
.tint(.accentColor)
}
// Contrast slider
VStack(spacing: 6) {
HStack {
Text("Contrast")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.contrast))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.contrast, in: 0.0...4.0)
.tint(.accentColor)
}
// Pixel Scale slider
VStack(spacing: 6) {
HStack {
Text("Pixel Scale")
.font(.system(size: 13))
Spacer()
Text("\(Int(viewModel.pixelScale))x")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
.tint(.accentColor)
} }
} }
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale) Divider()
} .padding(.vertical, 8)
Section("Pre-Processing") { // --- QUANTIZATION SECTION ---
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 16) {
HStack { Text("QUANTIZATION")
Text("Brightness") .font(.system(size: 11, weight: .medium))
Spacer() .foregroundStyle(.secondary)
Text(String(format: "%.2f", viewModel.brightness))
.monospacedDigit() VStack(spacing: 6) {
.foregroundStyle(.secondary) HStack {
} Text("Color Depth")
Slider(value: $viewModel.brightness, in: -1.0...1.0) .font(.system(size: 13))
} Spacer()
Text("\(Int(viewModel.colorDepth))")
VStack(alignment: .leading) { .font(.system(size: 13, weight: .medium))
HStack { .foregroundStyle(.secondary)
Text("Contrast") .monospacedDigit()
Spacer() }
Text(String(format: "%.2f", viewModel.contrast)) Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
.monospacedDigit() .tint(.accentColor)
.foregroundStyle(.secondary) }
} }
Slider(value: $viewModel.contrast, in: 0.0...4.0)
} Spacer()
VStack(alignment: .leading) {
HStack {
Text("Pixel Scale")
Spacer()
Text("\(Int(viewModel.pixelScale))x")
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
}
VStack(alignment: .leading) {
HStack {
Text("Color Depth")
Spacer()
Text("\(Int(viewModel.colorDepth))")
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
}
} }
.padding(20)
} }
.formStyle(.grouped) .background(.regularMaterial) // Native material background
.padding(.vertical) .ignoresSafeArea(edges: .top) // Fix for titlebar gap
.navigationTitle("iDither") .navigationTitle("iDither")
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
Button(action: { isImporting = true }) { Button(action: { isImporting = true }) {

View file

@ -15,6 +15,8 @@ final class MetalImageRenderer: Sendable {
private let device: MTLDevice private let device: MTLDevice
private let commandQueue: MTLCommandQueue private let commandQueue: MTLCommandQueue
private let pipelineState: MTLComputePipelineState private let pipelineState: MTLComputePipelineState
private let pipelineStateFS_Pass1: MTLComputePipelineState?
private let pipelineStateFS_Pass2: MTLComputePipelineState?
init?() { init?() {
guard let device = MTLCreateSystemDefaultDevice(), guard let device = MTLCreateSystemDefaultDevice(),
@ -29,6 +31,15 @@ final class MetalImageRenderer: Sendable {
do { do {
self.pipelineState = try device.makeComputePipelineState(function: function) self.pipelineState = try device.makeComputePipelineState(function: function)
// Load FS Kernels
if let f1 = library.makeFunction(name: "ditherShaderFS_Pass1"),
let f2 = library.makeFunction(name: "ditherShaderFS_Pass2") {
self.pipelineStateFS_Pass1 = try device.makeComputePipelineState(function: f1)
self.pipelineStateFS_Pass2 = try device.makeComputePipelineState(function: f2)
} else {
self.pipelineStateFS_Pass1 = nil
self.pipelineStateFS_Pass2 = nil
}
} catch { } catch {
print("Failed to create pipeline state: \(error)") print("Failed to create pipeline state: \(error)")
return nil return nil
@ -60,26 +71,68 @@ final class MetalImageRenderer: Sendable {
return nil return nil
} }
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
var params = params var params = params
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
let h = pipelineState.maxTotalThreadsPerThreadgroup / w // FLOYD-STEINBERG MULTI-PASS
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1) // Create Error Texture (Float16 or Float32 for precision)
let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
errorDesc.usage = [.shaderWrite, .shaderRead]
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
computeEncoder.endEncoding()
return nil
}
// PASS 1: Even Rows
computeEncoder.setComputePipelineState(pipe1)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setTexture(errorTexture, index: 2)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
// Dispatch (1, H/2, 1) -> Each thread handles one full row
let h = (inputTexture.height + 1) / 2
let threadsPerGrid = MTLSizeMake(1, h, 1)
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
// Memory Barrier (Ensure Pass 1 writes are visible to Pass 2)
computeEncoder.memoryBarrier(scope: .textures)
// PASS 2: Odd Rows
computeEncoder.setComputePipelineState(pipe2)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setTexture(errorTexture, index: 2)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
} else {
// STANDARD ALGORITHMS
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
}
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder.endEncoding() computeEncoder.endEncoding()
commandBuffer.commit() commandBuffer.commit()
commandBuffer.waitUntilCompleted() commandBuffer.waitUntilCompleted()
// Convert back to CGImage (for simplicity in this iteration, though MTKView is better for display)
// We will use a helper to convert MTLTexture to CGImage
return createCGImage(from: outputTexture) return createCGImage(from: outputTexture)
} }

View file

@ -165,5 +165,211 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
} }
} }
outputTexture.write(float4(rgb, color.a), gid); outputTexture.write(float4(rgb, color.a), gid);
} }
// ==================================================================================
// FLOYD-STEINBERG ERROR DIFFUSION HELPERS & KERNELS (Algorithm ID 7)
// ==================================================================================
// Helper to get luminance for error calculation in grayscale mode
float getLuma(float3 rgb) {
return dot(rgb, float3(0.299, 0.587, 0.114));
}
// PASS 1: EVEN ROWS (Left -> Right)
// - Reads original pixel
// - Dithers it
// - Writes result to outputTexture
// - Writes RAW error 'diff' to errorTexture (at current coord) for Pass 2 to consume
kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[texture(0)]],
texture2d<float, access::write> outputTexture [[texture(1)]],
texture2d<float, access::write> errorTexture [[texture(2)]],
constant RenderParameters &params [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
// Dispatch: (1, height/2, 1). Each thread processes one FULL ROW.
uint y = gid.y * 2; // Pass 1 processes EVEN rows: 0, 2, 4...
if (y >= inputTexture.get_height()) return;
uint width = inputTexture.get_width();
float3 currentError = float3(0.0); // Error propagated from immediate Left neighbor
// Scale handling (minimal implementation for now, usually FS runs 1:1)
// If pixel scale > 1, FS behaves weirdly unless we downsample/upsample.
// For now, let's treat FS as operating on the native coordinates (or scaled ones).
// The previous shader code did manual pixelation.
// To support `pixelScale`, we simply use the scaled coordinates for reading input,
// but we iterate 1:1 on output? No, if we pixelate, we want blocky dither?
// FS is hard to 'blocky' dither without pre-scaling.
// Let's stick to 1:1 processing for the error diffusion logic itself.
// But we read the input color from the "pixelated" coordinate.
float scale = max(1.0, params.pixelScale);
for (uint x = 0; x < width; x++) {
uint2 coords = uint2(x, y);
// Pixelate Input Read
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
float4 colorRaw = inputTexture.read(mappedCoords);
float3 originalColor = colorRaw.rgb;
// Color Adjust
originalColor = originalColor + params.brightness;
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
// Grayscale
if (params.isGrayscale > 0) {
float l = getLuma(originalColor);
originalColor = float3(l);
}
// ----------------------------------------------------
// ERROR DIFFUSION CORE
// ----------------------------------------------------
// Add error from Left Neighbor (Pass 1 is L->R)
float3 pixelIn = originalColor + currentError;
// Quantize
float3 pixelOut = float3(0.0);
float levels = max(1.0, params.colorDepth); // Ensure no div by zero
if (levels <= 1.0) levels = 2.0;
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut = clamp(pixelOut, 0.0, 1.0);
// Calculate Error
float3 diff = pixelIn - pixelOut;
// Store RAW error for Pass 2 (Row below) to read
// Note: we store 'diff', NOT the distributed parts. Pass 2 will calculate distribution.
if (y + 1 < inputTexture.get_height()) {
errorTexture.write(float4(diff, 1.0), coords);
}
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
// Propagate to Right Neighbor (7/16)
currentError = diff * (7.0 / 16.0);
}
}
// PASS 2: ODD ROWS (Right -> Left Serpentine)
// - Reads original pixel
// - Absorbs error from Row Above (which stored RAW diffs)
// - Dithers
// - Writes result
kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[texture(0)]],
texture2d<float, access::write> outputTexture [[texture(1)]],
texture2d<float, access::read> errorTexture [[texture(2)]], // Contains diffs from Pass 1
constant RenderParameters &params [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
// Dispatch: (1, height/2, 1)
uint y = gid.y * 2 + 1; // Pass 2 processes ODD rows: 1, 3, 5...
if (y >= inputTexture.get_height()) return;
uint width = inputTexture.get_width();
float3 currentError = float3(0.0); // Error propagated from immediate Right neighbor (Serpentine R->L)
float scale = max(1.0, params.pixelScale);
// Serpentine: Iterate Right to Left
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
uint x = uint(x_int);
uint2 coords = uint2(x, y);
// 1. Calculate Incoming Error from Row Above (Even Row, L->R)
// Row Above is y-1. We are at x.
// Even Row (y-1) propagated error to us (y) via:
// - (x-1, y-1) sent 3/16 (Bottom Left) -> reaches ME at x if I am (x-1+1) = x. Correct.
// - (x, y-1) sent 5/16 (Down) -> reaches ME at x. Correct.
// - (x+1, y-1) sent 1/16 (Bottom Right)-> reaches ME at x. Correct.
float3 errorFromAbove = float3(0.0);
uint prevY = y - 1;
// Read neighbor errors (and apply weights now)
// From Top-Left (x-1, y-1): It pushed 3/16 to Bottom-Right (x) ? No.
// Standard FS (Left->Right scan):
// P(x, y) distributes:
// Right (x+1, y): 7/16
// Bottom-Left (x-1, y+1): 3/16
// Bottom (x, y+1): 5/16
// Bottom-Right (x+1, y+1): 1/16
// So, ME (x, y) receives from:
// (x+1, y-1) [Top Right]: sent 3/16 to its Bottom Left (which is ME).
// (x, y-1) [Top]: sent 5/16 to its Bottom (which is ME).
// (x-1, y-1) [Top Left]: sent 1/16 to its Bottom Right (which is ME).
// Read Top Right (x+1, prevY)
if (x + 1 < width) {
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
errorFromAbove += e * (3.0 / 16.0);
}
// Read Top (x, prevY)
{
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
errorFromAbove += e * (5.0 / 16.0);
}
// Read Top Left (x-1, prevY)
if (x >= 1) {
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
errorFromAbove += e * (1.0 / 16.0);
}
// 2. Read Pixel
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
float4 colorRaw = inputTexture.read(mappedCoords);
float3 originalColor = colorRaw.rgb;
originalColor = originalColor + params.brightness;
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
if (params.isGrayscale > 0) {
float l = getLuma(originalColor);
originalColor = float3(l);
}
// 3. Combine
float3 pixelIn = originalColor + currentError + errorFromAbove;
// 4. Quantize
float3 pixelOut = float3(0.0);
float levels = max(1.0, params.colorDepth);
if (levels <= 1.0) levels = 2.0;
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
pixelOut = clamp(pixelOut, 0.0, 1.0);
// 5. Diff
float3 diff = pixelIn - pixelOut;
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
// 6. Propagate Horizontally (Serpentine R->L)
// In R->L scan, 'Right' neighbor in FS diagram is actually 'Left' neighbor in spatial.
// We push 7/16 to the next pixel we visit (x-1).
currentError = diff * (7.0 / 16.0);
}
}

View file

@ -10,6 +10,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case cluster4x4 = 4 case cluster4x4 = 4
case cluster8x8 = 5 case cluster8x8 = 5
case blueNoise = 6 case blueNoise = 6
case floydSteinberg = 7
var id: Int { rawValue } var id: Int { rawValue }
@ -22,6 +23,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case .cluster4x4: return "Cluster 4x4 (Vintage)" case .cluster4x4: return "Cluster 4x4 (Vintage)"
case .cluster8x8: return "Cluster 8x8 (Soft)" case .cluster8x8: return "Cluster 8x8 (Soft)"
case .blueNoise: return "Blue Noise / Organic (Best Quality)" case .blueNoise: return "Blue Noise / Organic (Best Quality)"
case .floydSteinberg: return "Floyd-Steinberg (Error Diffusion)"
} }
} }
} }

View file

@ -15,6 +15,7 @@ struct iDitherApp: App {
} }
} }
} }
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified) .windowToolbarStyle(.unified)
} }
} }