Embedded SystemsDistinguishedlegendary

Color Space Conversion: RGB888 → RGB565 → Grayscale

Bit-level RGB888→RGB565→grayscale conversion for embedded vision: packing math, fixed-point luma formulas, quantization error, and design tradeoffs.

8 min readAhmet Zahid ArıcanUpdated 12 Sept 2026
Contents & prerequisites

Every embedded vision pipeline touches color space conversion at least twice: once to shrink the frame buffer footprint (RGB888 → RGB565) and once to prep data for a CNN or classical CV kernel that only needs luminance (→ grayscale). On an MCU with 320–512 KB of SRAM, these conversions aren't cosmetic — they decide whether a 320×240 frame buffer fits in memory at all, and they determine whether the arithmetic you later run (Haar cascades, DS-CNN input, optical flow) is even fed correct data. Getting the bit-packing and the luma weighting wrong silently corrupts every downstream inference result without throwing a single error.

RGB888: The Reference Format

RGB888 is 24-bit truecolor: 8 bits each for red, green, and blue, giving 16.7 million colors. It's what most camera ISPs, JPEG decoders, and image sensors ultimately produce (or can be converted to) before further processing.

Byte:   [ R7..R0 ] [ G7..G0 ] [ B7..B0 ]
Bits:      8            8         8      = 24 bits/pixel = 3 bytes/pixel

For a QVGA frame (320×240):

320 × 240 × 3 bytes = 230,400 bytes ≈ 225 KiB

That alone exceeds the total SRAM of many Cortex-M4/M7 parts (192–320 KB), which is precisely why RGB888 is rarely kept as a live frame buffer on an MCU — it's a source or intermediate format, not a storage format.

RGB565: The 16-bit Compromise

RGB565 packs each pixel into a single 16-bit word, exploiting the human eye's lower sensitivity to blue than to green:

Bit:    15 14 13 12 11 | 10 9 8 7 6 5 | 4 3 2 1 0
Field:   R4 R3 R2 R1 R0 | G5 G4 G3 G2 G1 G0 | B4 B3 B2 B1 B0
         └──── 5 ────┘   └──────6──────┘   └──── 5 ────┘

That's 5 bits red, 6 bits green, 5 bits blue — 65,536 total colors, half the memory of RGB888:

320 × 240 × 2 bytes = 153,600 bytes = 150 KiB   (33% smaller than RGB888)

RGB565 is the native format of most SPI/parallel TFT LCD controllers (ILI9341, ST7789) and many low-cost camera modules' direct output mode, which is why it's the de facto display and DVP-camera-output standard in embedded vision.

Conversion: RGB888 → RGB565

Downscaling each channel from 8 bits to N bits is a right-shift, discarding the low-order bits (truncation), or a rounded division for better accuracy:

uint16_t rgb888_to_rgb565(uint8_t r, uint8_t g, uint8_t b) {
    uint16_t r5 = r >> 3;   // 8 bits -> 5 bits
    uint16_t g6 = g >> 2;   // 8 bits -> 6 bits
    uint16_t b5 = b >> 3;   // 8 bits -> 5 bits
    return (r5 << 11) | (g6 << 5) | b5;
}

Worked example — convert (R,G,B) = (200, 130, 40):

r5 = 200 >> 3 = 25   (binary 11001)
g6 = 130 >> 2 = 32   (binary 100000)
b5 = 40  >> 3 = 5    (binary 00101)

packed = (25 << 11) | (32 << 5) | 5
       = 0b11001_100000_00101
       = 0xCC05

Verify by reversing (expand back to 8 bits by replicating the top bits into the missing low bits, the standard "bit-replication" upscale):

r8 = (25 << 3) | (25 >> 2) = 200 | 6  → 206   (vs. original 200, error = 6)
g8 = (32 << 2) | (32 >> 4) = 128 | 2  → 130   (vs. original 130, error = 0)
b8 = (5  << 3) | (5  >> 2) = 40  | 1  → 41    (vs. original 40, error = 1)

This confirms the quantization: red and blue can be off by up to ±7 (5-bit field, step size 8), green by up to ±3 (6-bit field, step size 4) — visible as mild banding in smooth gradients, which is why RGB565 is acceptable for UI/preview but a lossy step for any pipeline computing precise color statistics.

Memory and Bandwidth Comparison

FormatBits/pixelQVGA frame sizeColorsTypical use
RGB88824225 KiB16.7MISP output, JPEG, PC-side reference
RGB56516150 KiB65,536TFT display, DVP camera raw output
Grayscale (8-bit)875 KiB256 levelsCNN/CV input, motion detection

Grayscale: Luminance Extraction

Grayscale conversion collapses three channels into one intensity value. The naive average (R+G+B)/3 is computationally trivial but perceptually wrong — it weights blue (which the eye barely perceives) the same as green (to which the eye is most sensitive). The standard luma formula (ITU-R BT.601, used for standard-definition video and most embedded CV pipelines) instead uses perceptually weighted coefficients:

Y = 0.299·R + 0.587·G + 0.114·B

For BT.709 (HD/sRGB-oriented), the weights shift toward green and away from red:

