V0.1.4, added Floyd Steinberg algorithm and revisited UI
This commit is contained in:
parent
d2b78668c3
commit
cddbbec233
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
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<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(¶ms, 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(¶ms, 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.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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,5 +165,211 @@ kernel void ditherShader(texture2d<float, access::read> 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<float, access::read> inputTexture [[texture(0)]],
|
||||
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||
texture2d<float, access::write> 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<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 ¶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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ struct iDitherApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue