feat(dma2d): added async color converter example

illustrate how to use DMA2D convert a YUV422 image to RGB888 format
This commit is contained in:
morris
2026-06-30 11:51:40 +08:00
parent a035a90ef6
commit b0fc4b0e57
8 changed files with 372 additions and 0 deletions
@@ -100,6 +100,12 @@ examples/peripherals/dac/dac_cosine_wave:
- esp_driver_dac
- soc
examples/peripherals/dma/async_color_convert:
disable:
- if: SOC_DMA2D_SUPPORTED != 1
depends_components:
- esp_driver_dma
examples/peripherals/dma/async_crc:
disable:
- if: SOC_GDMA_SUPPORT_CRC != 1
@@ -0,0 +1,5 @@
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(async_color_convert_example)
@@ -0,0 +1,69 @@
| Supported Targets | ESP32-P4 | ESP32-S31 |
| ----------------- | -------- | --------- |
# Async Color Convert Example
(See the README.md file in the upper level 'examples' directory for more information about examples.)
## Overview
This example demonstrates how to use the Async Color Convert driver (`esp_async_color_convert.h`) with the DMA2D backend.
The example performs:
- Loading an embedded UYVY422 raw image from flash
- Letting DMA2D read the source image directly from mapped flash
- Performing a blocking UYVY422 -> RGB888 conversion with the Async Color Convert driver
- Base64-encoding the converted BGR24 image and printing it with machine-parseable markers
- Letting pytest decode the payload, save a PPM artifact, and compare it against a golden reference image
## Hardware Required
Any board with a supported ESP target that mentioned in the above table can be used.
## Build and Flash
Run `idf.py -p PORT flash monitor` to build and flash the project.
(To exit the serial monitor, type ``Ctrl-]``.)
See the [Getting Started Guide](https://docs.espressif.com/projects/esp-idf/en/latest/get-started/index.html) for full steps to configure and use ESP-IDF to build projects.
## Example Output
```text
Loading embedded UYVY image from flash...
Embedded image size: 12288 bytes
Converting UYVY422 -> RGB888...
Converted image size: 18432 bytes
IMAGE_META width=96 height=64 format=BGR24 encoding=base64
IMAGE_BASE64_BEGIN
IMAGE_BASE64 ...
IMAGE_BASE64 ...
IMAGE_BASE64_END
Async color convert visual demo done.
```
## Visual Result In Pytest
The accompanying pytest script captures the `IMAGE_META` and `IMAGE_BASE64` output, reconstructs the converted image, and saves it as:
- `dut.logdir/async_color_convert_result.ppm`
It also compares the generated result with `golden_result.ppm` by hashing the decoded RGB pixel content. This turns the example into a regression test as well as a visual demo: the image must both render correctly for a human and match the stored golden output for CI.
## Replacing The Embedded UYVY Asset
The example embeds `main/assets/sample_96x64_uyvy.yuv`.
You can regenerate a compatible asset from any PNG with `ffmpeg`. One simple workflow is:
```bash
ffmpeg -y -i input.png -vf scale=96:64 -pix_fmt uyvy422 -f rawvideo sample_96x64_uyvy.yuv
```
After replacing the `.yuv` file, rebuild and flash the example. The firmware will emit the converted image as base64, and pytest will save the resulting PPM artifact automatically. If you intend the new image to become the expected output, update `golden_result.ppm` as well so the regression check stays in sync.
## Troubleshooting
(For any technical queries, please open an [issue](https://github.com/espressif/esp-idf/issues) on GitHub. We will get back to you as soon as possible.)
@@ -0,0 +1,4 @@
idf_component_register(SRCS "async_color_convert_example_main.c"
PRIV_REQUIRES esp_driver_dma mbedtls
INCLUDE_DIRS "."
EMBED_FILES "assets/sample_96x64_uyvy.yuv")
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include "mbedtls/base64.h"
#include "esp_async_color_convert.h"
#include "esp_check.h"
#include "esp_heap_caps.h"
#define EXAMPLE_WIDTH 96
#define EXAMPLE_HEIGHT 64
#define EXAMPLE_BASE64_CHUNK_LEN 96
/* These linker symbols are generated automatically for the file added by
* EMBED_FILES in CMakeLists.txt. They let the example treat the embedded
* raw .yuv asset as a byte array stored in flash. */
extern const uint8_t sample_96x64_uyvy_yuv_start[] asm("_binary_sample_96x64_uyvy_yuv_start");
extern const uint8_t sample_96x64_uyvy_yuv_end[] asm("_binary_sample_96x64_uyvy_yuv_end");
static void print_base64_payload(const unsigned char *encoded, size_t encoded_len)
{
/* The payload is split into short lines so the UART log stays easy to
* parse from pytest and less likely to be damaged by very long lines. */
printf("IMAGE_BASE64_BEGIN\n");
for (size_t offset = 0; offset < encoded_len; offset += EXAMPLE_BASE64_CHUNK_LEN) {
size_t chunk_len = encoded_len - offset;
if (chunk_len > EXAMPLE_BASE64_CHUNK_LEN) {
chunk_len = EXAMPLE_BASE64_CHUNK_LEN;
}
printf("IMAGE_BASE64 %.*s\n", (int)chunk_len, (const char *)&encoded[offset]);
}
printf("IMAGE_BASE64_END\n");
}
void app_main(void)
{
/* UYVY422 stores 2 bytes per pixel on average, while BGR/RGB888 uses
* 3 bytes per pixel. The example keeps the image size small so the
* buffers and UART payload stay beginner-friendly. */
const size_t pixel_num = EXAMPLE_WIDTH * EXAMPLE_HEIGHT;
const size_t yuv422_size = pixel_num * 2;
const size_t rgb888_size = pixel_num * 3;
const size_t embedded_size = sample_96x64_uyvy_yuv_end - sample_96x64_uyvy_yuv_start;
printf("Loading embedded UYVY image from flash...\n");
printf("Embedded image size: %zu bytes\n", embedded_size);
assert(embedded_size == yuv422_size);
/* The destination buffer still needs DMA-capable internal RAM because
* DMA2D writes the converted pixels into this memory region. */
uint8_t *dst_bgr = heap_caps_aligned_calloc(64, 1, rgb888_size,
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
assert(dst_bgr);
async_color_convert_config_t config = {
.backlog = 1, // because we use the blocking API, so only need 1 in-flight request at most
.dma_burst_size = 16,
};
async_color_convert_handle_t conv_hdl = NULL;
/* Install the async color convert driver with the DMA2D backend.
* The returned handle is used by later conversion requests. */
ESP_ERROR_CHECK(esp_async_color_convert_install_dma2d(&config, &conv_hdl));
/* This request describes one full-frame conversion:
* - source buffer: UYVY422 image
* - destination buffer: BGR24 image
* - stride/height: layout of each image in memory
* - copy_width/copy_height: region to convert */
async_color_convert_request_t req_yuv_to_bgr = {
/* DMA2D can read the source image directly from mapped flash, so the
* example does not need an extra CPU copy into internal RAM first. */
.src_buffer = sample_96x64_uyvy_yuv_start,
.src_stride = EXAMPLE_WIDTH,
.src_height = EXAMPLE_HEIGHT,
.src_x = 0,
.src_y = 0,
.dst_buffer = dst_bgr,
.dst_stride = EXAMPLE_WIDTH,
.dst_height = EXAMPLE_HEIGHT,
.dst_x = 0,
.dst_y = 0,
.copy_width = EXAMPLE_WIDTH,
.copy_height = EXAMPLE_HEIGHT,
.src_color_format = ESP_COLOR_FOURCC_UYVY,
.dst_color_format = ESP_COLOR_FOURCC_BGR24,
.color_conv_std = COLOR_CONV_STD_RGB_YUV_BT601,
};
printf("Converting UYVY422 -> RGB888...\n");
/* This example uses the blocking API for simplicity: the call returns only
* after the hardware conversion is finished and dst_bgr contains the result. */
ESP_ERROR_CHECK(esp_color_convert_blocking(conv_hdl, &req_yuv_to_bgr, -1));
printf("Converted image size: %zu bytes\n", rgb888_size);
/* Base64 turns the binary BGR image into printable ASCII so it can be
* safely transported through the serial console and reconstructed by pytest. */
size_t encoded_len = 0;
int ret = mbedtls_base64_encode(NULL, 0, &encoded_len, dst_bgr, rgb888_size);
ESP_ERROR_CHECK((ret == MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) ? ESP_OK : ESP_FAIL);
unsigned char *encoded = calloc(encoded_len + 1, 1);
assert(encoded);
ESP_ERROR_CHECK(mbedtls_base64_encode(encoded, encoded_len + 1, &encoded_len, dst_bgr, rgb888_size) == 0 ? ESP_OK : ESP_FAIL);
/* IMAGE_META plus the chunked IMAGE_BASE64 lines form a tiny text protocol
* that the pytest script understands and converts back into a PPM file. */
printf("IMAGE_META width=%u height=%u format=BGR24 encoding=base64\n", EXAMPLE_WIDTH, EXAMPLE_HEIGHT);
print_base64_payload(encoded, encoded_len);
printf("Async color convert visual demo done.\n");
ESP_ERROR_CHECK(esp_async_color_convert_uninstall(conv_hdl));
free(encoded);
free(dst_bgr);
}
@@ -0,0 +1,168 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: CC0-1.0
import base64
import hashlib
import logging
import re
from dataclasses import dataclass
from pathlib import Path
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
from pytest_embedded_idf.utils import soc_filtered_targets
IMAGE_META_PATTERN = r'IMAGE_META width=(\d+) height=(\d+) format=(\w+) encoding=(\w+)'
IMAGE_META_RE = re.compile(rf'^{IMAGE_META_PATTERN}$')
IMAGE_CHUNK_RE = re.compile(r'^IMAGE_BASE64 ([A-Za-z0-9+/=]+)$')
IMAGE_OUTPUT_NAME = 'async_color_convert_result.ppm'
GOLDEN_IMAGE_NAME = 'golden_result.ppm'
EXPECTED_PIXEL_FORMAT = 'BGR24'
EXPECTED_ENCODING = 'base64'
PPM_MAGIC = b'P6'
PPM_MAX_VALUE = b'255'
@dataclass(frozen=True)
class ImageMetadata:
width: int
height: int
pixel_format: str
encoding: str
@dataclass(frozen=True)
class RgbImage:
width: int
height: int
pixels_rgb888: bytes
def __post_init__(self) -> None:
expected_size = self.width * self.height * 3
if len(self.pixels_rgb888) != expected_size:
raise ValueError(f'Expected {expected_size} RGB bytes, got {len(self.pixels_rgb888)}')
def parse_image_metadata(meta_line: str) -> ImageMetadata:
match = IMAGE_META_RE.match(meta_line)
if not match:
raise ValueError(f'Invalid image metadata line: {meta_line}')
return ImageMetadata(
width=int(match.group(1)),
height=int(match.group(2)),
pixel_format=match.group(3),
encoding=match.group(4),
)
def collect_base64_payload(dut: Dut) -> list[str]:
payload_lines: list[str] = []
while True:
match = dut.expect(r'(IMAGE_BASE64_END|IMAGE_BASE64 [A-Za-z0-9+/=]+\r?\n)')
line = match.group(1).decode('utf-8').strip()
if line == 'IMAGE_BASE64_END':
return payload_lines
chunk_match = IMAGE_CHUNK_RE.match(line)
assert chunk_match is not None
payload_lines.append(chunk_match.group(1))
def _bgr24_to_rgb888(raw_bytes: bytes) -> bytes:
rgb_bytes = bytearray(len(raw_bytes))
for offset in range(0, len(raw_bytes), 3):
blue, green, red = raw_bytes[offset : offset + 3]
rgb_bytes[offset : offset + 3] = (red, green, blue)
return bytes(rgb_bytes)
def _encode_ppm(image: RgbImage) -> bytes:
header = b'%s\n%d %d\n%s\n' % (PPM_MAGIC, image.width, image.height, PPM_MAX_VALUE)
return header + image.pixels_rgb888
def _load_ppm(path: Path) -> RgbImage:
ppm_bytes = path.read_bytes()
header_match = re.match(rb'^P6\s+(\d+)\s+(\d+)\s+(\d+)\s', ppm_bytes)
if not header_match:
raise ValueError('Invalid PPM header')
width = int(header_match.group(1))
height = int(header_match.group(2))
max_value = header_match.group(3)
if width <= 0 or height <= 0:
raise ValueError('Unsupported PPM dimensions')
if max_value != PPM_MAX_VALUE:
raise ValueError(f'Unsupported PPM max value: {max_value.decode("ascii", errors="replace")}')
pixel_data = ppm_bytes[header_match.end() :]
expected_size = width * height * 3
if len(pixel_data) != expected_size:
raise ValueError(f'Expected {expected_size} PPM pixel bytes, got {len(pixel_data)}')
return RgbImage(width=width, height=height, pixels_rgb888=pixel_data)
def decode_bgr24_base64_image(metadata: ImageMetadata, payload_lines: list[str]) -> RgbImage:
if metadata.pixel_format != EXPECTED_PIXEL_FORMAT:
raise ValueError(f'Unsupported pixel format: {metadata.pixel_format}')
if metadata.encoding != EXPECTED_ENCODING:
raise ValueError(f'Unsupported payload encoding: {metadata.encoding}')
raw_bytes = base64.b64decode(''.join(payload_lines), validate=True)
expected_size = metadata.width * metadata.height * 3
if len(raw_bytes) != expected_size:
raise ValueError(f'Expected {expected_size} decoded bytes, got {len(raw_bytes)}')
return RgbImage(width=metadata.width, height=metadata.height, pixels_rgb888=_bgr24_to_rgb888(raw_bytes))
def save_ppm_artifact(image: RgbImage, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
output_path.write_bytes(_encode_ppm(image))
except OSError:
logging.exception('Failed to save async color convert artifact to %s', output_path)
return
logging.info('Saved async color convert artifact to %s', output_path)
def rgb_pixel_digest(image: RgbImage) -> str:
digest = hashlib.sha256()
digest.update(image.width.to_bytes(4, 'big'))
digest.update(image.height.to_bytes(4, 'big'))
digest.update(image.pixels_rgb888)
return digest.hexdigest()
def assert_image_matches_golden(result_image: RgbImage, golden_path: Path) -> None:
assert golden_path.is_file(), f'Golden image not found: {golden_path}'
golden_image = _load_ppm(golden_path)
assert rgb_pixel_digest(result_image) == rgb_pixel_digest(golden_image), (
f'Generated image does not match golden file: {golden_path.name}'
)
@pytest.mark.generic
@idf_parametrize('target', soc_filtered_targets('SOC_DMA2D_SUPPORTED == 1'), indirect=['target'])
def test_async_color_convert_example(dut: Dut) -> None:
dut.expect_exact('Loading embedded UYVY image from flash...')
dut.expect(r'Embedded image size: \d+ bytes')
dut.expect_exact('Converting UYVY422 -> RGB888...')
dut.expect(r'Converted image size: \d+ bytes')
metadata = parse_image_metadata(dut.expect(IMAGE_META_PATTERN).group(0).decode('utf-8'))
dut.expect_exact('IMAGE_BASE64_BEGIN')
payload_lines = collect_base64_payload(dut)
result_image = decode_bgr24_base64_image(metadata, payload_lines)
output_path = Path(dut.logdir) / IMAGE_OUTPUT_NAME
save_ppm_artifact(result_image, output_path)
assert_image_matches_golden(result_image, Path(__file__).with_name(GOLDEN_IMAGE_NAME))
dut.expect_exact('Async color convert visual demo done.')