Private/ConvertTo-XKCDSixel.ps1
|
Function ConvertTo-XKCDSixel { <# .SYNOPSIS Converts raw image bytes into a Sixel escape sequence that can be written to a Sixel-capable terminal. .DESCRIPTION Each colour channel is posterized to 6 levels (216 colours max) to keep the palette within a size that's practical to build without a full colour-quantization algorithm. This works well for xkcd's mostly black-and-white line art, at the cost of some banding on any gradients/photos. #> [cmdletbinding()] Param( # The raw bytes of the image to convert (e.g. PNG or JPEG). [Parameter(Mandatory)] [byte[]] $ImageBytes, # The maximum pixel width to render the image at. Larger images are downscaled to this width, preserving aspect ratio. [int] $MaxWidth = 640 ) Add-Type -AssemblyName System.Drawing $stream = [System.IO.MemoryStream]::new($ImageBytes) $source = [System.Drawing.Bitmap]::new($stream) if ($source.Width -gt $MaxWidth) { $newHeight = [int]($source.Height * ($MaxWidth / $source.Width)) $bitmap = [System.Drawing.Bitmap]::new($source, $MaxWidth, $newHeight) $source.Dispose() } else { $bitmap = $source } $width = $bitmap.Width $height = $bitmap.Height $rect = [System.Drawing.Rectangle]::new(0, 0, $width, $height) $bitmapData = $bitmap.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) $pixelBytes = [byte[]]::new($bitmapData.Stride * $height) [System.Runtime.InteropServices.Marshal]::Copy($bitmapData.Scan0, $pixelBytes, 0, $pixelBytes.Length) $stride = $bitmapData.Stride $bitmap.UnlockBits($bitmapData) $bitmap.Dispose() # Posterize each pixel to a 6x6x6 palette and record a colour index per pixel. $colorIndex = [int[]]::new($width * $height) $palette = @{} for ($y = 0; $y -lt $height; $y++) { $rowOffset = $y * $stride for ($x = 0; $x -lt $width; $x++) { $pixelOffset = $rowOffset + ($x * 4) $blue = $pixelBytes[$pixelOffset] $green = $pixelBytes[$pixelOffset + 1] $red = $pixelBytes[$pixelOffset + 2] $rLevel = [int][math]::Round($red / 255 * 5) $gLevel = [int][math]::Round($green / 255 * 5) $bLevel = [int][math]::Round($blue / 255 * 5) $key = ($rLevel * 36) + ($gLevel * 6) + $bLevel if (-not $palette.Contains($key)) { $palette[$key] = [pscustomobject]@{ Index = $palette.Count R = [int]($rLevel / 5 * 100) G = [int]($gLevel / 5 * 100) B = [int]($bLevel / 5 * 100) } } $colorIndex[($y * $width) + $x] = $palette[$key].Index } } $esc = [char]27 $sb = [System.Text.StringBuilder]::new() [void]$sb.Append($esc).Append('Pq"1;1;').Append($width).Append(';').Append($height) foreach ($entry in $palette.Values) { [void]$sb.Append('#').Append($entry.Index).Append(';2;').Append($entry.R).Append(';').Append($entry.G).Append(';').Append($entry.B) } for ($bandStart = 0; $bandStart -lt $height; $bandStart += 6) { $bandHeight = [Math]::Min(6, $height - $bandStart) $bandColors = [System.Collections.Generic.HashSet[int]]::new() for ($row = 0; $row -lt $bandHeight; $row++) { $y = $bandStart + $row for ($x = 0; $x -lt $width; $x++) { [void]$bandColors.Add($colorIndex[($y * $width) + $x]) } } $isFirstColor = $true foreach ($color in $bandColors) { if (-not $isFirstColor) { [void]$sb.Append('$') } $isFirstColor = $false [void]$sb.Append('#').Append($color) $lastValue = -1 $runLength = 0 for ($x = 0; $x -lt $width; $x++) { $value = 0 for ($row = 0; $row -lt $bandHeight; $row++) { $y = $bandStart + $row if ($colorIndex[($y * $width) + $x] -eq $color) { $value = $value -bor (1 -shl $row) } } if ($value -eq $lastValue) { $runLength++ } else { if ($runLength -gt 0) { $runChar = [char](63 + $lastValue) if ($runLength -gt 1) { [void]$sb.Append('!').Append($runLength) } [void]$sb.Append($runChar) } $lastValue = $value $runLength = 1 } } if ($runLength -gt 0) { $runChar = [char](63 + $lastValue) if ($runLength -gt 1) { [void]$sb.Append('!').Append($runLength) } [void]$sb.Append($runChar) } } [void]$sb.Append('-') } [void]$sb.Append($esc).Append('\') $sb.ToString() } |