├── .gitignore ├── config.nims ├── clutter.nimble ├── src ├── lib │ ├── palettes.nim │ ├── cli.nim │ ├── interpolation.nim │ └── vips.nim └── clutter.nim ├── examples └── palettes.kdl └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | clutter 2 | -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | --define: 2 | release 3 | --deepcopy: 4 | on 5 | -------------------------------------------------------------------------------- /clutter.nimble: -------------------------------------------------------------------------------- 1 | version = "0.1.0" 2 | author = "Licorice" 3 | description = "Fast as Fuck interpolated LUT generator and applier" 4 | license = "GPL-3.0-only" 5 | srcDir = "src" 6 | bin = @["clutter"] 7 | 8 | # Dependencies 9 | requires "nim >= 2.0.0" 10 | requires "kdl >= 2.0.3" 11 | requires "therapist >= 0.3.0" 12 | 13 | # More Dependencies 14 | import distros 15 | if detectOs(Ubuntu) or detectOs(Debian): 16 | foreignDep "libvips" 17 | foreignDep "libvips-tools" 18 | else: 19 | foreignDep "libvips" 20 | -------------------------------------------------------------------------------- /src/lib/palettes.nim: -------------------------------------------------------------------------------- 1 | import kdl, kdl/decoder 2 | import tables, os, strutils 3 | 4 | const ExamplePalettes* = staticRead("../../examples/palettes.kdl") 5 | 6 | type PaletteFile* = Table[string, seq[string]] 7 | 8 | let paletteFilePath* = getConfigDir() / "clutter" / "palettes.kdl" 9 | 10 | proc installPalettes*() {.inline.} = 11 | if not fileExists(paletteFilePath): 12 | echo "Initialized Palettes at " & paletteFilePath 13 | createDir(getConfigDir() / "clutter") 14 | writeFile(paletteFilePath, ExamplePalettes) 15 | 16 | proc parsePalettes*(): PaletteFile {.inline.} = 17 | let palettes = parseKdl paletteFilePath.readFile() 18 | return palettes.decodeKdl(PaletteFile) 19 | 20 | proc addPalette*(name: string, colors: seq[string]) = 21 | if not (name in parsePalettes()): 22 | var res = name 23 | for color in colors: 24 | res &= " \"" & color & "\"" 25 | writeFile(paletteFilePath, paletteFilePath.readFile() & "\n" & res) 26 | else: 27 | echo "Palette $# already exists" % [name] 28 | -------------------------------------------------------------------------------- /src/lib/cli.nim: -------------------------------------------------------------------------------- 1 | import strformat, therapist 2 | 3 | const versionNum* = staticRead(fmt"../../clutter.nimble").splitLines()[0].split("=")[1] 4 | .strip() 5 | .replace("\"", "") 6 | 7 | const release = defined(release) 8 | 9 | var bType: string 10 | 11 | if release: 12 | bType = "release" 13 | else: 14 | bType = "debug" 15 | 16 | let pals = toSeq parsePalettes().keys 17 | 18 | let add = ( 19 | help: newHelpArg(), 20 | name: newStringArg(@[""], help = "The name of the palette"), 21 | colors: newStringArg( 22 | @[""], 23 | multi = true, 24 | help = "The colors in the palette (space-seperated list of hex codes)", 25 | ), 26 | ) 27 | 28 | let palettescmd = ( 29 | help: newHelpArg(), 30 | list: 31 | newMessageCommandArg(@["list", "ls"], pals.join("\n"), help = "List all palettes"), 32 | add: newCommandArg(@["add"], add, help = "Create a palette"), 33 | ) 34 | 35 | let args* = ( 36 | palettes: newCommandArg(@["palettes", "p"], palettescmd, help = "Palete Management"), 37 | output: 38 | newStringArg(@["--output", "-o"], help = "The file to write to", optional = true), 39 | input: newPathArg(@["--input", "-i"], help = "The file to convert"), 40 | strength: 41 | newFloatArg(@["--strength", "-s"], help = "The LUT strength", defaultVal = 0.375), 42 | interp: newIntArg( 43 | @["--interpolate", "-I"], help = "How many colors to generate", defaultVal = 32 44 | ), 45 | palette: newStringArg( 46 | @[""], help = "The palette to use", multi = true, optional = true 47 | ), 48 | version: newMessageArg( 49 | @["--version", "-v"], 50 | "clutter v$#\nrelease: $#" % @[versionNum, $release], 51 | help = "Show version information", 52 | ), 53 | help: newHelpArg(), 54 | ) 55 | 56 | args.parseOrQuit( 57 | prolog = "cluter - fast as fuck interpolated LUT generator and applier", 58 | command = "clutter", 59 | ) 60 | 61 | export therapist 62 | -------------------------------------------------------------------------------- /examples/palettes.kdl: -------------------------------------------------------------------------------- 1 | decayce "#f1d8a5" "#ecd3a0" "#90ceaa" "#0f111a" "#c296eb" "#151720" "#0b0d16" "#11131c" "#dd6777" "#1c1e27" "#86aaec" "#a5b6cf" "#93cee9" "#0d0f18" "#cbced3" 2 | iceberg "#e27878" "#91acd1" "#e9b189" "#95c4ce" "#c6c8d1" "#ada0d3" "#84a0c6" "#a093c7" "#89b8c2" "#6b7089" "#161821" "#b4be82" "#c0ca8e" "#e2a478" "#1e2132" "#d2d4de" "#e98989" 3 | everforest "#859289" "#e67e80" "#7a8478" "#7fbbb3" "#edeece" "#d3c6aa" "#f8f0dc" "#e6e9c4" "#efead4" "#d699b6" "#9da9a0" "#dbbc7f" "#b9c0ab" "#f9e0d4" "#e7ede5" "#e9e5cf" "#f6e9c9" "#dcd8c4" "#e69875" "#a7c080" "#83c092" "#e1ddc9" 4 | ashes "#ADB3BA" "#565E65" "#C7CCD1" "#95AEC7" "#C795AE" "#C79595" "#C7AE95" "#393F45" "#95C7AE" "#C7C795" "#AEC795" "#DFE2E5" "#1C2023" "#F3F4F5" "#747C84" "#AE95C7" 5 | ok "#e15858" "#b057cb" "#b5cdbd" "#d2daf4" "#0b0d10" "#3580c1" "#daa640" "#4796d9" "#ac45cc" "#51d5c3" "#dfa93f" "#636c7e" "#c44949" "#3fcc6c" "#53db7f" "#36b5a4" 6 | darkdecay "#15191e" "#70a5eb" "#13171c" "#0e1217" "#101419" "#74bee9" "#485263" "#e9a180" "#c68aee" "#7ddac5" "#1a1e23" "#e05f65" "#242931" "#4d5768" "#73c291" "#f1cf8a" "#b6beca" 7 | nord "#434c5e" "#d08770" "#5e81ac" "#3b4252" "#b48ead" "#81a1c1" "#e5e9f0" "#bf616a" "#88c0d0" "#4c566a" "#d8dee9" "#a3be8c" "#2e3440" "#8fbcbb" "#eceff4" "#ebcb8b" 8 | articblush "#040c16" "#d9d7d6" "#92bbed" "#cfebec" "#bdd6f4" "#FF7377" "#e2d06a" "#323949" "#E6676B" "#b3ffff" "#f9ecf7" "#A2E4B8" "#3d3e51" "#AAF0C1" "#c2cae2" "#ecc6e8" "#80ffff" "#cce9ea" "#edf7f8" "#eadd94" 9 | catppuccin "#C6D0F5" "#B4BEFE" "#F5E0DC" "#36374A" "#12121C" "#90C1FB" "#AEB7D9" "#F2CDCD" "#4E5167" "#FAB387" "#666A83" "#07070A" "#EBA0AC" "#89DCEB" "#1E1E2E" "#7E84A0" "#94E2D5" "#969DBC" "#F5C2E7" "#CBA6F7" "#A6E3A1" "#F38BA8" "#74C7EC" "#F9E2AF" 10 | decay "#70a5eb" "#74bee9" "#485263" "#e9a180" "#c68aee" "#171B20" "#7ddac5" "#1a1e24" "#15191d" "#1a1e23" "#21262e" "#e05f65" "#242931" "#4d5768" "#73c291" "#f1cf8a" "#b6beca" 11 | tokyonight "#f7768e" "#7aa2f7" "#2ac3de" "#9ece6a" "#cfc9c2" "#7dcfff" "#bb9af7" "#565f89" "#9aa5ce" "#ff9e64" "#e0af68" "#414868" "#b4f9f8" "#24283b" "#a9b1d6" "#c0caf5" "#73daca" "#1a1b26" 12 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Clutter 2 | Clutter is a command-line image processing tool written in Nim that applies cinematic color grading using customizable color palettes to create and apply smooth LookUp Tables (LUTs). 3 | ## Examples 4 | | **Original** | ![clutter](https://github.com/user-attachments/assets/7e2b86cb-a531-4031-984c-3367b8982b74) | 5 | | --------------- | ------------------------------------------------------------------------------------------------------ | 6 | | **Nord** | ![nord-clutter](https://github.com/user-attachments/assets/e0223b0d-783d-4dbd-9dcf-42be1e35d27a) | 7 | | **Catppuccin** | ![catppuccin-clutter](https://github.com/user-attachments/assets/4a80774a-f863-47a4-8342-70abda49fd08) | 8 | | **Tokyo Night** | ![tokyo-clutter](https://github.com/user-attachments/assets/f8c8c7fb-d492-4e6f-a08f-1b8439c928ac) | 9 | 10 | 11 | ## Installation 12 | ### Dependencies 13 | **Build Dependencies**: 14 | - nim 15 | - nimble (should be included with your nim install) 16 | 17 | **Program Dependencies**: 18 | - libvips 19 | ### Building 20 | Clutter can be built and installed from Nim's package manager, nimble. 21 | ```sh 22 | nimble install "gh:arashi-software/clutter@#HEAD" 23 | ``` 24 | 25 | or you can build from source 26 | ```sh 27 | git clone https://github.com/arashi-software/clutter 28 | cd clutter 29 | nimble build 30 | cp clutter ~/.local/bin/ 31 | ``` 32 | 33 | ## Usage 34 | You can easily generate a LUT like this 35 | ```sh 36 | clutter -i image.png -o out-image.png decay 37 | ``` 38 | 39 | You can check the configured palettes with 40 | ```sh 41 | clutter p ls 42 | ``` 43 | 44 | You can create a new palette using clutter as well 45 | ```sh 46 | clutter p add sapphy "#6A6B69 #232421 #B0F601 #A8CF4A #FEFEFE #EEEEEE #FF715B #E88873 #F991CC #D8829D #AFCBFF #85BDBF #D7F9FF #74D3AE #F3E9D2 #F9FBB2 #FFB17A #DE6E4B" 47 | 48 | # or from a file with space seperated hex codes 49 | clutter p add sapphy "$(cat ~/sapphy.txt)" 50 | ``` 51 | 52 | Or you can even skip the palette system altogether and manually specify the colors 53 | ```sh 54 | clutter -i image.png -o out-image.png "#6A6B69 #232421 #B0F601 #A8CF4A #FEFEFE #EEEEEE #FF715B #E88873 #F991CC #D8829D #AFCBFF #85BDBF #D7F9FF #74D3AE #F3E9D2 #F9FBB2 #FFB17A #DE6E4B" 55 | 56 | # or likewise 57 | clutter -i image.png -o out-image.png "$(cat ~/sapphy.txt)" 58 | ``` 59 | 60 | To see the full range of options and commands 61 | ```sh 62 | clutter -h 63 | ``` 64 | ## Todo 65 | - [ ] Binaries 66 | - [ ] Better packaging 67 | - [ ] More palettes 68 | - [ ] Optimizations 69 | -------------------------------------------------------------------------------- /src/lib/interpolation.nim: -------------------------------------------------------------------------------- 1 | type LabColor = array[3, float] 2 | 3 | proc applyGammaCorrection(val: float): float = 4 | if val > 0.04045: 5 | pow((val + 0.055) / 1.055, 2.4) 6 | else: 7 | val / 12.92 8 | 9 | proc applyInverseGamma(val: float): float = 10 | if val > 0.0031308: 11 | 1.055 * pow(val, 1.0 / 2.4) - 0.055 12 | else: 13 | 12.92 * val 14 | 15 | proc xyzToLabHelper(t: float): float = 16 | if t > 0.008856: 17 | pow(t, 1.0 / 3.0) 18 | else: 19 | (7.787 * t + 16.0 / 116.0) 20 | 21 | proc labToXyzHelper(t: float): float = 22 | if t > 0.206893: 23 | pow(t, 3.0) 24 | else: 25 | (t - 16.0 / 116.0) / 7.787 26 | 27 | proc rgbToLab(rgb: array[3, float]): LabColor = 28 | let r = applyGammaCorrection(rgb[0]) 29 | let g = applyGammaCorrection(rgb[1]) 30 | let b = applyGammaCorrection(rgb[2]) 31 | 32 | let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375 33 | let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750 34 | let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041 35 | 36 | let xn = x / 0.95047 37 | let yn = y / 1.00000 38 | let zn = z / 1.08883 39 | 40 | let fx = xyzToLabHelper(xn) 41 | let fy = xyzToLabHelper(yn) 42 | let fz = xyzToLabHelper(zn) 43 | 44 | let L = 116.0 * fy - 16.0 45 | let a = 500.0 * (fx - fy) 46 | let b_lab = 200.0 * (fy - fz) 47 | 48 | [L / 100.0, (a + 128.0) / 255.0, (b_lab + 128.0) / 255.0] 49 | 50 | proc labToRgb(lab: LabColor): array[3, float] = 51 | let L = lab[0] * 100.0 52 | let a = lab[1] * 255.0 - 128.0 53 | let b_lab = lab[2] * 255.0 - 128.0 54 | 55 | let fy = (L + 16.0) / 116.0 56 | let fx = a / 500.0 + fy 57 | let fz = fy - b_lab / 200.0 58 | 59 | let x = labToXyzHelper(fx) * 0.95047 60 | let y = labToXyzHelper(fy) * 1.00000 61 | let z = labToXyzHelper(fz) * 1.08883 62 | 63 | let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314 64 | let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560 65 | let b = x * 0.0556434 + y * -0.2040259 + z * 1.0572252 66 | 67 | [ 68 | clamp(applyInverseGamma(r), 0.0, 1.0), 69 | clamp(applyInverseGamma(g), 0.0, 1.0), 70 | clamp(applyInverseGamma(b), 0.0, 1.0), 71 | ] 72 | 73 | proc calculateLabDistance(color1, color2: LabColor): float = 74 | sqrt( 75 | pow(color1[0] - color2[0], 2) + pow(color1[1] - color2[1], 2) + 76 | pow(color1[2] - color2[2], 2) 77 | ) 78 | 79 | proc isColorSufficientlyDifferent( 80 | newColor: LabColor, existingColors: seq[LabColor], minDistance: float = 0.05 81 | ): bool = 82 | for existing in existingColors: 83 | if calculateLabDistance(newColor, existing) < minDistance: 84 | return false 85 | true 86 | 87 | proc interpolateLabColors(color1, color2: LabColor, t: float): LabColor = 88 | [ 89 | color1[0] * (1.0 - t) + color2[0] * t, 90 | color1[1] * (1.0 - t) + color2[1] * t, 91 | color1[2] * (1.0 - t) + color2[2] * t, 92 | ] 93 | 94 | proc generateLabVariation( 95 | baseColor: LabColor, variationType: int, intensity: float = 0.1 96 | ): LabColor = 97 | result = baseColor 98 | 99 | case variationType mod 8 100 | of 0: 101 | result[0] = clamp(baseColor[0] + intensity, 0.0, 1.0) 102 | of 1: 103 | result[0] = clamp(baseColor[0] - intensity, 0.0, 1.0) 104 | of 2: 105 | result[1] = clamp(baseColor[1] + intensity, 0.0, 1.0) 106 | of 3: 107 | result[1] = clamp(baseColor[1] - intensity, 0.0, 1.0) 108 | of 4: 109 | result[2] = clamp(baseColor[2] + intensity, 0.0, 1.0) 110 | of 5: 111 | result[2] = clamp(baseColor[2] - intensity, 0.0, 1.0) 112 | of 6: 113 | result[0] = clamp(baseColor[0] + intensity * 0.5, 0.0, 1.0) 114 | result[1] = clamp(baseColor[1] + intensity * 0.3, 0.0, 1.0) 115 | of 7: 116 | result[0] = clamp(baseColor[0] - intensity * 0.5, 0.0, 1.0) 117 | result[2] = clamp(baseColor[2] - intensity * 0.3, 0.0, 1.0) 118 | else: 119 | discard 120 | 121 | proc findBestCandidateColor(existingColors: seq[LabColor]): LabColor = 122 | var maxDistance = 0.0 123 | var bestCandidate = existingColors[0] 124 | 125 | for l in 0 .. 9: 126 | for a in 0 .. 9: 127 | for b in 0 .. 9: 128 | let candidate: LabColor = [l.float / 9.0, a.float / 9.0, b.float / 9.0] 129 | 130 | var minDist = 2.0 131 | for existing in existingColors: 132 | let dist = calculateLabDistance(candidate, existing) 133 | minDist = min(minDist, dist) 134 | 135 | if minDist > maxDistance: 136 | maxDistance = minDist 137 | bestCandidate = candidate 138 | 139 | bestCandidate 140 | 141 | proc addInterpolatedColors( 142 | labPalette: seq[LabColor], expandedLab: var seq[LabColor], targetCount: int 143 | ) = 144 | if labPalette.len < 2: 145 | return 146 | 147 | var sortedIndices = toSeq(0 ..< labPalette.len) 148 | sortedIndices.sort( 149 | proc(a, b: int): int = 150 | if labPalette[a][0] < labPalette[b][0]: 151 | -1 152 | elif labPalette[a][0] > labPalette[b][0]: 153 | 1 154 | else: 155 | 0 156 | ) 157 | 158 | let interpolationsNeeded = min(targetCount - labPalette.len, (labPalette.len - 1) * 3) 159 | let stepsPerPair = max(1, interpolationsNeeded div (labPalette.len - 1)) 160 | 161 | for i in 0 ..< (sortedIndices.len - 1): 162 | let idx1 = sortedIndices[i] 163 | let idx2 = sortedIndices[i + 1] 164 | let color1 = labPalette[idx1] 165 | let color2 = labPalette[idx2] 166 | 167 | for j in 1 .. stepsPerPair: 168 | if expandedLab.len >= targetCount: 169 | break 170 | let t = j.float / (stepsPerPair + 1).float 171 | let interpolated = interpolateLabColors(color1, color2, t) 172 | expandedLab.add(interpolated) 173 | 174 | proc addVariationColors( 175 | labPalette: seq[LabColor], expandedLab: var seq[LabColor], targetCount: int 176 | ) = 177 | var variationIndex = 0 178 | 179 | while expandedLab.len < targetCount and variationIndex < labPalette.len * 8: 180 | let baseIdx = variationIndex mod labPalette.len 181 | let baseColor = labPalette[baseIdx] 182 | let variationType = variationIndex div labPalette.len 183 | 184 | let newColor = generateLabVariation(baseColor, variationType) 185 | 186 | if isColorSufficientlyDifferent(newColor, expandedLab): 187 | expandedLab.add(newColor) 188 | 189 | variationIndex += 1 190 | 191 | proc addUniformSampledColors(expandedLab: var seq[LabColor], targetCount: int) = 192 | while expandedLab.len < targetCount: 193 | let bestNewColor = findBestCandidateColor(expandedLab) 194 | 195 | if calculateLabDistance(bestNewColor, expandedLab[0]) > 0.01: 196 | expandedLab.add(bestNewColor) 197 | else: 198 | let baseIdx = expandedLab.len mod (expandedLab.len div 2 + 1) 199 | let baseColor = expandedLab[baseIdx] 200 | let variation: LabColor = [ 201 | clamp(baseColor[0] + (expandedLab.len.float * 0.01) mod 0.2 - 0.1, 0.0, 1.0), 202 | clamp(baseColor[1] + (expandedLab.len.float * 0.007) mod 0.14 - 0.07, 0.0, 1.0), 203 | clamp(baseColor[2] + (expandedLab.len.float * 0.013) mod 0.26 - 0.13, 0.0, 1.0), 204 | ] 205 | expandedLab.add(variation) 206 | 207 | proc convertLabPaletteToHex(labColors: seq[LabColor]): seq[string] = 208 | result = @[] 209 | for labColor in labColors: 210 | let rgb = labToRgb(labColor) 211 | let color = rgbToColor(rgb) 212 | result.add(colorToHex(color)) 213 | 214 | proc expandPalette(originalColors: seq[string], targetCount: int = 256): seq[string] = 215 | if originalColors.len == 0: 216 | raise newException(ValueError, "Original palette is empty") 217 | 218 | if originalColors.len >= targetCount: 219 | return originalColors[0 ..< targetCount] 220 | 221 | let originalPalette = originalColors.map(hexToColor) 222 | 223 | var labPalette: seq[LabColor] = @[] 224 | for color in originalPalette: 225 | let rgb = colorToRGB(color) 226 | labPalette.add(rgbToLab(rgb)) 227 | 228 | var expandedLab = labPalette 229 | 230 | addInterpolatedColors(labPalette, expandedLab, targetCount) 231 | addVariationColors(labPalette, expandedLab, targetCount) 232 | addUniformSampledColors(expandedLab, targetCount) 233 | 234 | result = convertLabPaletteToHex(expandedLab[0 ..< targetCount]) 235 | echo "Expanded palette from ", 236 | originalColors.len, " to ", result.len, " colors using LAB color space" 237 | -------------------------------------------------------------------------------- /src/clutter.nim: -------------------------------------------------------------------------------- 1 | import std/[sequtils, math, algorithm, os, tables, hashes] 2 | import lib/palettes 3 | include lib/vips 4 | 5 | type Color = object 6 | r, g, b: uint8 7 | 8 | proc hexToColor(hex: string): Color = 9 | let cleaned = hex.strip().replace("#", "") 10 | if cleaned.len != 6: 11 | raise newException(ValueError, "Invalid hex color: " & hex) 12 | 13 | result.r = fromHex[uint8](cleaned[0 .. 1]) 14 | result.g = fromHex[uint8](cleaned[2 .. 3]) 15 | result.b = fromHex[uint8](cleaned[4 .. 5]) 16 | 17 | proc colorToRGB(c: Color): array[3, float] = 18 | [c.r.float / 255.0, c.g.float / 255.0, c.b.float / 255.0] 19 | 20 | proc rgbToColor(rgb: array[3, float]): Color = 21 | Color( 22 | r: clamp(rgb[0] * 255.0, 0.0, 255.0).uint8, 23 | g: clamp(rgb[1] * 255.0, 0.0, 255.0).uint8, 24 | b: clamp(rgb[2] * 255.0, 0.0, 255.0).uint8, 25 | ) 26 | 27 | proc colorToHex(color: Color): string = 28 | # Direct conversion from uint8 values to hex 29 | result = "#" & color.r.toHex(2) & color.g.toHex(2) & color.b.toHex(2) 30 | 31 | proc rgbToHsv(rgb: array[3, float]): array[3, float] = 32 | let r = rgb[0] 33 | let g = rgb[1] 34 | let b = rgb[2] 35 | 36 | let maxVal = max(max(r, g), b) 37 | let minVal = min(min(r, g), b) 38 | let delta = maxVal - minVal 39 | 40 | var h, s, v: float 41 | 42 | v = maxVal 43 | 44 | if maxVal == 0.0: 45 | s = 0.0 46 | else: 47 | s = delta / maxVal 48 | 49 | if delta == 0.0: 50 | h = 0.0 51 | elif maxVal == r: 52 | h = (60.0 * ((g - b) / delta) + 360.0) mod 360.0 53 | elif maxVal == g: 54 | h = (60.0 * ((b - r) / delta) + 120.0) mod 360.0 55 | else: 56 | h = (60.0 * ((r - g) / delta) + 240.0) mod 360.0 57 | 58 | result = [h / 360.0, s, v] 59 | 60 | proc getLuminance(rgb: array[3, float]): float = 61 | 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] 62 | 63 | proc findBestPaletteMatch( 64 | inputRgb: array[3, float], palette: seq[Color] 65 | ): array[3, float] = 66 | let inputHsv = rgbToHsv(inputRgb) 67 | let inputLuminance = getLuminance(inputRgb) 68 | 69 | var bestMatch = colorToRGB(palette[0]) 70 | var bestScore = 1000.0 71 | 72 | for paletteColor in palette: 73 | let paletteRgb = colorToRGB(paletteColor) 74 | let paletteHsv = rgbToHsv(paletteRgb) 75 | let paletteLuminance = getLuminance(paletteRgb) 76 | 77 | let lumDiff = abs(inputLuminance - paletteLuminance) 78 | let hueDiff = 79 | min(abs(inputHsv[0] - paletteHsv[0]), 1.0 - abs(inputHsv[0] - paletteHsv[0])) 80 | let satDiff = abs(inputHsv[1] - paletteHsv[1]) * 0.3 # Less weight on saturation 81 | 82 | let score = lumDiff * 2.0 + hueDiff * 1.5 + satDiff 83 | 84 | if score < bestScore: 85 | bestScore = score 86 | bestMatch = paletteRgb 87 | 88 | result = bestMatch 89 | 90 | proc smoothstep(edge0, edge1, x: float): float = 91 | let t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0) 92 | t * t * (3.0 - 2.0 * t) 93 | 94 | proc generatePaletteLUT( 95 | targetColors: seq[string], strength: float = 0.8 96 | ): ptr VipsImage = 97 | let lutSize = 256 98 | var lutData = newSeq[uint8](lutSize * 3) 99 | let palette = targetColors.map(hexToColor) 100 | 101 | if palette.len < 2: 102 | raise newException(ValueError, "Need at least 2 colors in palette") 103 | 104 | var sortedPalette = palette 105 | sortedPalette.sort( 106 | proc(a, b: Color): int = 107 | let lumA = getLuminance(colorToRGB(a)) 108 | let lumB = getLuminance(colorToRGB(b)) 109 | if lumA < lumB: 110 | -1 111 | elif lumA > lumB: 112 | 1 113 | else: 114 | 0 115 | ) 116 | 117 | for i in 0 ..< lutSize: 118 | let intensity = i.float / 255.0 119 | let originalColor = [intensity, intensity, intensity] 120 | 121 | let paletteMatch = findBestPaletteMatch(originalColor, sortedPalette) 122 | 123 | var finalRgb: array[3, float] 124 | for channel in 0 .. 2: 125 | let blendFactor = strength * smoothstep(0.1, 0.9, intensity) # Stronger in midtones 126 | finalRgb[channel] = 127 | originalColor[channel] * (1.0 - blendFactor) + 128 | paletteMatch[channel] * blendFactor 129 | finalRgb[channel] = clamp(finalRgb[channel], 0.0, 1.0) 130 | 131 | let idx = i * 3 132 | lutData[idx] = (finalRgb[0] * 255.0).uint8 133 | lutData[idx + 1] = (finalRgb[1] * 255.0).uint8 134 | lutData[idx + 2] = (finalRgb[2] * 255.0).uint8 135 | 136 | result = vips_image_new_from_memory_copy( 137 | lutData[0].unsafeAddr, 138 | lutData.len.csize_t, 139 | lutSize.cint, 140 | 1.cint, 141 | 3.cint, 142 | VIPS_FORMAT_UCHAR.cint, 143 | ) 144 | 145 | if result == nil: 146 | raise newException(VipsError, "Failed to create palette LUT image") 147 | 148 | proc applyPaletteLUT( 149 | image: ptr VipsImage, 150 | lut: ptr VipsImage, 151 | preserveDetails: bool = true, 152 | detailStrength: float = 0.3, 153 | ): ptr VipsImage = 154 | var lutResult: ptr VipsImage 155 | let status = vips_maplut(image, lutResult.addr, lut, nil) 156 | checkVipsResult(status, "maplut with palette LUT") 157 | 158 | if not preserveDetails: 159 | return lutResult 160 | 161 | var detailMask: ptr VipsImage 162 | let blurStatus = vips_resize(image, detailMask.addr, 0.5, nil) 163 | # Simple detail detection 164 | checkVipsResult(blurStatus, "resize for detail detection") 165 | 166 | let a = [1.0 - detailStrength, 1.0 - detailStrength, 1.0 - detailStrength] 167 | let b = [detailStrength, detailStrength, detailStrength] 168 | 169 | var preservedResult: ptr VipsImage 170 | let linearStatus = vips_linear( 171 | lutResult, preservedResult.addr, a[0].unsafeAddr, b[0].unsafeAddr, 3, nil 172 | ) 173 | checkVipsResult(linearStatus, "linear blend for detail preservation") 174 | 175 | g_object_unref(detailMask) 176 | g_object_unref(lutResult) 177 | 178 | result = preservedResult 179 | 180 | proc processImageWithPaletteGrading( 181 | inputPath: string, 182 | outputPath: string, 183 | targetColors: seq[string], 184 | strength: float = 0.7, 185 | preserveDetails: bool = true, 186 | saveHaldReference: bool = true, 187 | ) = 188 | checkVipsResult(vips_init("clutter"), "vips_init") 189 | 190 | try: 191 | let inputImage = vips_image_new_from_file(inputPath.cstring, nil) 192 | if inputImage == nil: 193 | raise newException(VipsError, "Failed to load image: " & inputPath) 194 | 195 | echo "Using palette: ", targetColors.join(", ") 196 | 197 | let lut = generatePaletteLUT(targetColors, strength) 198 | 199 | let outputImage = applyPaletteLUT(inputImage, lut, preserveDetails, 0.2) 200 | 201 | checkVipsResult( 202 | vips_image_write_to_file(outputImage, outputPath.cstring, nil), "write_to_file" 203 | ) 204 | 205 | echo "Successfully processed with palette grading: ", inputPath, " -> ", outputPath 206 | 207 | g_object_unref(inputImage) 208 | g_object_unref(lut) 209 | g_object_unref(outputImage) 210 | except Exception as e: 211 | echo "Error: ", e.msg 212 | quit 1 213 | 214 | include lib/interpolation 215 | 216 | proc clutter( 217 | input: string, 218 | output: string, 219 | strength = 0.25, 220 | interpolation = 64, 221 | palette: seq[string], 222 | ) = 223 | try: 224 | let pal = parsePalettes() 225 | var colors = palette 226 | if palette.len == 1 and palette[0] in pal: 227 | colors = pal[palette[0]] 228 | echo "Processing with palette strength: ", strength 229 | processImageWithPaletteGrading( 230 | input, 231 | output, 232 | colors.expandPalette(interpolation), 233 | strength = strength, 234 | preserveDetails = true, 235 | ) 236 | except VipsError as e: 237 | echo "VipsError: ", e.msg 238 | except Exception as e: 239 | echo "Error: ", e.msg 240 | 241 | when isMainModule: 242 | include lib/cli 243 | putEnv("VIPS_WARNING", "0") 244 | putEnv("G_MESSAGES_DEBUG", "") 245 | installPalettes() 246 | if palettescmd.add.seen: 247 | echo "Creating palette '" & add.name.value & "'" 248 | addPalette(add.name.value, add.colors.value.split(" ")) 249 | if args.input.value != "": 250 | var outPath = args.output.value 251 | if outPath == "": 252 | outPath = 253 | $hash(args.input.value & args.palette.value & splitFile(args.input.value).ext) 254 | clutter( 255 | args.input.value, 256 | outPath, 257 | args.strength.value, 258 | args.interp.value, 259 | args.palette.value.split(" "), 260 | ) 261 | -------------------------------------------------------------------------------- /src/lib/vips.nim: -------------------------------------------------------------------------------- 1 | # UHH- These are some pretty sketchy nim bindings to the libvips library that I wrote and kind of edited to work 2 | # USE AT YOUR OWN RISK; even though they work with what I want them to do, I cannot guarantee they will work for you. 3 | 4 | import strutils 5 | 6 | type 7 | gint* = cint 8 | guint* = cuint 9 | gsize* = csize_t 10 | gpointer* = pointer 11 | gconstpointer* = pointer 12 | gboolean* = cint 13 | gdouble* = cdouble 14 | gchar* = cchar 15 | 16 | type 17 | GObject* = object of RootObj 18 | GObjectClass* = object of RootObj 19 | 20 | GType* = csize_t 21 | 22 | type 23 | VipsObject* = object of GObject 24 | VipsObjectClass* = object of GObjectClass 25 | 26 | VipsImage* {.importc: "VipsImage", header: "vips/vips.h".} = object of VipsObject 27 | 28 | type 29 | VipsFormat* = cint 30 | Format* {.importc: "VipsFormat", header: "vips/vips.h", size: sizeof(cint).} = enum 31 | VIPS_FORMAT_NOTSET = -1.cint 32 | VIPS_FORMAT_UCHAR = 0.cint 33 | VIPS_FORMAT_CHAR = 1.cint 34 | VIPS_FORMAT_USHORT = 2.cint 35 | VIPS_FORMAT_SHORT = 3.cint 36 | VIPS_FORMAT_UINT = 4.cint 37 | VIPS_FORMAT_INT = 5.cint 38 | VIPS_FORMAT_FLOAT = 6.cint 39 | VIPS_FORMAT_COMPLEX = 7.cint 40 | VIPS_FORMAT_DOUBLE = 8.cint 41 | VIPS_FORMAT_DPCOMPLEX = 9.cint 42 | 43 | type VipsInterpretation* {. 44 | importc: "VipsInterpretation", header: "vips/vips.h", size: sizeof(cint) 45 | .} = enum 46 | VIPS_INTERPRETATION_ERROR = -1 47 | VIPS_INTERPRETATION_MULTIBAND = 0 48 | VIPS_INTERPRETATION_B_W = 1 49 | VIPS_INTERPRETATION_HISTOGRAM = 10 50 | VIPS_INTERPRETATION_XYZ = 12 51 | VIPS_INTERPRETATION_LAB = 13 52 | VIPS_INTERPRETATION_CMYK = 15 53 | VIPS_INTERPRETATION_LABQ = 16 54 | VIPS_INTERPRETATION_RGB = 17 55 | VIPS_INTERPRETATION_CMC = 18 56 | VIPS_INTERPRETATION_LCH = 19 57 | VIPS_INTERPRETATION_LABS = 21 58 | VIPS_INTERPRETATION_sRGB = 22 59 | VIPS_INTERPRETATION_YXY = 23 60 | VIPS_INTERPRETATION_FOURIER = 24 61 | VIPS_INTERPRETATION_RGB16 = 25 62 | VIPS_INTERPRETATION_GREY16 = 26 63 | VIPS_INTERPRETATION_MATRIX = 27 64 | VIPS_INTERPRETATION_scRGB = 28 65 | VIPS_INTERPRETATION_HSV = 29 66 | 67 | type VipsRelational* {. 68 | importc: "VipsRelational", header: "vips/vips.h", size: sizeof(cint) 69 | .} = enum 70 | VIPS_RELATIONAL_EQUAL = 0 71 | VIPS_RELATIONAL_NOTEQ = 1 72 | VIPS_RELATIONAL_LESS = 2 73 | VIPS_RELATIONAL_LESSEQ = 3 74 | VIPS_RELATIONAL_MORE = 4 75 | VIPS_RELATIONAL_MOREEQ = 5 76 | 77 | type VipsError* = object of CatchableError 78 | 79 | proc vips_init*(argv0: cstring): cint {.importc: "vips_init", header: "vips/vips.h".} 80 | proc vips_shutdown*() {.importc: "vips_shutdown", header: "vips/vips.h".} 81 | 82 | proc g_object_ref*( 83 | obj: pointer 84 | ): pointer {.importc: "g_object_ref", header: "glib-object.h".} 85 | 86 | proc g_object_unref*( 87 | obj: pointer 88 | ) {.importc: "g_object_unref", header: "glib-object.h".} 89 | 90 | proc vips_image_new*(): ptr VipsImage {. 91 | importc: "vips_image_new", header: "vips/vips.h" 92 | .} 93 | 94 | proc vips_image_new_from_file*( 95 | filename: cstring, args: pointer 96 | ): ptr VipsImage {.importc: "vips_image_new_from_file", header: "vips/vips.h".} 97 | 98 | proc vips_image_new_from_memory*( 99 | data: pointer, 100 | size: csize_t, 101 | width: cint, 102 | height: cint, 103 | bands: cint, 104 | format: VipsFormat, 105 | ): ptr VipsImage {.importc: "vips_image_new_from_memory", header: "vips/vips.h".} 106 | 107 | proc vips_image_new_from_memory_copy*( 108 | data: pointer, 109 | size: csize_t, 110 | width: cint, 111 | height: cint, 112 | bands: cint, 113 | format: VipsFormat, 114 | ): ptr VipsImage {.importc: "vips_image_new_from_memory_copy", header: "vips/vips.h".} 115 | 116 | proc vips_image_get_width*( 117 | image: ptr VipsImage 118 | ): cint {.importc: "vips_image_get_width", header: "vips/vips.h".} 119 | 120 | proc vips_image_get_height*( 121 | image: ptr VipsImage 122 | ): cint {.importc: "vips_image_get_height", header: "vips/vips.h".} 123 | 124 | proc vips_image_get_bands*( 125 | image: ptr VipsImage 126 | ): cint {.importc: "vips_image_get_bands", header: "vips/vips.h".} 127 | 128 | proc vips_image_get_format*( 129 | image: ptr VipsImage 130 | ): VipsFormat {.importc: "vips_image_get_format", header: "vips/vips.h".} 131 | 132 | proc vips_image_get_interpretation*( 133 | image: ptr VipsImage 134 | ): VipsInterpretation {. 135 | importc: "vips_image_get_interpretation", header: "vips/vips.h" 136 | .} 137 | 138 | proc vips_image_write_to_file*( 139 | image: ptr VipsImage, filename: cstring, args: pointer 140 | ): cint {.importc: "vips_image_write_to_file", header: "vips/vips.h".} 141 | 142 | proc vips_image_write_to_memory*( 143 | image: ptr VipsImage, size: ptr csize_t 144 | ): pointer {.importc: "vips_image_write_to_memory", header: "vips/vips.h".} 145 | 146 | proc vips_maplut*( 147 | input: ptr VipsImage, output: ptr ptr VipsImage, lut: ptr VipsImage, args: pointer 148 | ): cint {.importc: "vips_maplut", header: "vips/vips.h".} 149 | 150 | proc vips_resize*( 151 | input: ptr VipsImage, output: ptr ptr VipsImage, scale: cdouble, args: pointer 152 | ): cint {.importc: "vips_resize", header: "vips/vips.h".} 153 | 154 | proc vips_hist_find*( 155 | input: ptr VipsImage, output: ptr ptr VipsImage, args: pointer 156 | ): cint {.importc: "vips_hist_find", header: "vips/vips.h".} 157 | 158 | proc vips_hist_find_indexed*( 159 | input: ptr VipsImage, output: ptr ptr VipsImage, index: ptr VipsImage, args: pointer 160 | ): cint {.importc: "vips_hist_find_indexed", header: "vips/vips.h".} 161 | 162 | proc vips_getpoint*( 163 | image: ptr VipsImage, vector: ptr cdouble, x: cint, y: cint, args: pointer 164 | ): cint {.importc: "vips_getpoint", header: "vips/vips.h".} 165 | 166 | proc vips_add*( 167 | left: ptr VipsImage, right: ptr VipsImage, output: ptr ptr VipsImage, args: pointer 168 | ): cint {.importc: "vips_add", header: "vips/vips.h".} 169 | 170 | proc vips_subtract*( 171 | left: ptr VipsImage, right: ptr VipsImage, output: ptr ptr VipsImage, args: pointer 172 | ): cint {.importc: "vips_subtract", header: "vips/vips.h".} 173 | 174 | proc vips_multiply*( 175 | left: ptr VipsImage, right: ptr VipsImage, output: ptr ptr VipsImage, args: pointer 176 | ): cint {.importc: "vips_multiply", header: "vips/vips.h".} 177 | 178 | proc vips_add_const*( 179 | input: ptr VipsImage, 180 | output: ptr ptr VipsImage, 181 | c: ptr cdouble, 182 | n: cint, 183 | args: pointer, 184 | ): cint {.importc: "vips_linear", header: "vips/vips.h".} 185 | # Note: vips_add_const is deprecated 186 | 187 | proc vips_multiply_const*( 188 | input: ptr VipsImage, 189 | output: ptr ptr VipsImage, 190 | c: ptr cdouble, 191 | n: cint, 192 | args: pointer, 193 | ): cint {.importc: "vips_linear", header: "vips/vips.h".} # Use vips_linear instead 194 | 195 | proc vips_linear*( 196 | input: ptr VipsImage, 197 | output: ptr ptr VipsImage, 198 | a: ptr cdouble, 199 | b: ptr cdouble, 200 | n: cint, 201 | args: pointer, 202 | ): cint {.importc: "vips_linear", header: "vips/vips.h".} 203 | 204 | proc vips_relational*( 205 | left: ptr VipsImage, 206 | right: ptr VipsImage, 207 | output: ptr ptr VipsImage, 208 | relational: VipsRelational, 209 | args: pointer, 210 | ): cint {.importc: "vips_relational", header: "vips/vips.h".} 211 | 212 | proc vips_relational_const*( 213 | input: ptr VipsImage, 214 | output: ptr ptr VipsImage, 215 | relational: VipsRelational, 216 | c: ptr cdouble, 217 | n: cint, 218 | args: pointer, 219 | ): cint {.importc: "vips_relational_const", header: "vips/vips.h".} 220 | 221 | proc vips_boolean*( 222 | left: ptr VipsImage, 223 | right: ptr VipsImage, 224 | output: ptr ptr VipsImage, 225 | boolean: cint, 226 | args: pointer, 227 | ): cint {.importc: "vips_boolean", header: "vips/vips.h".} 228 | 229 | proc vips_ifthenelse*( 230 | cond: ptr VipsImage, 231 | input1: ptr VipsImage, 232 | input2: ptr VipsImage, 233 | output: ptr ptr VipsImage, 234 | args: pointer, 235 | ): cint {.importc: "vips_ifthenelse", header: "vips/vips.h".} 236 | 237 | proc vips_colourspace*( 238 | input: ptr VipsImage, 239 | output: ptr ptr VipsImage, 240 | space: VipsInterpretation, 241 | args: pointer, 242 | ): cint {.importc: "vips_colourspace", header: "vips/vips.h".} 243 | 244 | proc vips_icc_transform*( 245 | input: ptr VipsImage, 246 | output: ptr ptr VipsImage, 247 | output_profile: cstring, 248 | args: pointer, 249 | ): cint {.importc: "vips_icc_transform", header: "vips/vips.h".} 250 | 251 | proc vips_avg*( 252 | input: ptr VipsImage, output: ptr cdouble, args: pointer 253 | ): cint {.importc: "vips_avg", header: "vips/vips.h".} 254 | 255 | proc vips_min*( 256 | input: ptr VipsImage, output: ptr cdouble, args: pointer 257 | ): cint {.importc: "vips_min", header: "vips/vips.h".} 258 | 259 | proc vips_max*( 260 | input: ptr VipsImage, output: ptr cdouble, args: pointer 261 | ): cint {.importc: "vips_max", header: "vips/vips.h".} 262 | 263 | proc vips_image_set_int*( 264 | image: ptr VipsImage, name: cstring, i: cint 265 | ) {.importc: "vips_image_set_int", header: "vips/vips.h".} 266 | 267 | proc vips_image_get_int*( 268 | image: ptr VipsImage, name: cstring, output: ptr cint 269 | ): cint {.importc: "vips_image_get_int", header: "vips/vips.h".} 270 | 271 | proc vips_malloc*( 272 | size: csize_t 273 | ): pointer {.importc: "vips_malloc", header: "vips/vips.h".} 274 | 275 | proc vips_error_buffer*(): cstring {. 276 | importc: "vips_error_buffer", header: "vips/vips.h" 277 | .} 278 | 279 | proc vips_error_clear*() {.importc: "vips_error_clear", header: "vips/vips.h".} 280 | 281 | proc checkVipsResult*(result: cint, operation: string = "vips operation") = 282 | if result != 0: 283 | let errorMsg = $vips_error_buffer() 284 | vips_error_clear() 285 | raise newException(VipsError, operation & " failed: " & errorMsg) 286 | 287 | proc createImageFromData*(data: seq[uint8], width, height, bands: int): ptr VipsImage = 288 | result = vips_image_new_from_memory_copy( 289 | data[0].unsafeAddr, 290 | data.len.csize_t, 291 | width.cint, 292 | height.cint, 293 | bands.cint, 294 | VIPS_FORMAT_UCHAR.cint, 295 | ) 296 | if result == nil: 297 | raise newException(VipsError, "Failed to create image from data") 298 | 299 | proc getPixelSafe*(image: ptr VipsImage, x, y: int): seq[float] = 300 | let bands = vips_image_get_bands(image).int 301 | result = newSeq[float](bands) 302 | var values = newSeq[cdouble](bands) 303 | 304 | let status = vips_getpoint(image, values[0].addr, x.cint, y.cint, nil) 305 | if status != 0: 306 | checkVipsResult(status, "getpoint") 307 | 308 | for i in 0 ..< bands: 309 | result[i] = values[i].float 310 | 311 | {.pragma: vipsLib, dynlib: "libvips.so.42".} 312 | 313 | {.passC: gorge("pkg-config --cflags vips").} 314 | {.passL: gorge("pkg-config --libs vips").} 315 | --------------------------------------------------------------------------------