First version of the app
This commit is contained in:
parent
f809f16cf4
commit
77d9acfb7a
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
25
Package.swift
Normal 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")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
186
Sources/iDither/ContentView.swift
Normal file
186
Sources/iDither/ContentView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
113
Sources/iDither/Renderer/MetalImageRenderer.swift
Normal file
113
Sources/iDither/Renderer/MetalImageRenderer.swift
Normal 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(¶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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
87
Sources/iDither/Resources/Shaders.metal
Normal file
87
Sources/iDither/Resources/Shaders.metal
Normal 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 ¶ms [[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);
|
||||
}
|
||||
80
Sources/iDither/ViewModel/DitherViewModel.swift
Normal file
80
Sources/iDither/ViewModel/DitherViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
13
Sources/iDither/iDitherApp.swift
Normal file
13
Sources/iDither/iDitherApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue