Toolmingo
Guides6 min read

How to Convert an Image to Grayscale (JavaScript, Python, Go, PHP)

Learn how to convert images to grayscale programmatically — luminance formula, average method, desaturation — with code examples in JavaScript, Python, Go, and PHP.

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.
  • Pillowconvert('LA') keeps alpha: img.convert('LA').save('out.png').
  • Go — use image.NewNRGBA and 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.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools