Images as arrays of pixels

Represent a grayscale image as rows of numbers, convert color to gray, and adjust brightness with clipping.

Python
image = [
    [0, 50, 100],
    [150, 200, 250],
]


def to_gray(pixel):
    red, green, blue = pixel
    return round(0.299 * red + 0.587 * green + 0.114 * blue)


def brighten(img, amount):
    return [[min(255, max(0, value + amount)) for value in row] for row in img]


def normalize(img):
    return [[value / 255 for value in row] for row in img]


print(len(image), len(image[0]))
print(to_gray((200, 100, 50)), to_gray((255, 255, 255)))
print(brighten(image, 100))
print([[round(value, 2) for value in row] for row in normalize(image)])
print(image[1][2])
Output
2 3
124 255
[[100, 150, 200], [250, 255, 255]]
[[0.0, 0.2, 0.39], [0.59, 0.78, 0.98]]
250

Explanation

To a computer, a digital image is a grid of numbers. Each grid cell is a pixel. A grayscale image has one number per pixel, usually an 8-bit integer from 0 (black) to 255 (white). A color image has several channels per pixel, most often red, green, and blue, so its shape is height × width × 3. In Python, a small image is naturally a list of rows, and image[row][col] reads one pixel. The first index is the row, which is the vertical position y, and the second is the column, the horizontal position x, so coordinates are often written (row, col) rather than (x, y).

Converting color to gray means weighting the channels, not just averaging them, because the eye is most sensitive to green and least to blue. The standard luma weights from ITU-R BT.601 are 0.299 for red, 0.587 for green, and 0.114 for blue. The orange pixel (200, 100, 50) becomes about 124, and white (255, 255, 255) stays 255. Libraries differ in channel order: OpenCV, for example, loads color images as blue-green-red, not red-green-blue.

Arithmetic on pixels must respect the 0 to 255 range. Adding 100 to a pixel of 200 should give 255, not 300, so brightening clips the value with min and max. In NumPy with 8-bit unsigned integers, overflow instead wraps around (250 + 10 becomes 4), which is a classic source of bugs. Dividing by 255 normalizes intensities to the range 0 to 1, the form most machine-learning code expects.

Explore the dry run
Guided walkthroughReady
1image = [
2 [0, 50, 100],
3 [150, 200, 250],
4]
5
6
7def to_gray(pixel):
8 red, green, blue = pixel
9 return round(0.299 * red + 0.587 * green + 0.114 * blue)
10
11
12def brighten(img, amount):
13 return [[min(255, max(0, value + amount)) for value in row] for row in img]
14
15
16def normalize(img):
17 return [[value / 255 for value in row] for row in img]
18
19
20print(len(image), len(image[0]))
21print(to_gray((200, 100, 50)), to_gray((255, 255, 255)))
22print(brighten(image, 100))
23print([[round(value, 2) for value in row] for row in normalize(image)])
24print(image[1][2])

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
The image has 2 rows and 3 columns: height 2, width 3.—
2 3
Luma weights favour green. (200, 100, 50) → 59.8 + 58.7 + 5.7 ≈ 124.—
2 3
Brightening adds the amount and clips to the valid range 0 to 255.—
2 3
200 + 100 and 250 + 100 would exceed 255, so both clip to 255.—
2 3
124 255
[[100, 150, 200], [250, 255, 255]]
image[row][col]: row 1, column 2 is the pixel with value 250.—
2 3
124 255
[[100, 150, 200], [250, 255, 255]]
[[0.0, 0.2, 0.39], [0.59, 0.78, 0.98]]
250

Cost and common mistakes

Each pixel operation here touches every pixel once: O(H·W) for an image of height H and width W.

  • Indexing is image[row][col], meaning (y, x). Mixing up the order is a very common bug.
  • Plain averaging of R, G, and B gives a different, usually less natural gray than the luminance-weighted formula.
  • 8-bit unsigned integers wrap on overflow in NumPy. Convert to a wider type or clip before adding.

Try it yourself

What does brighten(image, -60) return?

Reveal answer and reasoning

[[0, 0, 40], [90, 140, 190]]. Negative results are clipped to 0.

Further reading