Rewrite palette generation in Haskell

This does not rely on an external library for colour selection,
therefore it can be fine-tuned to create a better theme.

Closes #2 because Colorgram is no longer used.
This commit is contained in:
Daniel Thwaites 2021-10-17 15:04:19 +01:00
parent ed67f81454
commit 7b34be82ff
No known key found for this signature in database
GPG key ID: D8AFC4BF05670F9D
9 changed files with 297 additions and 190 deletions

View file

@ -1,18 +0,0 @@
{
imports = [
./stylix/default.nix
./modules/console.nix
./modules/dunst.nix
./modules/feh.nix
./modules/fish.nix
./modules/grub.nix
./modules/gtk.nix
./modules/kitty.nix
./modules/lightdm.nix
./modules/plymouth
./modules/qutebrowser.nix
./modules/sway.nix
./modules/vim.nix
];
}

43
flake.lock Normal file
View file

@ -0,0 +1,43 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1633422745,
"narHash": "sha256-gA6Ok64nPbkjHk3Oanq4641EeYkjcKhisDF9wBjLxEk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8e1eab9eae4278c9bb1dcae426848a581943db5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"utils": "utils"
}
},
"utils": {
"locked": {
"lastModified": 1631561581,
"narHash": "sha256-3VQMV5zvxaVLvqqUrNz3iJelLw30mIVSfZmAaauM3dA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e5bf3925f6fbdfaf50a2a7ca0be2879c4261d19",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1 +1,55 @@
{ outputs = inputs: { nixosModules.stylix = import ./default.nix; }; }
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
utils.url = "github:numtide/flake-utils";
};
outputs = { nixpkgs, utils, self, ... }:
(utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
ghc = pkgs.haskell.packages.ghc901.ghcWithPackages
(haskellPackages: with haskellPackages; [ json JuicyPixels ]);
palette-generator = pkgs.stdenvNoCC.mkDerivation {
name = "palette-generator";
src = ./palette-generator;
buildInputs = [ ghc ];
buildPhase = "ghc -O -threaded -Wall Main.hs";
installPhase = "install -D Main $out/bin/palette-generator";
};
palette-generator-app = utils.lib.mkApp {
drv = palette-generator;
name = "palette-generator";
};
in {
packages.palette-generator = palette-generator;
apps.palette-generator = palette-generator-app;
})) // {
nixosModules.stylix = { pkgs, ... }: {
imports = [
./modules/console.nix
./modules/dunst.nix
./modules/feh.nix
./modules/fish.nix
./modules/grub.nix
./modules/gtk.nix
./modules/kitty.nix
./modules/lightdm.nix
./modules/plymouth
./modules/qutebrowser.nix
./modules/sway.nix
./modules/vim.nix
(import ./stylix/palette.nix
self.packages.${pkgs.system}.palette-generator)
./stylix/base16.nix
./stylix/fonts.nix
./stylix/home-manager.nix
./stylix/pixel.nix
];
};
};
}

100
palette-generator/Main.hs Normal file
View file

