First version of the app

This commit is contained in:
ewen 2026-01-14 01:14:08 +01:00
parent f809f16cf4
commit 77d9acfb7a
7 changed files with 532 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# --- macOS ---
.DS_Store
.AppleDouble
.LSOverride
._*
# --- Xcode ---
DerivedData/
build/
*.moved-aside
*.xccheckout
*.xcscmblueprint
*.xcuserdata/
*.xcworkspace/xcuserdata/
# --- Swift Package Manager (SPM) ---
.build/
.swiftpm/
Packages/
# --- Metal ---
*.metallib
*.air
# --- Autres ---
*.swp
*~

25
Package.swift Normal file
View file

@ -0,0 +1,25 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "iDither",
platforms: [
.macOS(.v14)
],
products: [
.executable(
name: "iDither",
targets: ["iDither"]
),
],
targets: [
.executableTarget(
name: "iDither",
resources: [
.process("Resources")
]
),
]
)

View file

@ -0,0 +1,186 @@
import SwiftUI
struct ContentView: View {
@State private var viewModel = DitherViewModel()
@State private var isExporting = false
var body: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel, openFile: openFile, saveFile: saveFile)
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
} detail: {
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
}
.onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
}
private func openFile() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
if panel.runModal() == .OK {
if let url = panel.url {
viewModel.load(url: url)
}
}
}
private func saveFile() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "dithered_image.png"
if panel.runModal() == .OK {
if let url = panel.url {
viewModel.exportResult(to: url)
}
}
}
private func loadFromProviders(_ providers: [NSItemProvider]) -> Bool {
guard let provider = providers.first else { return false }
if provider.canLoadObject(ofClass: URL.self) {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url = url {
DispatchQueue.main.async {
viewModel.load(url: url)
}
}
}
return true
}
return false
}
}
struct SidebarView: View {
@Bindable var viewModel: DitherViewModel
var openFile: () -> Void
var saveFile: () -> Void
var body: some View {
Form {
Section("Dithering Algorithm") {
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
ForEach(DitherAlgorithm.allCases) { algo in
Text(algo.name).tag(algo)
}
}
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)
}
}
}
.formStyle(.grouped)
.padding(.vertical)
.navigationTitle("iDither")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: openFile) {
Label("Import", systemImage: "square.and.arrow.down")
}
.help("Import Image")
Button(action: saveFile) {
Label("Export", systemImage: "square.and.arrow.up")
}
.disabled(viewModel.processedImage == nil)
.help("Export PNG")
}
}
}
}
struct DetailView: View {
var viewModel: DitherViewModel
var loadFromProviders: ([NSItemProvider]) -> Bool
var body: some View {
ZStack {
CheckeredBackground()
.ignoresSafeArea()
if let processedImage = viewModel.processedImage {
Image(decorative: processedImage, scale: 1.0, orientation: .up)
.resizable()
.aspectRatio(contentMode: .fit)
.padding(40)
.shadow(radius: 10)
} else {
ContentUnavailableView {
Label("No Image Selected", systemImage: "photo.badge.plus")
} description: {
Text("Drag and drop an image here to start dithering.")
}
}
}
.onDrop(of: [.image], isTargeted: nil) { providers in
loadFromProviders(providers)
}
}
}
struct CheckeredBackground: View {
var body: some View {
Canvas { context, size in
let squareSize: CGFloat = 20
let rows = Int(ceil(size.height / squareSize))
let cols = Int(ceil(size.width / squareSize))
for row in 0..<rows {
for col in 0..<cols {
if (row + col) % 2 == 0 {
let rect = CGRect(x: CGFloat(col) * squareSize,
y: CGFloat(row) * squareSize,
width: squareSize,
height: squareSize)
context.fill(Path(rect), with: .color(.gray.opacity(0.15)))
}
}
}
}
.background(Color.white)
}
}

View file

@ -0,0 +1,113 @@
import Metal
import MetalKit
import CoreGraphics
struct RenderParameters {
var brightness: Float
var contrast: Float
var pixelScale: Float
var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
var isGrayscale: Int32 // 0: false, 1: true
}
final class MetalImageRenderer: Sendable {
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let pipelineState: MTLComputePipelineState
init?() {
guard let device = MTLCreateSystemDefaultDevice(),
let commandQueue = device.makeCommandQueue(),
let library = try? device.makeDefaultLibrary(bundle: Bundle.module),
let function = library.makeFunction(name: "ditherShader") else {
return nil
}
self.device = device
self.commandQueue = commandQueue
do {
self.pipelineState = try device.makeComputePipelineState(function: function)
} catch {
print("Failed to create pipeline state: \(error)")
return nil
}
}
func render(input: CGImage, params: RenderParameters) -> CGImage? {
let textureLoader = MTKTextureLoader(device: device)
// Load input texture
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
return nil
}
// Create output texture
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
return nil
}
// Encode command
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
return nil
}
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
var params = params
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.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)
}
private func createCGImage(from texture: MTLTexture) -> CGImage? {
let width = texture.width
let height = texture.height
let rowBytes = width * 4
let length = rowBytes * height
var bytes = [UInt8](repeating: 0, count: length)
let region = MTLRegionMake2D(0, 0, width, height)
texture.getBytes(&bytes, bytesPerRow: rowBytes, from: region, mipmapLevel: 0)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let provider = CGDataProvider(data: Data(bytes: bytes, count: length) as CFData) else { return nil }
return CGImage(width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: rowBytes,
space: colorSpace,
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent)
}
}

View file

