From cddbbec23370de52b3f3ff0f47e67dbaaba562bd Mon Sep 17 00:00:00 2001 From: ewen Date: Wed, 14 Jan 2026 23:39:55 +0100 Subject: [PATCH] V0.1.4, added Floyd Steinberg algorithm and revisited UI --- Sources/iDither/ContentView.swift | 159 +++++++++----- .../iDither/Renderer/MetalImageRenderer.swift | 79 +++++-- Sources/iDither/Resources/Shaders.metal | 206 ++++++++++++++++++ .../iDither/ViewModel/DitherViewModel.swift | 2 + Sources/iDither/iDitherApp.swift | 1 + 5 files changed, 379 insertions(+), 68 deletions(-) diff --git a/Sources/iDither/ContentView.swift b/Sources/iDither/ContentView.swift index d1d6565..da5c76a 100644 --- a/Sources/iDither/ContentView.swift +++ b/Sources/iDither/ContentView.swift @@ -9,7 +9,7 @@ struct ContentView: View { var body: some View { NavigationSplitView { SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting) - .navigationSplitViewColumnWidth(min: 260, ideal: 300) + .navigationSplitViewColumnWidth(min: 280, ideal: 300) } detail: { DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders) } @@ -100,66 +100,115 @@ struct SidebarView: View { @Binding var isExporting: Bool var body: some View { - Form { - Section("Dithering Algorithm") { - Picker("Algorithm", selection: $viewModel.selectedAlgorithm) { - ForEach(DitherAlgorithm.allCases) { algo in - Text(algo.name).tag(algo) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + // --- ALGORITHM SECTION --- + 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) - } - - Section("Pre-Processing") { - VStack(alignment: .leading) { - HStack { - Text("Brightness") - Spacer() - Text(String(format: "%.2f", viewModel.brightness)) - .monospacedDigit() - .foregroundStyle(.secondary) - } - Slider(value: $viewModel.brightness, in: -1.0...1.0) - } - - VStack(alignment: .leading) { - HStack { - Text("Contrast") - Spacer() - Text(String(format: "%.2f", viewModel.contrast)) - .monospacedDigit() - .foregroundStyle(.secondary) - } - Slider(value: $viewModel.contrast, in: 0.0...4.0) - } - - 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) - } + Divider() + .padding(.vertical, 8) + + // --- QUANTIZATION SECTION --- + VStack(alignment: .leading, spacing: 16) { + Text("QUANTIZATION") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + + VStack(spacing: 6) { + HStack { + Text("Color Depth") + .font(.system(size: 13)) + Spacer() + Text("\(Int(viewModel.colorDepth))") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0) + .tint(.accentColor) + } + } + + Spacer() } + .padding(20) } - .formStyle(.grouped) - .padding(.vertical) + .background(.regularMaterial) // Native material background + .ignoresSafeArea(edges: .top) // Fix for titlebar gap .navigationTitle("iDither") + .frame(minWidth: 280, maxWidth: .infinity, alignment: .leading) .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button(action: { isImporting = true }) { diff --git a/Sources/iDither/Renderer/MetalImageRenderer.swift b/Sources/iDither/Renderer/MetalImageRenderer.swift index f1975aa..278ed4e 100644 --- a/Sources/iDither/Renderer/MetalImageRenderer.swift +++ b/Sources/iDither/Renderer/MetalImageRenderer.swift @@ -15,6 +15,8 @@ final class MetalImageRenderer: Sendable { private let device: MTLDevice private let commandQueue: MTLCommandQueue private let pipelineState: MTLComputePipelineState + private let pipelineStateFS_Pass1: MTLComputePipelineState? + private let pipelineStateFS_Pass2: MTLComputePipelineState? init?() { guard let device = MTLCreateSystemDefaultDevice(), @@ -29,6 +31,15 @@ final class MetalImageRenderer: Sendable { do { 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 { print("Failed to create pipeline state: \(error)") return nil @@ -60,26 +71,68 @@ final class MetalImageRenderer: Sendable { return nil } - computeEncoder.setComputePipelineState(pipelineState) - computeEncoder.setTexture(inputTexture, index: 0) - computeEncoder.setTexture(outputTexture, index: 1) - var params = params - computeEncoder.setBytes(¶ms, length: MemoryLayout.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) + if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 { + // FLOYD-STEINBERG MULTI-PASS + + // 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(¶ms, length: MemoryLayout.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(¶ms, length: MemoryLayout.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(¶ms, length: MemoryLayout.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.endEncoding() commandBuffer.commit() 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) } diff --git a/Sources/iDither/Resources/Shaders.metal b/Sources/iDither/Resources/Shaders.metal index 517bedf..d31bb97 100644 --- a/Sources/iDither/Resources/Shaders.metal +++ b/Sources/iDither/Resources/Shaders.metal @@ -165,5 +165,211 @@ kernel void ditherShader(texture2d inputTexture [[texture(0 } } + 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 inputTexture [[texture(0)]], + texture2d outputTexture [[texture(1)]], + texture2d errorTexture [[texture(2)]], + constant RenderParameters ¶ms [[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 inputTexture [[texture(0)]], + texture2d outputTexture [[texture(1)]], + texture2d errorTexture [[texture(2)]], // Contains diffs from Pass 1 + constant RenderParameters ¶ms [[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); + } +} + diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift index 66f0cbd..4049aef 100644 --- a/Sources/iDither/ViewModel/DitherViewModel.swift +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -10,6 +10,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable { case cluster4x4 = 4 case cluster8x8 = 5 case blueNoise = 6 + case floydSteinberg = 7 var id: Int { rawValue } @@ -22,6 +23,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable { case .cluster4x4: return "Cluster 4x4 (Vintage)" case .cluster8x8: return "Cluster 8x8 (Soft)" case .blueNoise: return "Blue Noise / Organic (Best Quality)" + case .floydSteinberg: return "Floyd-Steinberg (Error Diffusion)" } } } diff --git a/Sources/iDither/iDitherApp.swift b/Sources/iDither/iDitherApp.swift index 19e5869..c330fc7 100644 --- a/Sources/iDither/iDitherApp.swift +++ b/Sources/iDither/iDitherApp.swift @@ -15,6 +15,7 @@ struct iDitherApp: App { } } } + .windowStyle(.hiddenTitleBar) .windowToolbarStyle(.unified) } }