Papa Labs

Remove a logo's white background with pure PowerShell (halo-free)

Building this site’s header surfaced a classic small problem: the only brand logo available was an opaque white-background PNG (24bpp, no alpha channel). Drop it on a dark-mode page and you get a glaring white rectangle.

No Photoshop on this machine, and installing ImageMagick for one job felt silly — Windows’ built-in .NET System.Drawing is enough.

Before/after: the white-background PNG is a white slab on a dark page; after processing the background is fully transparent

Left: the original dropped straight onto a dark page. Right: the script’s output.

The approach

  1. Redraw the image onto a 32bpp (alpha-capable) canvas;
  2. Walk the pixels: near-white becomes fully transparent;
  3. The crucial part: edge pixels get graduated transparency, otherwise you cut out a white halo around everything.

“Near-white” is judged by the minimum of the RGB channels, m:

  • m ≥ 238 → fully transparent (background);
  • 210 ≤ m < 238 → proportional partial alpha (anti-aliased edges);
  • everything else → untouched (the logo itself).

The code

Performance matters: don’t loop with GetPixel/SetPixel (hundreds of thousands of method calls — minutes in PowerShell). Use LockBits and work on the raw byte array:

Add-Type -AssemblyName System.Drawing

$src = New-Object System.Drawing.Bitmap "logo.png"
$bmp = New-Object System.Drawing.Bitmap $src.Width, $src.Height, `
  ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.DrawImage($src, 0, 0, $src.Width, $src.Height)
$g.Dispose(); $src.Dispose()

$rect = New-Object System.Drawing.Rectangle 0, 0, $bmp.Width, $bmp.Height
$data = $bmp.LockBits($rect, 'ReadWrite', $bmp.PixelFormat)
$bytes = New-Object byte[] ($data.Stride * $data.Height)
[System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $bytes.Length)

for ($i = 0; $i -lt $bytes.Length; $i += 4) {
  # BGRA order
  $m = [Math]::Min([Math]::Min([int]$bytes[$i], [int]$bytes[$i+1]), [int]$bytes[$i+2])
  if ($m -ge 238) { $bytes[$i+3] = 0 }
  elseif ($m -ge 210) { $bytes[$i+3] = [byte][Math]::Round(255 * (238 - $m) / 28) }
}

[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $data.Scan0, $bytes.Length)
$bmp.UnlockBits($data)
$bmp.Save("logo-transparent.png", [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()

Two things that bit me

  • Pixels are BGRA, not RGBA: LockBits hands you Blue, Green, Red, Alpha. Mix up the channels and every threshold decision is wrong;
  • The background isn’t pure white: real “white” backgrounds are gradients like #fefefc and #f8f9fb (especially AI-generated or scanned images). Leave headroom in the threshold — don’t hardcode 255.

The dark-mode follow-up

Transparency isn’t the whole story: dark text in the logo has poor contrast on a dark background. My solution was to crop the icon and the wordmark separately, and in dark mode run a CSS filter only on the text portion, keeping the icon in brand colors:

.dark .wordmark img.word {
  filter: brightness(0) invert(0.92);
}

Zero installs, all command line — the header of this very site is this script’s output.

← All posts