Y = 0.2126·R + 0.7152·G + 0.0722·B

Embedded implementations avoid floating point entirely, replacing it with fixed-point integer math scaled by 256 (>>8 to divide):

uint8_t rgb888_to_gray(uint8_t r, uint8_t g, uint8_t b) {
    // BT.601 weights scaled by 256: 77 + 150 + 29 = 256
    uint16_t y = (77u * r + 150u * g + 29u * b) >> 8;
    return (uint8_t)y;
}

Worked example — same pixel (200, 130, 40):

Floating point:
Y = 0.299×200 + 0.587×130 + 0.114×40
  = 59.8 + 76.31 + 4.56
  = 140.67 → round to 141

Fixed point (÷256 scale):
Y = (77×200 + 150×130 + 29×40) >> 8
  = (15400 + 19500 + 1160) >> 8
  = 36060 >> 8
  = 140.859... → integer division → 140

Verify: the fixed-point result (140) differs from the floating-point rounded result (141) by exactly 1 LSB — expected, since 77/256 = 0.30078 (not exactly 0.299) and integer right-shift truncates rather than rounds. This ±1 LSB fixed-point error is standard and acceptable for CV/CNN input; if bit-exact matching to a reference model matters (e.g., validating against a PC-trained pipeline), add a rounding bias before the shift: (... + 128) >> 8.

Converting Directly from RGB565 (Common in Practice)

Many camera pipelines only ever produce RGB565 (never expose raw RGB888), so grayscale conversion has to unpack from the 16-bit word first:

uint8_t rgb565_to_gray(uint16_t px) {
    uint8_t r5 = (px >> 11) & 0x1F;
    uint8_t g6 = (px >> 5)  & 0x3F;
    uint8_t b5 =  px        & 0x1F;

    uint8_t r8 = (r5 << 3) | (r5 >> 2);   // expand 5->8 bits
    uint8_t g8 = (g6 << 2) | (g6 >> 4);   // expand 6->8 bits
    uint8_t b8 = (b5 << 3) | (b5 >> 2);   // expand 5->8 bits

    return (uint8_t)((77u * r8 + 150u * g8 + 29u * b8) >> 8);
}

This two-step expand-then-weight approach compounds the RGB565 quantization error into the luma value — typically ±1–2 gray levels versus computing luma directly from true RGB888 — negligible for most CNN input normalization but worth knowing when validating bit-exactness against a training-side preprocessing script.

Practical Design Implications

  • Pipeline ordering matters: if both a display and a CNN need the frame, convert from RGB888 (or raw sensor Bayer output) to each target independently rather than chaining RGB888 → RGB565 → grayscale, which stacks two rounds of quantization error into the final grayscale image.
  • DMA and hardware acceleration: STM32 DCMI/JPEG peripherals and camera ISPs often output RGB565 or YUV422 directly, avoiding a software conversion step; check whether the sensor can output grayscale (Y-only) directly for CV-only pipelines — it saves both conversion cycles and 2× memory versus capturing RGB565 first.
  • YUV as an alternative: many camera modules natively output YUV422, where the Y channel is the grayscale image with no computation needed — often cheaper than capturing RGB and computing luma.
  • Fixed-point coefficient choice: confirm the three weights sum to exactly 256 (77+150+29=256) so integer division by 256 (>>8) doesn't introduce systematic bias.
  • In-place vs. scratch buffer: RGB888→RGB565 can be done in place only if writing 2 bytes per pixel never overtakes the unread 3-byte source pixels; safest is a separate destination buffer or right-to-left overwrite when doing in-place packing.
  • SIMD/NEON on Cortex-A: on embedded Linux platforms, use ARM NEON to vectorize the shift-and-mask operations across multiple pixels per instruction — critical for real-time conversion at higher resolutions (720p+).

Key Takeaways

  • RGB888 (24 bpp) is the accuracy reference; RGB565 (16 bpp) trades color depth for 33% memory savings and is the native format for most embedded displays and camera outputs.
  • RGB565 quantization introduces up to ±7 error on red/blue channels and ±3 on green, due to 5-bit and 6-bit field truncation — visible as banding, negligible for ML input.
  • Grayscale conversion should use perceptually weighted luma (BT.601: Y = 0.299R + 0.587G + 0.114B), not a flat average, to match human/CNN-relevant brightness perception.
  • Fixed-point luma ((77R + 150G + 29B) >> 8) avoids floating point on Cortex-M and matches floating-point results to within ±1 LSB.
  • Converting grayscale from RGB565 instead of RGB888 compounds quantization error; when both display and CV outputs are needed, derive each from the highest-precision source available rather than chaining conversions.
  • Check camera/ISP capabilities first — many sensors natively output YUV422 or grayscale, eliminating a conversion step and its associated CPU cycles and error.

Learning

Sign in to track your progress.

Evidence

Public projects engineers linked to Color Space Conversion: RGB888 → RGB565 → Grayscale.

Add evidence

No engineer has linked a project to this topic yet. Built something that proves it? Add the project and tag it with embedded-systems-color-space-conversion-rgb888-rgb565-grayscale — it then shows here and on your public profile.