Classes/ColorMath.ps1
|
<#
.SYNOPSIS The color arithmetic of gradients and color forms: OKLab, HSL and the 256 colors. .DESCRIPTION Written so Windows PowerShell 5.1, PowerShell 7 and PWRSWriteColorEX give the same results to the last bit: the constants are the bit patterns of their doubles rather than decimal numbers a parser may round either way, and the arithmetic is +, -, * and / in a fixed order, which IEEE 754 rounds the same everywhere. A class rather than functions because a gradient computes a color for every character, and a function call costs more than that arithmetic. .NOTES Author: Mark Newton License: MIT Requires: PowerShell 5.1 or later #> class ColorMath { # The linear value of each sRGB channel value 0-255: ((c / 255 + 0.055) / 1.055) ^ 2.4, or # c / 255 / 12.92 at the dark end. It rises, so a linear value finds its nearest channel # value by comparing with its neighbors. static [double[]] $SrgbToLinear = [ColorMath]::LinearTable() # Bjorn Ottosson's OKLab matrices: linear sRGB to LMS, the cube roots of LMS to OKLab, OKLab # to the cube roots of LMS, and LMS to linear sRGB static [double] $L1 = [BitConverter]::Int64BitsToDouble(0x3FDA61D629F2E197) static [double] $L2 = [BitConverter]::Int64BitsToDouble(0x3FE129A2D9E60E32) static [double] $L3 = [BitConverter]::Int64BitsToDouble(0x3FAA572112081026) static [double] $M1 = [BitConverter]::Int64BitsToDouble(0x3FCB1FA76156A7C5) static [double] $M2 = [BitConverter]::Int64BitsToDouble(0x3FE5C84A69936914) static [double] $M3 = [BitConverter]::Int64BitsToDouble(0x3FBB7E5DF0497455) static [double] $S1 = [BitConverter]::Int64BitsToDouble(0x3FB69AFD7A044C17) static [double] $S2 = [BitConverter]::Int64BitsToDouble(0x3FD207AE728A2F45) static [double] $S3 = [BitConverter]::Int64BitsToDouble(0x3FE428C9177A5EDB) static [double] $LL = [BitConverter]::Int64BitsToDouble(0x3FCAF02A3FE8A4FA) static [double] $LM = [BitConverter]::Int64BitsToDouble(0x3FE9655120032AAD) static [double] $LS = -[BitConverter]::Int64BitsToDouble(0x3F70ADD9BD572B38) static [double] $AL = [BitConverter]::Int64BitsToDouble(0x3FFFA5E1BFFFDE12) static [double] $AM = -[BitConverter]::Int64BitsToDouble(0x40036DC1BFFE5D3E) static [double] $AS = [BitConverter]::Int64BitsToDouble(0x3FDCD686FFF371A5) static [double] $BL = [BitConverter]::Int64BitsToDouble(0x3F9A869680B729E0) static [double] $BM = [BitConverter]::Int64BitsToDouble(0x3FE90C776001F502) static [double] $BS = -[BitConverter]::Int64BitsToDouble(0x3FE9E0AC0001353D) static [double] $LA = [BitConverter]::Int64BitsToDouble(0x3FD95D9920068C8A) static [double] $LB = [BitConverter]::Int64BitsToDouble(0x3FCB9F751FFA8CC8) static [double] $MA = -[BitConverter]::Int64BitsToDouble(0x3FBB06117FEEC881) static [double] $MB = -[BitConverter]::Int64BitsToDouble(0x3FB058BF3FE39E34) static [double] $SA = -[BitConverter]::Int64BitsToDouble(0x3FB6E86F5FDF38B5) static [double] $SB = -[BitConverter]::Int64BitsToDouble(0x3FF4A9ECBFFEAA8D) static [double] $R1 = [BitConverter]::Int64BitsToDouble(0x40104E955DC3D73A) static [double] $R2 = -[BitConverter]::Int64BitsToDouble(0x400A76317EA9DE73) static [double] $R3 = [BitConverter]::Int64BitsToDouble(0x3FCD906C3222FFEF) static [double] $G1 = -[BitConverter]::Int64BitsToDouble(0x3FF44B85A62C2AFF) static [double] $G2 = [BitConverter]::Int64BitsToDouble(0x4004E0C87D01BF65) static [double] $G3 = -[BitConverter]::Int64BitsToDouble(0x3FD5D82D4F5D4F2A) static [double] $B1 = -[BitConverter]::Int64BitsToDouble(0x3F712FEA56E00671) static [double] $B2 = -[BitConverter]::Int64BitsToDouble(0x3FE68267C131178D) static [double] $B3 = [BitConverter]::Int64BitsToDouble(0x3FFB5263CAEF6BCD) hidden static [double[]] LinearTable() { $bits = [long[]]@( 0x0000000000000000, 0x3F33E45677C176F7, 0x3F43E45677C176F7, 0x3F4DD681B3A23272, 0x3F53E45677C176F7, 0x3F58DD6C15B1D4B4, 0x3F5DD681B3A23272, 0x3F6167CBA8C94818, 0x3F63E45677C176F7, 0x3F6660E146B9A5D5, 0x3F68DD6C15B1D4B4, 0x3F6B6A31B5259C99, 0x3F6E1E31D70C99DD, 0x3F707C38BF8583A9, 0x3F71FCC2BEED6421, 0x3F7390FFAF95E279, 0x3F753936CC7BC928, 0x3F76F5ADDB50C915, 0x3F78C6A94031B561, 0x3F7AAC6C0FB97351, 0x3F7CA7381F9F602B, 0x3F7EB74E160978D0, 0x3F806E76BBDA92B8, 0x3F818C2A5A8A8044, 0x3F82B4E09B3F0AE3, 0x3F83E8B7B3BDE965, 0x3F8527CD60AF8B85, 0x3F86723EEA8D3709, 0x3F87C8292A3DB6B3, 0x3F8929A88D67B521, 0x3F8A96D91A8016BD, 0x3F8C0FD67499FAB6, 0x3F8D94BBDEFD740E, 0x3F8F25A44089883F, 0x3F9061551372C694, 0x3F9135F3E4C2CCE2, 0x3F9210BB8642B172, 0x3F92F1B8C1AE46BD, 0x3F93D8F839B79C0B, 0x3F94C6866B3E9FA4, 0x3F95BA6FAE794313, 0x3F96B4C0380D2DEE, 0x3F97B5841A1BF3AC, 0x3F98BCC74542ADDB, 0x3F99CA95898DC8B5, 0x3F9ADEFA9761C020, 0x3F9BFA0200597BD9, 0x3F9D1BB7381AEC1F, 0x3F9E442595227BCA, 0x3F9F73585185E1B5, 0x3FA054AD45D76878, 0x3FA0F31BA386FF26, 0x3FA194FCB663747B, 0x3FA23A55E62A662A, 0x3FA2E32C8E148D11, 0x3FA38F85FD21EACF, 0x3FA43F67766310FF, 0x3FA4F2D6313FA8D0, 0x3FA5A9D759BA5ED0, 0x3FA6647010B254EE, 0x3FA722A56C2239EE, 0x3FA7E47C775D2427, 0x3FA8A9FA33494B07, 0x3FA973239698B9CC, 0x3FAA3FFD8E001389, 0x3FAB108CFC6B7FBC, 0x3FABE4D6BB31D522, 0x3FACBCDF9A4616F2, 0x3FAD98AC60675833, 0x3FAE7841CB4F16DF, 0x3FAF5BA48FDE2048, 0x3FB0216CAD240765, 0x3FB096F2671EB815, 0x3FB10E65C38A5192, 0x3FB187C90BF8BCE2, 0x3FB2031E85F5D6DA, 0x3FB28068731A1952, 0x3FB2FFA9111CB94B, 0x3FB380E299E53F92, 0x3FB40417439CA10F, 0x3FB4894940BDDBFB, 0x3FB5107AC0261E59, 0x3FB599ADED247AAC, 0x3FB624E4EF892ED4, 0x3FB6B221EBB4817E, 0x3FB7416702A539D1, 0x3FB7D2B65206B527, 0x3FB86611F43E9E6A, 0x3FB8FB7C007A4A70, 0x3FB992F68ABBBC89, 0x3FBA2C83A3E6566D, 0x3FBAC82559CB3644, 0x3FBB65DDB7354604, 0x3FBC05AEC3F4FE5E, 0x3FBCA79A84EBE030, 0x3FBD4BA2FC17A6A5, 0x3FBDF1CA289D34B8, 0x3FBE9A1206D34003, 0x3FBF447C904CBB4E, 0x3FBFF10BBBE302C2, 0x3FC04FE0BEDFE5F1, 0x3FC0A84FE3B36D8F, 0x3FC101D443DFC06F, 0x3FC15C6ED58EEFDF, 0x3FC1B8208DA5FEF0, 0x3FC214EA5FC9514A, 0x3FC272CD3E610123, 0x3FC2D1CA1A9D1CFB, 0x3FC331E1E479CDF5, 0x3FC393158AC3674E, 0x3FC3F565FB1A5FD5, 0x3FC458D421F735DF, 0x3FC4BD60EAAE3E73, 0x3FC5230D3F736034, 0x3FC589DA095DBAA1, 0x3FC5F1C8306B3A3C, 0x3FC65AD89B841A2B, 0x3FC6C50C307E53BF, 0x3FC73063D420FC80, 0x3FC79CE06A279303, 0x3FC80A82D5453B5D, 0x3FC8794BF727EB3F, 0x3FC8E93CB07B8679, 0x3FC95A55E0ECEC0B, 0x3FC9CC98672CF47E, 0x3FCA400520F3619C, 0x3FCAB49CEB01C003, 0x3FCB2A60A1263B0A, 0x3FCBA1511E3E632D, 0x3FCC196F3C39E76F, 0x3FCC92BBD41D41FE, 0x3FCD0D37BE045851, 0x3FCD88E3D1250F68, 0x3FCE05C0E3D1D3E0, 0x3FCE83CFCB7C16F0, 0x3FCF03115CB6BFD3, 0x3FCF83866B38924D, 0x3FD00297E4EF4553, 0x3FD044072557177A, 0x3FD086115F6BEB3A, 0x3FD0C8B6FB5C735E, 0x3FD10BF860EF039A, 0x3FD14FD5F782A5A6, 0x3FD1945026102997, 0x3FD1D967532B31B1, 0x3FD21F1BE50339E7, 0x3FD2656E41649AE3, 0x3FD2AC5ECDB988F8, 0x3FD2F3EDEF0B0ED8, 0x3FD33C1C0A020438, 0x3FD384E982E800B1, 0x3FD3CE56BDA84A81, 0x3FD418641DD0C1BC, 0x3FD463120692C7AF, 0x3FD4AE60DAC4229D, 0x3FD4FA50FCDFDE15, 0x3FD546E2CF0727A9, 0x3FD59416B3022858, 0x3FD5E1ED0A40DAAB, 0x3FD6306635DBDD7B, 0x3FD67F82969543A2, 0x3FD6CF428CD96079, 0x3FD71FA678BF915D, 0x3FD770AEBA0B042A, 0x3FD7C25BB02B7AC5, 0x3FD814ADBA3E0BD9, 0x3FD867A5370DE0B1, 0x3FD8BB428514F067, 0x3FD90F86027CB84E, 0x3FD964700D1EF1B1, 0x3FD9BA0102864521, 0x3FDA10393FEEFAFD, 0x3FDA67192247A9BE, 0x3FDABEA10631E195, 0x3FDB16D14802D5CA, 0x3FDB6FAA43C403BB, 0x3FDBC92C5533D785, 0x3FDC2357D7C64E5D, 0x3FDC7E2D26A596DE, 0x3FDCD9AC9CB2AEF2, 0x3FDD35D69485FFC5, 0x3FDD92AB686FF782, 0x3FDDF02B7279A10D, 0x3FDE4E570C6539C5, 0x3FDEAD2E8FAEC526, 0x3FDF0CB2558C9EA4, 0x3FDF6CE2B6F00983, 0x3FDFCDC00C85BEC2, 0x3FE017A5575B3CB2, 0x3FE048C17AD3C04B, 0x3FE07A349C9D9837, 0x3FE0ABFEE888C050, 0x3FE0DE208A4444C8, 0x3FE11099AD5E83EB, 0x3FE1436A7D456EEF, 0x3FE176932546CA12, 0x3FE1AA13D0906BDA, 0x3FE1DDECAA307B85, 0x3FE2121DDD15AECE, 0x3FE246A7940F86D1, 0x3FE27B89F9CE8C4B, 0x3FE2B0C538E48B07, 0x3FE2E6597BC4CCA0, 0x3FE31C46ECC4528D, 0x3FE3528DB61A0F73, 0x3FE3892E01DF1FCC, 0x3FE3C027FA0F01EB, 0x3FE3F77BC887CD3B, 0x3FE42F29970A68F8, 0x3FE467318F3AC22D, 0x3FE49F93DAA00113, 0x3FE4D850A2A4BDE1, 0x3FE51168109734E5, 0x3FE54ADA4DA97A1B, 0x3FE584A782F1AC23, 0x3FE5BECFD96A2698, 0x3FE5F95379F1B3ED, 0x3FE634328D4BBE97, 0x3FE66F6D3C2081CF, 0x3FE6AB03AEFD39AA, 0x3FE6E6F60E5452B1, 0x3FE72344827D98F6, 0x3FE75FEF33B6669B, 0x3FE79CF64A21D1E2, 0x3FE7DA59EDC8DAB0, 0x3FE8181A469A9787, 0x3FE856377C6C6224, 0x3FE894B1B6FA0377, 0x3FE8D3891DE5DF49, 0x3FE912BDD8B91F45, 0x3FE952500EE3DDA5, 0x3FE9923FE7BD4F67, 0x3FE9D28D8A83EDFC, 0x3FEA13391E5DA09F, 0x3FEA5442CA57E52E, 0x3FEA95AAB567F88F, 0x3FEAD771066AFEC2, 0x3FEB1995E4262A69, 0x3FEB5C197546E3F8, 0x3FEB9EFBE062F086, 0x3FEBE23D4BF8981B, 0x3FEC25DDDE6ECBBB, 0x3FEC69DDBE154AF1, 0x3FECAE3D1124C90B, 0x3FECF2FBFDBF11F1, 0x3FED381AA9EF2E82, 0x3FED7D993BA988D4, 0x3FEDC377D8CC0FD5, 0x3FEE09B6A71E5AA6, 0x3FEE5055CC51CBB4, 0x3FEE97556E01B351, 0x3FEEDEB5B1B37216, 0x3FEF2676BCD69ADE, 0x3FEF6E98B4C51466, 0x3FEFB71BBEC33AB2, 0x3FF0000000000000 ) $table = [double[]]::new(256) for ($i = 0; $i -lt 256; $i++) { $table[$i] = [BitConverter]::Int64BitsToDouble($bits[$i]) } return $table } # The cube root of a number from 0 to 1, by 40 Newton steps from 1. [Math]::Pow and Math.Cbrt # may differ in the last bit between .NET versions and libraries; Newton's steps use only *, # + and /, which do not. From 1 the steps fall to the root, and 40 of them reach it from any # LMS value a color gives. static [double] CubeRoot([double] $Value) { if ($Value -le 0.0) { return 0.0 } $root = 1.0 for ($step = 0; $step -lt 40; $step++) { $root = (2.0 * $root + $Value / ($root * $root)) / 3.0 } return $root } # The OKLab coordinates L, a and b of an sRGB color given as three channel values 0-255 static [double[]] ToOkLab([int[]] $Rgb) { $table = [ColorMath]::SrgbToLinear $red = $table[$Rgb[0]] $green = $table[$Rgb[1]] $blue = $table[$Rgb[2]] $l = [ColorMath]::CubeRoot([ColorMath]::L1 * $red + [ColorMath]::L2 * $green + [ColorMath]::L3 * $blue) $m = [ColorMath]::CubeRoot([ColorMath]::M1 * $red + [ColorMath]::M2 * $green + [ColorMath]::M3 * $blue) $s = [ColorMath]::CubeRoot([ColorMath]::S1 * $red + [ColorMath]::S2 * $green + [ColorMath]::S3 * $blue) return [double[]]@( ([ColorMath]::LL * $l + [ColorMath]::LM * $m + [ColorMath]::LS * $s), ([ColorMath]::AL * $l + [ColorMath]::AM * $m + [ColorMath]::AS * $s), ([ColorMath]::BL * $l + [ColorMath]::BM * $m + [ColorMath]::BS * $s) ) } # The sRGB channel values of a hue in degrees and a saturation and lightness from 0 to 100: # C = (1 - |2L - 1|) * S, X = C * (1 - |(H / 60) mod 2 - 1|), m = L - C / 2, each channel # rounded half to even after multiplying by 255 static [int[]] FromHsl([double] $Hue, [double] $Saturation, [double] $Lightness) { $h = $Hue - 360.0 * [Math]::Floor($Hue / 360.0) $s = [Math]::Min(100.0, [Math]::Max(0.0, $Saturation)) / 100.0 $l = [Math]::Min(100.0, [Math]::Max(0.0, $Lightness)) / 100.0 $chroma = (1.0 - [Math]::Abs(2.0 * $l - 1.0)) * $s $sector = $h / 60.0 $x = $chroma * (1.0 - [Math]::Abs(($sector - 2.0 * [Math]::Floor($sector / 2.0)) - 1.0)) $offset = $l - $chroma / 2.0 $parts = switch ([int][Math]::Floor($sector)) { 0 { $chroma, $x, 0.0 } 1 { $x, $chroma, 0.0 } 2 { 0.0, $chroma, $x } 3 { 0.0, $x, $chroma } 4 { $x, 0.0, $chroma } default { $chroma, 0.0, $x } } return [int[]]@( [int][Math]::Round(($parts[0] + $offset) * 255.0, [MidpointRounding]::ToEven), [int][Math]::Round(($parts[1] + $offset) * 255.0, [MidpointRounding]::ToEven), [int][Math]::Round(($parts[2] + $offset) * 255.0, [MidpointRounding]::ToEven) ) } # Each channel value's step in the 6x6x6 color cube, 0 to 5 static [int[]] $CubeSteps = [ColorMath]::CubeStepTable() hidden static [int[]] CubeStepTable() { $steps = [int[]]::new(256) for ($i = 0; $i -lt 256; $i++) { if ($i -lt 48) { $steps[$i] = 0 } elseif ($i -lt 115) { $steps[$i] = 1 } elseif ($i -lt 155) { $steps[$i] = 2 } elseif ($i -lt 195) { $steps[$i] = 3 } elseif ($i -lt 235) { $steps[$i] = 4 } else { $steps[$i] = 5 } } return $steps } # One color for each of Steps characters, blended evenly between the waypoints, which are RGB # arrays: RGB arrays, or 256-color codes with Ansi8. Each step falls between the two # waypoints around it, at a ratio from 0 to 1 between them; the first and last step of each # stretch are the waypoints themselves. With OkLab the blend is in OKLab, else each channel on # its own, rounded half to even. The conversions are written out in the loop because a # method call costs more than the arithmetic of a step. static [object[]] Blend([object[]] $Waypoints, [int] $Steps, [bool] $Ansi8, [bool] $OkLab) { $points = [int[][]]::new($Waypoints.Count) $labs = [double[][]]::new($Waypoints.Count) for ($w = 0; $w -lt $Waypoints.Count; $w++) { $points[$w] = [int[]]$Waypoints[$w] if ($OkLab) { $labs[$w] = [ColorMath]::ToOkLab($points[$w]) } } $table = [ColorMath]::SrgbToLinear $cube = [ColorMath]::CubeSteps $linear = [double[]]::new(3) $out = [object[]]::new($Steps) $segmentCount = $Waypoints.Count - 1 for ($step = 0; $step -lt $Steps; $step++) { if ($Steps -eq 1) { $segmentIndex = 0 $ratio = 0.0 } elseif ($segmentCount -eq 1) { $segmentIndex = 0 $ratio = [double]$step / ($Steps - 1) } else { $position = [double]$step / ($Steps - 1) * $segmentCount $segmentIndex = [Math]::Min([int][Math]::Floor($position), $segmentCount - 1) $ratio = $position - $segmentIndex } $start = $points[$segmentIndex] $end = $points[$segmentIndex + 1] $rgb = [int[]]::new(3) if ($ratio -eq 0.0) { $rgb[0] = $start[0] $rgb[1] = $start[1] $rgb[2] = $start[2] } elseif ($ratio -eq 1.0) { $rgb[0] = $end[0] $rgb[1] = $end[1] $rgb[2] = $end[2] } elseif ($OkLab) { # The OKLab coordinates between the two waypoints, to LMS and on to linear sRGB $startLab = $labs[$segmentIndex] $endLab = $labs[$segmentIndex + 1] $lightness = $startLab[0] + ($endLab[0] - $startLab[0]) * $ratio $greenRed = $startLab[1] + ($endLab[1] - $startLab[1]) * $ratio $blueYellow = $startLab[2] + ($endLab[2] - $startLab[2]) * $ratio $lRoot = $lightness + [ColorMath]::LA * $greenRed + [ColorMath]::LB * $blueYellow $mRoot = $lightness + [ColorMath]::MA * $greenRed + [ColorMath]::MB * $blueYellow $sRoot = $lightness + [ColorMath]::SA * $greenRed + [ColorMath]::SB * $blueYellow $l = $lRoot * $lRoot * $lRoot $m = $mRoot * $mRoot * $mRoot $s = $sRoot * $sRoot * $sRoot $linear[0] = [ColorMath]::R1 * $l + [ColorMath]::R2 * $m + [ColorMath]::R3 * $s $linear[1] = [ColorMath]::G1 * $l + [ColorMath]::G2 * $m + [ColorMath]::G3 * $s $linear[2] = [ColorMath]::B1 * $l + [ColorMath]::B2 * $m + [ColorMath]::B3 * $s # Each linear value to the channel value whose linear value is nearest, the lower # of two as near; below 0 is 0 and above 1 is 255. The sRGB curve gives a first # guess, which may be off where [Math]::Pow differs between platforms; the # table then decides, so the answer does not depend on [Math]::Pow. for ($channel = 0; $channel -lt 3; $channel++) { $value = $linear[$channel] if ($value -le 0.0) { $rgb[$channel] = 0 } elseif ($value -ge 1.0) { $rgb[$channel] = 255 } else { $low = [int][Math]::Floor((1.055 * [Math]::Pow($value, 1.0 / 2.4) - 0.055) * 255.0) if ($low -lt 0) { $low = 0 } elseif ($low -gt 254) { $low = 254 } while ($low -lt 254 -and $table[$low + 1] -le $value) { $low++ } while ($low -gt 0 -and $table[$low] -gt $value) { $low-- } if ($table[$low + 1] - $value -lt $value - $table[$low]) { $rgb[$channel] = $low + 1 } else { $rgb[$channel] = $low } } } } else { $rgb[0] = [int][Math]::Round($start[0] + ($end[0] - $start[0]) * $ratio, [MidpointRounding]::ToEven) $rgb[1] = [int][Math]::Round($start[1] + ($end[1] - $start[1]) * $ratio, [MidpointRounding]::ToEven) $rgb[2] = [int][Math]::Round($start[2] + ($end[2] - $start[2]) * $ratio, [MidpointRounding]::ToEven) } if (-not $Ansi8) { $out[$step] = $rgb continue } # The nearest 256-color code, as Convert-RGBToANSI8 answers it: one of the 24 grays # for a color whose channels are within 10 of each other, black (16) and white (231) # at the ends, else the 6x6x6 cube $redGreen = $rgb[0] - $rgb[1] $greenBlue = $rgb[1] - $rgb[2] if ($redGreen -gt -10 -and $redGreen -lt 10 -and $greenBlue -gt -10 -and $greenBlue -lt 10) { $gray = [Math]::Round(($rgb[0] + $rgb[1] + $rgb[2]) / 3.0) if ($gray -lt 8) { $out[$step] = 16 } elseif ($gray -gt 248) { $out[$step] = 231 } else { $out[$step] = 232 + [int][Math]::Min(23, [Math]::Round(($gray - 8) / 10.0)) } } else { $out[$step] = 16 + 36 * $cube[$rgb[0]] + 6 * $cube[$rgb[1]] + $cube[$rgb[2]] } } return $out } } |