@ -0,0 +1,100 @@
import Codec.Picture ( DynamicImage, Image(imageWidth, imageHeight), PixelRGB8(PixelRGB8), convertRGB8, pixelAt, readImage )
import Data.Bifunctor ( second )
import Data.List ( sortOn )
import Data.Word ( Word8 )
import RGBHSV ( HSV(HSV), RGB(RGB), hsvToRgb, rgbToHsv )
import System.Environment ( getArgs )
import System.Exit ( die )
import Text.JSON ( JSObject, encode, toJSObject )
import Text.Printf ( printf )
type OutputTable = JSObject String
makeOutputTable :: [(String, RGB Float)] -> OutputTable
makeOutputTable = toJSObject . concatMap makeOutputs
where makeOutputs :: (String, RGB Float) -> [(String, String)]
makeOutputs (name, RGB r g b) =
[ (name ++ "-dec-r", show $ r / 255)
, (name ++ "-dec-g", show $ g / 255)
, (name ++ "-dec-b", show $ b / 255)
, (name ++ "-rgb-r", show r')
, (name ++ "-rgb-g", show g')
, (name ++ "-rgb-b", show b')
, (name ++ "-hex-r", printf "%02x" r')
, (name ++ "-hex-g", printf "%02x" g')
, (name ++ "-hex-b", printf "%02x" b')
, (name ++ "-hex", printf "%02x%02x%02x" r' g' b')
, (name ++ "-hash", printf "#%02x%02x%02x" r' g' b')
]
where r' :: Word8
r' = round r
g' :: Word8
g' = round g
b' :: Word8
b' = round b
selectColours :: [HSV Float] -> [(String, HSV Float)]
selectColours image = zip names palette
where names :: [String]
names = map (printf "base%02X") ([0..15] :: [Int])
hueInRange :: (Ord a) => a -> a -> HSV a -> Bool
hueInRange low high (HSV hue _ _) = hue >= low && hue < high
binThresholds :: [Float]
binThresholds = [i * (6/9) | i <- [1..9]]
average :: (Fractional a) => [a] -> a
average xs = sum xs / fromIntegral (length xs)
averageColour :: (Fractional a) => [HSV a] -> HSV a
averageColour colours = HSV (average $ map (\(HSV h _ _) -> h) colours)
(average $ map (\(HSV _ s _) -> s) colours)
(average $ map (\(HSV _ _ v) -> v) colours)
bins :: [HSV Float]
bins = map averageColour
$ sortOn length
$ map (\bin -> filter (hueInRange (bin - (6/9)) bin) image)
binThresholds
primaryScale :: [HSV Float]
primaryScale = [HSV h s (v / 8) | v <- [1..8]]
where (HSV h s _) = head bins
palette :: [HSV Float]
palette = primaryScale ++ tail bins
unpackImage :: DynamicImage -> [RGB Float]
unpackImage image = do
let image' = convertRGB8 image
x <- [0 .. imageWidth image' - 1]
y <- [0 .. imageHeight image' - 1]
let (PixelRGB8 r g b) = pixelAt image' x y
return (RGB (fromIntegral r) (fromIntegral g) (fromIntegral b))
loadImage :: String -> IO DynamicImage
loadImage input = either error id <$> readImage input
main :: IO ()
main = either die mainProcess . parseArguments =<< getArgs
where parseArguments :: [String] -> Either String (String, String)
parseArguments [input, output] = Right (input, output)
parseArguments [_] = Left "Please specify an output file"
parseArguments [] = Left "Please specify an image"
parseArguments _ = Left "Too many arguments"
mainProcess :: (String, String) -> IO ()
mainProcess (input, output) = do
putStrLn $ "Processing " ++ input
image <- loadImage input
let outputTable = makeOutputTable
$ map (second hsvToRgb)
$ selectColours
$ map rgbToHsv
$ unpackImage image
writeFile output $ encode outputTable
putStrLn $ "Saved to " ++ output

View file

@ -0,0 +1,44 @@
module RGBHSV ( RGB(..), HSV(..), rgbToHsv, hsvToRgb ) where
import Data.Fixed ( mod' )
-- http://mattlockyer.github.io/iat455/documents/rgb-hsv.pdf
data RGB a = RGB a a a deriving (Eq, Show) -- 0 to 255
data HSV a = HSV a a a deriving (Eq, Show) -- 0 to 1
normaliseHue :: (Real a) => a -> a
normaliseHue h = h `mod'` 6
rgbToHsv :: (Eq a, Fractional a, Num a, Real a) => RGB a -> HSV a
rgbToHsv (RGB r' g' b') = HSV h' s v
where r = r' / 255
g = g' / 255
b = b' / 255
maximal = maximum [r, g, b]
minimal = minimum [r, g, b]
delta = maximal - minimal
h | delta == 0 = 0
| maximal == r = (g - b) / delta
| maximal == g = ((b - r) / delta) + 2
| otherwise = ((r - g) / delta) + 4
h' = normaliseHue h
s | v == 0 = 0
| otherwise = delta / v
v = maximal
hsvToRgb :: (Num a, Ord a, Real a) => HSV a -> RGB a
hsvToRgb (HSV h' s v) = RGB r' g' b'
where h = normaliseHue h'
alpha = v * (1 - s)
beta = v * (1 - (h - abs h) * s)
gamma = v * (1 - (1 - (h - abs h)) * s)
(r, g, b) | h < 1 = (v, gamma, alpha)
| h < 2 = (beta, v, alpha)
| h < 3 = (alpha, v, gamma)
| h < 4 = (alpha, beta, v)
| h < 5 = (gamma, alpha, v)
| otherwise = (v, alpha, beta)
r' = r * 255
g' = g * 255
b' = b * 255

View file

@ -1,58 +0,0 @@
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.stylix;
# TODO: This Python library should be included in Nixpkgs
colorgram = with pkgs.python3Packages;
buildPythonPackage rec {
pname = "colorgram.py";
version = "1.2.0";
src = fetchPypi {
inherit pname version;
sha256 = "1gzxgcmg3ndra2j4dg73x8q9dw6b0akj474gxyyhfwnyz6jncxz7";
};
propagatedBuildInputs = [ pillow ];
};
colorgramPython = pkgs.python3.withPackages (ps: [ colorgram ]);
# Pass the wallpaper and any manually selected colors to ./colors.py and
# return a JSON file containing the generated colorscheme
colorsJSON = pkgs.runCommand "stylix-colors" {
colors = builtins.toJSON config.stylix.colors;
passAsFile = [ "colors" ];
} ''
${colorgramPython}/bin/python ${./colors.py} \
${cfg.image} < $colorsPath > $out
'';
in {
options.stylix.colors = genAttrs [
"base00"
"base01"
"base02"
"base03"
"base04"
"base05"
"base06"
"base07"
"base08"
"base09"
"base0A"
"base0B"
"base0C"
"base0D"
"base0E"
"base0F"
] (name:
mkOption {
description = "Hexadecimal color value for ${name}.";
default = null;
defaultText = "Automatically selected from the background image.";
type = types.nullOr (types.strMatching "[0-9a-fA-F]{6}");
});
config.lib.stylix.colors = importJSON colorsJSON;
}

View file

@ -1,100 +0,0 @@
import json
import sys
import colorgram
import colorsys
# Select 9 colors from the image passed on the command line
colors = colorgram.extract(sys.argv[1], 9)
# Extract the most dominant color to use as the background
colors.sort(key=lambda c: c.proportion)
dominant_color = colors.pop(0)
# Decide whether to generate a light or dark scheme based
# on the lightness of the dominant color
if dominant_color.hsl.l >= 128:
def scale(i):
scale = 0.7 - (0.1 * i)
return scale * 255
def clamp(l):
return min(l, 100)
else:
def scale(i):
scale = 0.1 + (0.1 * i)
return scale * 255
def clamp(l):
return max(l, 155)
def int_to_base(i):
return "base{0:02X}".format(i)
scheme = {}
# base00 to base07 use the dominant color's hue and saturation,
# lightness is a linear scale
for i in range(8):
scheme[int_to_base(i)] = (
dominant_color.hsl.h,
scale(i),
dominant_color.hsl.s,
)
# base08 to base0A use the remaining 8 colors from the image,
# with their lightness clamped to enforce adequate contrast
colors.sort(key=lambda c: c.hsl.h) # sort by hue
for i in range(8, 16):
color = colors[i-8]
scheme[int_to_base(i)] = (
color.hsl.h,
clamp(color.hsl.l),
color.hsl.s,
)
# Override with any manually selected colors
manual_colors = json.load(sys.stdin)
for k, v in manual_colors.items():
if v is not None:
scheme[k] = colorsys.rgb_to_hls(
int(v[0:2], 16) / 255,
int(v[2:4], 16) / 255,
int(v[4:6], 16) / 255,
)
scheme[k] = (
scheme[k][0] * 255,
scheme[k][1] * 255,
scheme[k][2] * 255,
)
data = {}
for key, color in scheme.items():
r, g, b = colorsys.hls_to_rgb(
color[0] / 255,
color[1] / 255,
color[2] / 255,
)
data[key + "-dec-r"] = r
data[key + "-dec-g"] = g
data[key + "-dec-b"] = b
data[key + "-rgb-r"] = r * 255
data[key + "-rgb-g"] = g * 255
data[key + "-rgb-b"] = b * 255
hex_color = "{0:02x}{1:02x}{2:02x}".format(
round(r * 255),
round(g * 255),
round(b * 255),
)
data[key + "-hex"] = hex_color
data[key + "-hash"] = "#" + hex_color
data[key + "-hex-r"] = hex_color[0:2]
data[key + "-hex-g"] = hex_color[2:4]
data[key + "-hex-b"] = hex_color[4:6]
json.dump(data, sys.stdout)

View file

@ -1,13 +0,0 @@
{ lib, ... }:
with lib;
{
imports =
[ ./base16.nix ./colors.nix ./fonts.nix ./home-manager.nix ./pixel.nix ];
options.stylix.image = mkOption {
type = types.coercedTo types.package toString types.path;
description = "Wallpaper image.";
};
}

55
stylix/palette.nix Normal file
View file

@ -0,0 +1,55 @@
palette-generator:
{ pkgs, lib, config, ... }:
with lib;
let
cfg = config.stylix;
palette = pkgs.runCommand "palette.json" { } ''
${palette-generator}/bin/palette-generator ${cfg.image} $out
'';
in {
options.stylix = {
image = mkOption {
type = types.coercedTo types.package toString types.path;
description = ''
Wallpaper image. This is set as the background of your desktop
environment, if possible, and additionally used as the Plymouth splash
screen if that is enabled. Colours are automatically selected from the
picture to generate the system colour scheme.
'';
};
/*
TODO: Implement manual palette
palette = genAttrs [
"base00"
"base01"
"base02"
"base03"
"base04"
"base05"
"base06"
"base07"
"base08"
"base09"
"base0A"
"base0B"
"base0C"
"base0D"
"base0E"
"base0F"
] (name:
mkOption {
description = "Hexadecimal color value for ${name}.";
default = null;
defaultText = "Automatically selected from the background image.";
type = types.nullOr (types.strMatching "[0-9a-fA-F]{6}");
});
*/
};
config.lib.stylix.colors = importJSON palette;
}