How to Convert an Image to Grayscale
Converting an image to grayscale sounds simple — strip the colour and you are done. In practice there are three different methods, and choosing the wrong one produces results that look flat or unnatural. This guide explains the maths, when to use each method, and shows working code in JavaScript, Python, Go, and PHP.
Why Not Just Average R, G, and B?
The most obvious approach is grey = (R + G + B) / 3. It works, but it ignores how human vision actually perceives brightness. Our eyes are much more sensitive to green than to red or blue:
| Channel | Perceived weight |
|---|---|
| Red | ~21 % |
| Green | ~72 % |
| Blue | ~7 % |
A sky that looks vivid blue will appear nearly black with the average method. The weighted luminance formula produces results that match what we see.
Three Grayscale Methods
1. Luminance (Perceptual) — recommended
grey = 0.2126 × R + 0.7152 × G + 0.0722 × B
This is the ITU-R BT.709 formula used by CSS filters, most image editors, and every serious imaging library. Use it unless you have a specific reason not to.
2. Average — fast but inaccurate
grey = (R + G + B) / 3
Useful when speed matters more than accuracy (e.g., real-time video at low resolution). Tends to make blues look too bright and reds look too dark.
3. Desaturation (HSL method)
grey = (max(R, G, B) + min(R, G, B)) / 2
Converts to HSL colour space and sets saturation to 0. Produces a different tonal range — highlights are brighter and shadows are deeper than with the luminance method.
Quick Reference
| Method | Formula | Best for |
|---|---|---|
| Luminance | 0.2126R + 0.7152G + 0.0722B | Photos, UI screenshots, printing |
| Average | (R + G + B) / 3 | Thumbnails, low-resolution masks |
| Desaturation | (max(R,G,B) + min(R,G,B)) / 2 | High-contrast artistic effects |
Code Examples
JavaScript (Browser — Canvas API)
function toGrayscale(imageElement) {
const canvas = document.createElement('canvas');
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data; // Uint8ClampedArray [R,G,B,A, R,G,B,A, ...]
for (let i = 0; i < data.length; i += 4) {
// ITU-R BT.709 luminance
const grey = Math.round(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]);
data[i] = grey; // R
data[i + 1] = grey; // G
data[i + 2] = grey; // B
// data[i + 3] = alpha — untouched
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
JavaScript (Node.js — Sharp)
import sharp from 'sharp';
await sharp('photo.jpg')
.grayscale() // uses luminance internally
.toFile('photo-grey.jpg');
Sharp's .grayscale() applies the BT.709 formula and handles all colour space conversions automatically. For JPEG output, the result is a true single-channel image (smaller file than an RGB image with equal R, G, B values).
Python (Pillow)
from PIL import Image
img = Image.open('photo.jpg')
# Luminance — Pillow uses BT.601 by default (slightly different weights)
grey = img.convert('L')
grey.save('photo-grey.jpg')
# If you need the exact BT.709 formula:
import numpy as np
arr = np.array(img.convert('RGB'), dtype=float)
grey_arr = (0.2126 * arr[:, :, 0] +
0.7152 * arr[:, :, 1] +
0.0722 * arr[:, :, 2]).clip(0, 255).astype(np.uint8)
Image.fromarray(grey_arr, mode='L').save('photo-grey-bt709.jpg')
img.convert('L') is the one-liner for most use cases. Use the NumPy path when you need strict BT.709 compliance.
Go (standard library image)
package main
import (
"image"
"image/color"
"image/jpeg"
_ "image/jpeg" // register JPEG decoder
"os"
)
func main() {
f, _ := os.Open("photo.jpg")
defer f.Close()
src, _, _ := image.Decode(f)
bounds := src.Bounds()
grey := image.NewGray(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
// color.GrayModel converts using luminance automatically
grey.Set(x, y, color.GrayModel.Convert(src.At(x, y)))
}
}
out, _ := os.Create("photo-grey.jpg")
defer out.Close()
jpeg.Encode(out, grey, &jpeg.Options{Quality: 90})
}
Go's color.GrayModel applies the standard luminance formula. The output image.Gray stores one byte per pixel — half the size of an RGBA image.
PHP (GD)
<?php
$src = imagecreatefromjpeg('photo.jpg');
// GD built-in: applies luminance formula
imagefilter($src, IMG_FILTER_GRAYSCALE);
imagejpeg($src, 'photo-grey.jpg', 90);
imagedestroy($src);
IMG_FILTER_GRAYSCALE uses a weighted luminance formula. For exact BT.709 weights or PNG output with alpha channel preserved, use Imagick instead:
<?php
$img = new Imagick('photo.png');
$img->transformImageColorspace(Imagick::COLORSPACE_GRAY);
$img->writeImage('photo-grey.png');
$img->destroy();
Preserving the Alpha Channel
By default, grayscale conversion drops the alpha channel in many libraries. For images with transparency (PNG, WebP):
- Canvas API — alpha is left untouched (you only modify R, G, B).
- Sharp —
.grayscale()preserves alpha; the output is a two-channel GA image. - Pillow —
convert('LA')keeps alpha:img.convert('LA').save('out.png'). - Go — use
image.NewNRGBAand set each pixel's alpha from the source. - Imagick — alpha is preserved automatically when the colour space supports it.
Common Pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Blues look too bright | Using the average method | Switch to luminance formula |
| Output file larger than expected | Saved as RGB with R=G=B | Use a single-channel mode (L, Gray, COLORSPACE_GRAY) |
| Alpha channel lost | Library discarded it on conversion | Use LA mode or check library docs |
| Colour fringing on edges | JPEG chroma subsampling artefacts in source | Pre-process with lossless format or higher quality JPEG |
| BT.601 vs BT.709 mismatch | Different standards give slightly different weights | Pick one standard and apply it explicitly |
Frequently Asked Questions
What is the difference between grayscale and black-and-white? Grayscale images have 256 shades of grey between pure black and pure white. "Black and white" in everyday speech usually means grayscale, but in technical contexts it can mean a 1-bit binary image where each pixel is either fully black or fully white (e.g., fax documents).
Does converting to grayscale make the file smaller? It can — but only if you use a single-channel format. A JPEG saved from an RGB image with equal R, G, B channels is the same size as a colour JPEG. A true grayscale JPEG (single channel) is roughly one-third smaller. PNG grayscale files are typically 40–60 % smaller than their RGB equivalents.
Can I convert back from grayscale to colour? You can restore an RGB structure, but the original colour information is gone. Tools like Photoshop's "Match Color" or AI-based colourisation models can make educated guesses, but they cannot recover the exact original colours.
Which formula should I use for web accessibility? The WCAG contrast ratio formula uses relative luminance based on the BT.709 coefficients (0.2126, 0.7152, 0.0722), so use those when computing contrast for accessibility checks.
How do I apply grayscale with CSS only?
img {
filter: grayscale(100%);
}
The browser applies the BT.709 luminance formula. Partial values (e.g., grayscale(50%)) blend the original colour with the greyscale result.
Does grayscale conversion affect EXIF data?
Most libraries strip EXIF when re-encoding. If you need to preserve metadata (orientation, GPS, camera model), copy EXIF explicitly — e.g., Sharp's .withMetadata() or ExifTool.