This documentation page is also available as an interactive notebook. You can launch the notebook in
Kaggle or Colab, or download it for use with an IDE or local Jupyter installation, by clicking one of the
above links.
Problem
You need to convert color images to grayscale for analysis,
preprocessing, or model inputs that require single-channel images.
Different conversion methods produce different results—you need to
choose the right approach for your use case.
Solution
What’s in this recipe:
- Simple conversion with PIL
- Perceptually accurate grayscale (weighted RGB channels)
- Custom UDF for advanced conversion
You convert RGB images to grayscale in your table using either
Pixeltable’s built-in .convert() method for standard conversion, or a
custom UDF (relies on NumPy and PIL/Pillow) for gamma-corrected
conversion when scientific accuracy matters.
You can iterate on transformations before adding them to your table. Use
.select() with .collect() to preview results on sample
images—nothing is stored in your table. If you want to collect only the
first few rows, use .head(n) instead of .collect(). Once you’re
satisfied, use .add_computed_column() to apply the conversion to all
images in your table.
For more on this workflow, see Get fast feedback on
transformations.
Conversion methods:
The simple method uses PIL’s built-in conversion. The gamma-corrected
method requires a custom UDF (not built into PIL) that applies
perceptual weighting in linear color space.
For technical details on gamma correction and grayscale conversion, see
Wikipedia: Grayscale.
Setup
%pip install -qU pixeltable numpy
import pixeltable as pxt
import numpy as np
from PIL import Image
Load images
# Create a fresh directory (drop existing if present)
pxt.drop_dir('image_demo', force=True)
pxt.create_dir('image_demo')
Connected to Pixeltable database at: postgresql+psycopg://postgres:@/pixeltable?host=/Users/alison-pxt/.pixeltable/pgdata
Created directory ‘image_demo’.
<pixeltable.catalog.dir.Dir at 0x3035ae8b0>
t = pxt.create_table('image_demo.gray', {'image': pxt.Image})
Created table ‘gray’.
t.insert([
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000036.jpg'},
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000090.jpg'},
{'image': 'https://raw.githubusercontent.com/pixeltable/pixeltable/main/docs/resources/images/000000000106.jpg'},
])
Inserting rows into `gray`: 3 rows [00:00, 1134.72 rows/s]
Inserted 3 rows with 0 errors.
3 rows inserted, 6 values computed.
# View loaded images
t.collect()
Iterate: Convert with linear approximation for a few images first
# Query: Preview the conversion
t.select(t.image, t.image.convert('L')).head(1)
Add: Convert with linear approximation for all images in your table
# Commit: Save as computed column (built-in PIL conversion - fast and good for most use cases)
t.add_computed_column(grayscale=t.image.convert('L'))
Added 3 column values with 0 errors.
3 rows updated, 3 values computed.
# View images with grayscale column
t.collect()
Iterate: Convert with gamma decompression for a few images first
@pxt.udf
def rgb_to_gray_accurate(img: Image.Image) -> Image.Image:
"""Convert RGB to grayscale with full gamma correction.
Most accurate but slower. Gamma-decompresses, applies perceptual weights
in linear space, then re-compresses for display.
"""
rgb = np.array(img).astype(np.float32) / 255.0
# Gamma decompress: make pixel values perceptually linear
rgb_lin = ((rgb + 0.055) / 1.055) ** 2.4
rgb_lin = np.where(rgb <= 0.04045, rgb / 12.92, rgb_lin)
# Apply perceptual weights in linear space
gray_lin = (
0.2126 * rgb_lin[:, :, 0] +
0.7152 * rgb_lin[:, :, 1] +
0.0722 * rgb_lin[:, :, 2]
)
# Gamma compress: make values display-ready
gray = 1.055 * gray_lin ** (1 / 2.4) - 0.055
gray = np.where(gray_lin <= 0.0031308, 12.92 * gray_lin, gray)
gray = (gray * 255).astype(np.uint8)
return Image.fromarray(gray)
# Compare both methods on first image
t.select(
t.image,
t.grayscale,
rgb_to_gray_accurate(t.image)
).head(1)
Add: Convert with gamma decompression for all images in your table
t.add_computed_column(accurate=rgb_to_gray_accurate(t.image))
Added 3 column values with 0 errors.
3 rows updated, 3 values computed.
# View all results
t.collect()
Explanation
Two approaches:
-
Simple (
.convert('L')): PIL’s built-in. Fast, good for most
use cases (model preprocessing, general analysis).
-
Gamma-corrected (custom UDF): Not built into PIL. Requires a
custom UDF that:
- Gamma-decompresses to linear space
- Applies perceptual weights: 0.2126 × R + 0.7152 × G + 0.0722 × B
- Gamma-compresses back for display
- Slower but most perceptually accurate
- Use for scientific imaging, professional photography
Why gamma matters: Displays aren’t linear—doubling a pixel value
doesn’t double perceived brightness. Gamma correction accounts for this.
For best results, convert to linear space before weighting, then convert
back.
The gamma-corrected method is based on Brandon Rohrer’s
explanation of
perceptually accurate RGB to grayscale conversion.
The Pixeltable workflow:
In traditional databases, .select() just picks which columns to view.
In Pixeltable, .select() also lets you compute new transformations on
the fly—define new columns without storing them. This makes .select()
perfect for testing transformations before you commit them.
When you use .select(), you’re creating a query that doesn’t execute
until you call .collect(). You must use .collect() to execute the
query and return results—nothing is stored in your table. If you want to
collect only the first few rows, use .head(n) instead of .collect()
to test on a subset before processing your full dataset. Once satisfied,
use .add_computed_column() with the same expression to persist results
permanently.
For more on this workflow, see Get fast feedback on
transformations.
See also