@ -0,0 +1,87 @@
#include <metal_stdlib>
using namespace metal;
struct RenderParameters {
float brightness;
float contrast;
float pixelScale;
int algorithm; // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
int isGrayscale;
};
// Bayer 8x8 Matrix
constant float bayer8x8[8][8] = {
{ 0.0/64.0, 32.0/64.0, 8.0/64.0, 40.0/64.0, 2.0/64.0, 34.0/64.0, 10.0/64.0, 42.0/64.0 },
{48.0/64.0, 16.0/64.0, 56.0/64.0, 24.0/64.0, 50.0/64.0, 18.0/64.0, 58.0/64.0, 26.0/64.0 },
{12.0/64.0, 44.0/64.0, 4.0/64.0, 36.0/64.0, 14.0/64.0, 46.0/64.0, 6.0/64.0, 38.0/64.0 },
{60.0/64.0, 28.0/64.0, 52.0/64.0, 20.0/64.0, 62.0/64.0, 30.0/64.0, 54.0/64.0, 22.0/64.0 },
{ 3.0/64.0, 35.0/64.0, 11.0/64.0, 43.0/64.0, 1.0/64.0, 33.0/64.0, 9.0/64.0, 41.0/64.0 },
{51.0/64.0, 19.0/64.0, 59.0/64.0, 27.0/64.0, 49.0/64.0, 17.0/64.0, 57.0/64.0, 25.0/64.0 },
{15.0/64.0, 47.0/64.0, 7.0/64.0, 39.0/64.0, 13.0/64.0, 45.0/64.0, 5.0/64.0, 37.0/64.0 },
{63.0/64.0, 31.0/64.0, 55.0/64.0, 23.0/64.0, 61.0/64.0, 29.0/64.0, 53.0/64.0, 21.0/64.0 }
};
// Bayer 4x4 Matrix
constant float bayer4x4[4][4] = {
{ 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0 },
{12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0 },
{ 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0 },
{15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 }
};
kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0)]],
texture2d<float, access::write> outputTexture [[texture(1)]],
constant RenderParameters &params [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
if (gid.x >= outputTexture.get_width() || gid.y >= outputTexture.get_height()) {
return;
}
// 1. Pixelation (Downsampling)
float scale = max(1.0, params.pixelScale);
uint2 sourceCoord = uint2(floor(float(gid.x) / scale) * scale, floor(float(gid.y) / scale) * scale);
// Clamp to texture bounds
sourceCoord.x = min(sourceCoord.x, inputTexture.get_width() - 1);
sourceCoord.y = min(sourceCoord.y, inputTexture.get_height() - 1);
float4 color = inputTexture.read(sourceCoord);
// 2. Color Adjustment (Brightness & Contrast)
float3 rgb = color.rgb;
rgb = rgb + params.brightness;
rgb = (rgb - 0.5) * params.contrast + 0.5;
// Grayscale conversion (Luma)
float luma = dot(rgb, float3(0.299, 0.587, 0.114));
if (params.isGrayscale > 0) {
rgb = float3(luma);
}
// 3. Dithering
if (params.algorithm == 1) { // Bayer 8x8
// Map current pixel to matrix coordinates
// We use the original gid (screen coordinates) for the matrix pattern to keep it stable across pixelation blocks?
// OR we use the sourceCoord (pixelated coordinates) to make the dither pattern scale with the pixels?
// Usually, dither is applied at screen resolution, but for "retro pixel art" look, the dither pattern usually matches the "big pixel" size.
// Let's try using the scaled coordinate index: sourceCoord / scale
uint x = uint(sourceCoord.x / scale) % 8;
uint y = uint(sourceCoord.y / scale) % 8;
float threshold = bayer8x8[y][x];
// Apply threshold
rgb = (luma > threshold) ? float3(1.0) : float3(0.0);
} else if (params.algorithm == 2) { // Bayer 4x4
uint x = uint(sourceCoord.x / scale) % 4;
uint y = uint(sourceCoord.y / scale) % 4;
float threshold = bayer4x4[y][x];
rgb = (luma > threshold) ? float3(1.0) : float3(0.0);
}
outputTexture.write(float4(rgb, color.a), gid);
}

View file

@ -0,0 +1,80 @@
import SwiftUI
import Combine
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case none = 0
case bayer8x8 = 1
case bayer4x4 = 2
var id: Int { rawValue }
var name: String {
switch self {
case .none: return "No Dither"
case .bayer8x8: return "Bayer 8x8"
case .bayer4x4: return "Bayer 4x4"
}
}
}
@MainActor
@Observable
class DitherViewModel {
var inputImage: CGImage?
var processedImage: CGImage?
var brightness: Float = 0.0
var contrast: Float = 1.0
var pixelScale: Float = 1.0
var selectedAlgorithm: DitherAlgorithm = .none
var isGrayscale: Bool = false
private let renderer: MetalImageRenderer?
private var processingTask: Task<Void, Never>?
init() {
self.renderer = MetalImageRenderer()
}
func load(url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
print("Failed to load image")
return
}
self.inputImage = cgImage
processImage()
}
func processImage() {
guard let inputImage = inputImage, let renderer = renderer else { return }
processingTask?.cancel()
let params = RenderParameters(
brightness: brightness,
contrast: contrast,
pixelScale: pixelScale,
algorithm: Int32(selectedAlgorithm.rawValue),
isGrayscale: isGrayscale ? 1 : 0
)
processingTask = Task.detached(priority: .userInitiated) { [inputImage, renderer, params] in
if let result = renderer.render(input: inputImage, params: params) {
await MainActor.run {
self.processedImage = result
}
}
}
}
func exportResult(to url: URL) {
guard let processedImage = processedImage,
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
return
}
CGImageDestinationAddImage(destination, processedImage, nil)
CGImageDestinationFinalize(destination)
}
}

View file

@ -0,0 +1,13 @@
import SwiftUI
@main
struct iDitherApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// Style de fenêtre standard macOS
.windowStyle(.titleBar)
.windowToolbarStyle(.unified)
}
}