lib/palette.ps1
|
# palette.ps1 -- build a readable terminal colour scheme from image pixels. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Pure: takes the [int[]] (0xRRGGBB) that Read-ImagePixels returns and does no # I/O, so it runs and is tested on every platform. Deterministic: fixed # quantile seeds and a fixed iteration count, no random numbers. function ConvertTo-PaletteHsl { # '#rrggbb' -> [double[]] h (0-360), s (0-1), l (0-1). Internal. param([Parameter(Mandatory)][string]$Hex) $r = [Convert]::ToInt32($Hex.Substring(1, 2), 16) / 255.0 $g = [Convert]::ToInt32($Hex.Substring(3, 2), 16) / 255.0 $b = [Convert]::ToInt32($Hex.Substring(5, 2), 16) / 255.0 $max = [Math]::Max($r, [Math]::Max($g, $b)) $min = [Math]::Min($r, [Math]::Min($g, $b)) $l = ($max + $min) / 2.0 $d = $max - $min if ($d -eq 0) { return , @(0.0, 0.0, $l) } $s = if ($l -gt 0.5) { $d / (2.0 - $max - $min) } else { $d / ($max + $min) } if ($max -eq $r) { $h = (($g - $b) / $d) % 6 } elseif ($max -eq $g) { $h = (($b - $r) / $d) + 2 } else { $h = (($r - $g) / $d) + 4 } $h = $h * 60.0 if ($h -lt 0) { $h += 360.0 } , @($h, $s, $l) } function ConvertFrom-PaletteHsl { # h (0-360), s, l (0-1) -> '#rrggbb'. Internal; reuses Convert-HueToRgb. param([double]$H, [double]$S, [double]$L) $S = [Math]::Max(0.0, [Math]::Min(1.0, $S)) $L = [Math]::Max(0.0, [Math]::Min(1.0, $L)) $H = $H % 360.0 if ($H -lt 0) { $H += 360.0 } if ($S -eq 0) { $r = $L; $g = $L; $b = $L } else { $q = if ($L -lt 0.5) { $L * (1.0 + $S) } else { $L + $S - $L * $S } $p = 2.0 * $L - $q $hk = $H / 360.0 $r = Convert-HueToRgb -P $p -Q $q -T ($hk + 1.0 / 3.0) $g = Convert-HueToRgb -P $p -Q $q -T $hk $b = Convert-HueToRgb -P $p -Q $q -T ($hk - 1.0 / 3.0) } '#{0:x2}{1:x2}{2:x2}' -f [int][Math]::Round($r * 255.0), [int][Math]::Round($g * 255.0), [int][Math]::Round($b * 255.0) } function Get-PaletteHueDistance { param([double]$A, [double]$B) $d = [Math]::Abs($A - $B) % 360.0 if ($d -gt 180.0) { $d = 360.0 - $d } $d } function Get-PaletteClusters { # Deterministic k-means over a subsample. Returns objects with Hex and # Count, empty clusters dropped. Internal. param([int[]]$Pixels, [int]$K = 8, [int]$Iterations = 10, [int]$MaxSample = 2048) $n = $Pixels.Length $stride = [Math]::Max(1, [int][Math]::Ceiling($n / [double]$MaxSample)) $m = [int][Math]::Ceiling($n / [double]$stride) $pr = New-Object 'double[]' $m $pg = New-Object 'double[]' $m $pb = New-Object 'double[]' $m $luma = New-Object 'double[]' $m $idx = New-Object 'int[]' $m $j = 0 for ($i = 0; $i -lt $n -and $j -lt $m; $i += $stride) { $v = $Pixels[$i] $r = ($v -shr 16) -band 255; $g = ($v -shr 8) -band 255; $b = $v -band 255 $pr[$j] = $r; $pg[$j] = $g; $pb[$j] = $b $luma[$j] = 0.299 * $r + 0.587 * $g + 0.114 * $b $idx[$j] = $j $j++ } $m = $j # Seeds: evenly spaced quantiles of the luma-sorted sample. $sorted = [int[]]$idx[0..($m - 1)] $keys = [double[]]$luma[0..($m - 1)] [Array]::Sort($keys, $sorted) $cr = New-Object 'double[]' $K $cg = New-Object 'double[]' $K $cb = New-Object 'double[]' $K for ($c = 0; $c -lt $K; $c++) { $q = $sorted[[int][Math]::Floor(($c + 0.5) * $m / $K)] $cr[$c] = $pr[$q]; $cg[$c] = $pg[$q]; $cb[$c] = $pb[$q] } $assign = New-Object 'int[]' $m $cnt = New-Object 'int[]' $K for ($it = 0; $it -lt $Iterations; $it++) { $sr = New-Object 'double[]' $K $sg = New-Object 'double[]' $K $sb = New-Object 'double[]' $K $cnt = New-Object 'int[]' $K for ($i = 0; $i -lt $m; $i++) { $best = 0; $bd = [double]::MaxValue for ($c = 0; $c -lt $K; $c++) { $dr = $pr[$i] - $cr[$c]; $dg = $pg[$i] - $cg[$c]; $db = $pb[$i] - $cb[$c] $d = $dr * $dr + $dg * $dg + $db * $db if ($d -lt $bd) { $bd = $d; $best = $c } } $assign[$i] = $best $sr[$best] += $pr[$i]; $sg[$best] += $pg[$i]; $sb[$best] += $pb[$i] $cnt[$best]++ } for ($c = 0; $c -lt $K; $c++) { if ($cnt[$c] -gt 0) { $cr[$c] = $sr[$c] / $cnt[$c]; $cg[$c] = $sg[$c] / $cnt[$c]; $cb[$c] = $sb[$c] / $cnt[$c] } } } $out = New-Object System.Collections.Generic.List[object] for ($c = 0; $c -lt $K; $c++) { if ($cnt[$c] -le 0) { continue } $hex = '#{0:x2}{1:x2}{2:x2}' -f [int][Math]::Round($cr[$c]), [int][Math]::Round($cg[$c]), [int][Math]::Round($cb[$c]) $hsl = ConvertTo-PaletteHsl $hex $out.Add([pscustomobject]@{ Hex = $hex; Count = $cnt[$c]; H = $hsl[0]; S = $hsl[1]; L = $hsl[2] Luma = 0.299 * $cr[$c] + 0.587 * $cg[$c] + 0.114 * $cb[$c] }) } , $out.ToArray() } function New-PaletteFromPixels { <# .SYNOPSIS A readable 21-key colour scheme from image pixels. .DESCRIPTION Clusters the pixels (k-means, k = 8, fixed seeds). The background is the darkest large cluster, capped dark; the foreground the lightest, raised until it holds 7:1 against the background. Each ANSI role takes the cluster nearest its hue (within 25 degrees), else the role's hue at the image's mean saturation, lightened until it holds 4.5:1. Brights are the same hue, lighter. Same pixels in, same scheme out. #> [CmdletBinding()] param( [Parameter(Mandatory)][int[]]$Pixels, [Parameter(Mandatory)][string]$Name ) $total = $Pixels.Length $clusters = if ($total -gt 0) { @(Get-PaletteClusters -Pixels $Pixels) } else { @() } if ($clusters.Count -eq 0) { $clusters = @([pscustomobject]@{ Hex = '#808080'; Count = 1; H = 0.0; S = 0.0; L = 0.5; Luma = 128.0 }) } $sampleTotal = 0 foreach ($c in $clusters) { $sampleTotal += $c.Count } $large = @($clusters | Where-Object { $_.Count -ge 0.05 * $sampleTotal }) if ($large.Count -eq 0) { $large = $clusters } # Background: darkest large cluster, capped dark and not too colourful. $bgC = @($large | Sort-Object Luma, Hex)[0] $bg = ConvertFrom-PaletteHsl $bgC.H ([Math]::Min($bgC.S, 0.6)) ([Math]::Min($bgC.L, 0.10)) # Foreground: lightest large cluster, raised until 7:1. $fgC = @($large | Sort-Object @{ Expression = 'Luma'; Descending = $true }, Hex)[0] $fgS = [Math]::Min($fgC.S, 0.35) $fgL = [Math]::Max($fgC.L, 0.6) $fg = ConvertFrom-PaletteHsl $fgC.H $fgS $fgL while ((Get-ContrastRatio $fg $bg) -lt 7.0 -and $fgL -lt 1.0) { $fgL = [Math]::Min(1.0, $fgL + 0.01) $fg = ConvertFrom-PaletteHsl $fgC.H $fgS $fgL } # Mean saturation, for roles the image has no colour for. $ws = 0.0 foreach ($c in $clusters) { $ws += $c.S * $c.Count } $meanS = [Math]::Max(0.45, [Math]::Min(0.9, $ws / $sampleTotal)) $roles = [ordered]@{ red = 0.0; green = 120.0; yellow = 60.0; blue = 240.0; purple = 300.0; cyan = 180.0 } $colourful = @($clusters | Where-Object { $_.S -ge 0.2 -and $_.L -ge 0.08 -and $_.L -le 0.97 }) $normal = @{} $bright = @{} foreach ($role in $roles.Keys) { $hue = $roles[$role] $pick = $null; $pd = 25.0 foreach ($c in $colourful) { $d = Get-PaletteHueDistance $c.H $hue if ($d -le $pd) { $pd = $d; $pick = $c } } if ($pick) { $h = $pick.H; $s = [Math]::Max(0.45, [Math]::Min(0.9, $pick.S)); $l = [Math]::Max(0.45, $pick.L) } else { $h = $hue; $s = $meanS; $l = 0.55 } $hex = ConvertFrom-PaletteHsl $h $s $l while ((Get-ContrastRatio $hex $bg) -lt 4.5 -and $l -lt 0.9) { $l = [Math]::Min(0.9, $l + 0.01) $hex = ConvertFrom-PaletteHsl $h $s $l } $bl = [Math]::Min(0.92, $l + 0.10) $bhex = ConvertFrom-PaletteHsl $h $s $bl if ((Get-RelativeLuminance $bhex) -lt (Get-RelativeLuminance $hex)) { $bhex = $hex } $normal[$role] = $hex $bright[$role] = $bhex } # Neutrals. $bgHsl = ConvertTo-PaletteHsl $bg $fgHsl = ConvertTo-PaletteHsl $fg $black = ConvertFrom-PaletteHsl $bgHsl[0] $bgHsl[1] ($bgHsl[2] + 0.04) $bbL = $bgHsl[2] + 0.2 $bbS = [Math]::Min($bgHsl[1], 0.3) $brightBlack = ConvertFrom-PaletteHsl $bgHsl[0] $bbS $bbL while ((Get-ContrastRatio $brightBlack $bg) -lt 3.0 -and $bbL -lt 1.0) { $bbL = [Math]::Min(1.0, $bbL + 0.01) $brightBlack = ConvertFrom-PaletteHsl $bgHsl[0] $bbS $bbL } $white = ConvertFrom-PaletteHsl $fgHsl[0] $fgHsl[1] ($fgHsl[2] - 0.1) if ((Get-ContrastRatio $white $bg) -lt 4.5) { $white = $fg } $selection = Convert-HexAdjust -Hex $bg -Brightness 30 $o = [ordered]@{ name = $Name background = $bg foreground = $fg cursorColor = $fg selectionBackground = $selection black = $black red = $normal['red'] green = $normal['green'] yellow = $normal['yellow'] blue = $normal['blue'] purple = $normal['purple'] cyan = $normal['cyan'] white = $white brightBlack = $brightBlack brightRed = $bright['red'] brightGreen = $bright['green'] brightYellow = $bright['yellow'] brightBlue = $bright['blue'] brightPurple = $bright['purple'] brightCyan = $bright['cyan'] brightWhite = $fg } [pscustomobject]$o } function Get-PaletteAccent { # The most vivid colour in the scheme (highest HSL chroma among the six # hue slots and their brights); the first wins a tie. '#rrggbb'. [CmdletBinding()] param([Parameter(Mandatory)]$Scheme) $best = $null; $bc = -1.0 foreach ($k in 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan') { $hex = ConvertTo-NormalHex $Scheme.$k if (-not $hex) { continue } $hsl = ConvertTo-PaletteHsl $hex $chroma = $hsl[1] * (1.0 - [Math]::Abs(2.0 * $hsl[2] - 1.0)) if ($chroma -gt $bc) { $bc = $chroma; $best = $hex } } $best } function Get-SchemeLowContrastSlots { <# .SYNOPSIS Which colour slots fall below their contrast bar against the background? .DESCRIPTION foreground needs 7.0; the six ANSI hues, white and their brights 4.5; brightBlack 3.0 (it is the dim-text colour). black is exempt. Works on any scheme object or dictionary with those keys; a slot that is missing or does not parse is skipped (Get-SchemeUnreadableSlots reports unparsable values). Returns names in a fixed order; empty when every slot passes. -Text is more colours shown as text on that background (the creator's prompt path and symbol colours), name -> #rrggbb, held to the body-text bar of the ANSI hues, 4.5, and returned after the slots in their order. #> [CmdletBinding()] [OutputType([string[]])] param([Parameter(Mandatory)]$Scheme, [System.Collections.IDictionary]$Text) $bars = [ordered]@{ foreground = 7.0 } foreach ($k in 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white') { $bars[$k] = 4.5 } foreach ($k in 'Red', 'Green', 'Yellow', 'Blue', 'Purple', 'Cyan', 'White') { $bars["bright$k"] = 4.5 } $bars['brightBlack'] = 3.0 $bg = ConvertTo-NormalHex $Scheme.background if (-not $bg) { return } $low = New-Object System.Collections.Generic.List[string] foreach ($k in $bars.Keys) { $hex = ConvertTo-NormalHex $Scheme.$k if (-not $hex) { continue } if ((Get-ContrastRatio $hex $bg) -lt $bars[$k]) { $low.Add($k) } } if ($Text) { foreach ($k in $Text.Keys) { $hex = ConvertTo-NormalHex $Text[$k] if (-not $hex) { continue } if ((Get-ContrastRatio $hex $bg) -lt $bars['red']) { $low.Add([string]$k) } } } # Callers wrap in @(): a bare return unrolls to nothing when empty. $low.ToArray() } |