[Flutter] Canvas로 서로 다른 이미지를 하나로 합성하기

윤달·2025년 1월 31일
0
post-thumbnail

구현 목표


만약 위에 있는 사진처럼 서로 다른 이미지를 합성하여 새로운 이미지를 생성해달라는
요구사항을 전달받았다면 여러분은 어떻게 구현하시겠습니까?

아주 간단한 방법으로는 Stack으로 이미지를 쌓아올려서 그럴싸하게 보여주는 방법이 있겠죠.
경우에 따라서는 이미지를 편하게 합성해주는 오픈소스를 찾아 적용하는 방법도 존재할 것 같네요.

하지만 현재 제가 직면한 상황에서는 Flutter 엔진을 활용하여 직접 합성을 해야하는 상황이었습니다.

asset 이미지인 베이스 마커 이미지와 사용자가 첨부한 이미지를 하나로 합성한 후,
이를 GoogleMap 지도 위젯에 Custom Marker를 표시해야 하는 경우였습니다.

이러한 경우에는 Canvas를 사용하여 Flutter 엔진단에서 이미지 합성을 해야하는 경우인데요,
이번 포스팅에서는 Canvas 이미지 합성 방법을 단계별로 알아볼 예정입니다.

예제 주요 포인트


1. 각각의 이미지를 ui.image 형태로 변환하여 Canvas 합성을 위한 재료 준비하기
2. Canvas에 서로 다른 이미지를 하나로 합성하기
3. 합성된 ui.image를 바이너리 형태인 Uint8List로 변경하여 저장하기

생각보다(?) 번거로운 절차가 기다리고 있지만,
단계별로 차근차근 따라가면 어렵지 않게 원하는 이미지를 자유롭게 합성할 수 있게 됩니다.

GoogleMap Marker 위젯에 어떻게 적용하는지 궁금하시다면, 깃허브 프로젝트를 참고해주세요.
여기서는 글의 길이를 고려하여 이미지를 합성하는 핵심 로직에만 집중합니다.


1. 이미지 → ui.Image로 변환하기

Canvas로 이미지를 합성하려면, 우선 이미지를 flutter 엔진이 이미지 합성을 위해 데이터를 읽을 수 있는 형태인 ui.Image로 만들어야 합니다.

이를 위해 아래처럼 총 4단계 변환 과정을 거치게 됩니다.

jpg/png → ByteData → ui.Codec → ui.FrameInfo → ui.Image

저는 왜 4번이나 타입을 변환해야 하는지 의문이 들었는데요,
이 의문을 해소할만한 자세한 내용은 소주제별로 자세히 설명드리겠습니다.
Flutter 엔진은 다양한 이미지 포맷(예: PNG, GIF, WebP 등)을 일관된 구조로 처리하기 위해
디코딩 단계를 여러 객체로 분리해두었는데요, 각 단계를 하나씩 살펴보겠습니다.

여기서 디코딩이란 압축된 이미지 파일을 픽셀 데이터로 변환하는 과정이라고 생각하시면 됩니다.


1-1. jpg/png → ByteData

  // 1) jpg/png -> ByteData
  final ByteData baseData = await rootBundle.load(baseMarkerPath);

이미지 파일 자체는 디스크 또는 앱 번들에 저장된 단순 이진 데이터(binary file)입니다.
Flutter 엔진이 이미지를 합성하기 위해서는 먼저 메모리에 적재된 바이트 형태로 로드해야 합니다.
이때 사용되는 자료 구조가 바로 ByteData입니다.
즉, 엔진의 디코딩을 위해서는 RAM 상의 이진 데이터인 ByteData 형태가 되어야 후속으로 디코딩이 가능합니다.


1-2. ByteData → ui.Codec

  // 2) ByteData -> ui.Codec
  final ui.Codec baseCodec = await ui.instantiateImageCodec(
    baseData.buffer.asUint8List(),
    targetWidth: size,
  );

ByteData는 단순히 바이트를 담고 있을 뿐, 어떤 포맷으로 어떻게 디코딩할지에 대한 정보는 없습니다.

여기서 ui.Codec이 등장합니다.

Flutter 엔진은 JPG, GIF, WebP 등 여러 종류의 이미지 포맷을 한번에 처리하고 싶어 합니다.
그래서 코덱(Codec)을 통해 이미지 바이트를 디코딩하여 프레임별로 관리하게끔 되어 있습니다.
ui.Codec은 애니메이션 이미지를 다룰 수 있도록 프레임 단위로 접근할 수 있는 구조로 되어있는데요.

Flutter에서는 동일한 절차로 모든 여러 종류의 이미지 포맷을 디코딩할 수 있도록 설계했죠.

instantiateImageCodec 메서드에서는 targetWidth나 targetHeight를 지정해서 리사이즈도 가능합니다.
결국, ByteData를 읽어 어떤 포맷인지 판별하고, 내부적으로 픽셀 정보를 파싱하는 로직은 ui.Codec이 전담합니다.

이 단계가 없으면, Flutter 엔진은 바이트가 JPG인지 GIF인지조차 알 수 없으므로, 반드시 ui.Codec을 거쳐야합니다.


1-3. ui.Codec → ui.FrameInfo → ui.Image

  // 3) ui.Codec -> ui.FrameInfo -> ui.Image
  final ui.FrameInfo baseFrameInfo = await baseCodec.getNextFrame();
  return baseFrameInfo.image;

ui.Codec이 준비됐다면, 이제 ui.Codec의 이미지 데이터를 추출해야 합니다.
ui.Codec에 getNextFrame()을 호출하여 실제 픽셀 이미지가 들어있는 ui.frameInfo의 한 장의 프레임을 얻어냅니다.
만약 GIF나 WebP처럼 여러 프레임이 있는 경우, getNextFrame()을 반복 호출하여 순차적으로 각 프레임을 가져올 수도 있겠네요.

마지막으로 ui.FrameInfo.image를 통해 얻는 것이 ui.Image입니다.
Flutter 엔진에서 Canvas로 그릴 수 있는 ‘엔진 레벨’ 이미지 객체라고 생각하면 됩니다.
우리가 위젯에서 쓰는 Image나 AssetImage와는 다르게, Canvas에서 사용 가능한 저수준 그래픽에 사용되는 형태이죠.

이러한 일련의 과정을 하나의 메서드 코드로 보면 아래와 같습니다.


// baseMarkerPath: 로컬 asset 경로 (예: "assets/images/marker_base.png")
// size: 필요 시점에 맞게 리사이징할 크기
Future<ui.Image> _loadBaseMarker(String baseMarkerPath, int size) async {

  // 1) jpg/png -> ByteData
  final ByteData baseData = await rootBundle.load(baseMarkerPath);

  // 2) ByteData -> ui.Codec
  final ui.Codec baseCodec = await ui.instantiateImageCodec(
    baseData.buffer.asUint8List(),
    targetWidth: size,
  );

  // 3) ui.Codec -> ui.FrameInfo -> ui.Image
  final ui.FrameInfo baseFrameInfo = await baseCodec.getNextFrame();
  return baseFrameInfo.image;
}

2. Canvas로 이미지 합성하기

앞서 살펴본 것처럼, 우리는 baseMarker(고정된 마커 틀)와 userImage(사용자가 업로드한 이미지)를 각각 ui.Image 형태로 준비해두었습니다.

이제 이 두 이미지를 한 장의 최종 이미지로 합성해야 합니다.


2-1. PictureRecorder와 Canvas 생성

Flutter에서 Canvas로 그린 결과물은, 내부적으로 PictureRecorder에 기록됩니다.
PictureRecorder는 Canvas에 그린 모든 드로잉 명령을 캡처하고, 이를 Picture 객체로 변환하는 역할을 합니다.

그 뒤 최종적으로 Pictureui.Image로 만들 수 있게 됩니다.

// 1) Canvas 작업을 기록할 Recorder 객체
final recorder = ui.PictureRecorder();

// 2) Canvas 정의 (size x size 크기의 사각형 범위)
final canvas = Canvas(
  recorder,
  Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()),
);

Canvas 생성 시, 이 캔버스가 어느 영역에 그려질지를 나타내는 Rect 범위를 지정해줍니다.
여기서는 0,0 지점에서 size만큼의 정사각형을 캔버스로 설정했습니다.


2-2. 베이스 마커 이미지 그리기

기본 마커 틀(베이스 이미지)을 먼저 그립니다.
paintImage 함수를 사용하면, 간편하게 ui.Image 객체를 Canvas에 그릴 수 있습니다.
아래처럼 rect 영역 전체를 베이스 이미지로 채울 수 있습니다.

// 캔버스에 기본 마커 틀을 먼저 그리기
void _drawBaseMarker(Canvas canvas, ui.Image baseImage, int size) {
  paintImage(
    canvas: canvas,
    rect: Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()),
    image: baseImage,
  );
}

이 과정을 거치면, Canvas 배경에 베이스 마커가 그려지게 됩니다.


2-3. 사용자 이미지 합성하기

두 번째로, 사용자 이미지를 원하는 위치와 모양으로 그립니다.
아래 예시에서는 원형으로 잘라서 마커 중앙 부근에 합성합니다.

canvas.clipPath(...)로 특정 영역을 원형 마스킹한 뒤,
paintImage로 이미지를 덮어쓰면 동그란 프로필처럼 표현할 수 있습니다.

// 원하는 영역에 유저가 첨부한 이미지를 붙여넣기
void _drawUserImage(Canvas canvas, ui.Image userImage, int size) {
  final double userImageSize = size / 1.7;
  final Rect userImageRect = Rect.fromCircle(
    center: Offset(size / 2, size / 2.45),
    radius: userImageSize / 2,
  );

  // 캔버스 상태 저장
  canvas.save();
  
  // 원형 영역 클리핑
  canvas.clipPath(Path()..addOval(userImageRect));
  
  // 사용자 이미지 그리기
  paintImage(
    canvas: canvas,
    rect: userImageRect,
    image: userImage,
    fit: BoxFit.cover,
  );
  
  // 클리핑 원 상태 해제
  canvas.restore();
}

canvas.save()canvas.restore() 사이에서만 원형 클리핑이 유효합니다.
fit: BoxFit.cover를 통해, 원형 내부를 가득 채우는 형태로 이미지를 그릴 수 있습니다.

결과적으로, 베이스 마커 → 사용자 이미지 순서대로 덧그려지면서 하나의 Canvas 위에 2장의 이미지가 합성됩니다.


3. 합성된 이미지를 반환하기

3-1. Canvas → Picture → ui.Image

Canvas에 드로잉을 모두 마쳤다면, PictureRecorder를 멈추는 순간에 ui.Picture 객체가 만들어집니다.
이제 이 Picture는 Canvas에 그린 결과물을 임시로 담고 있는 상태로, 아직 최종 이미지 형식은 아닙니다.
Picture.toImage(width, height) 메서드를 호출하면 비로소 한 장의 실제 이미지ui.Image를 얻을 수 있습니다.

// 드로잉 후 만들어진 객체
final picture = recorder.endRecording();

final ui.Image combinedImage = await picture.toImage(size, size);

여기서 width와 height는 최종 이미지의 픽셀 크기를 의미합니다.

await 키워드가 붙은 것은 비동기로 처리되기 때문이며,
실제로 엔진이 Picture → Image 변환 작업을 수행하는 데 약간의 시간이 걸릴 수 있습니다.


3-2. ui.Image → Uint8List 변환

ui.Image는 Flutter 엔진 내부에서만 쓰이는 메모리 객체라,
이를 파일로 저장하거나 GoogleMap Marker 등에서 사용하기 위해서는 바이트 배열 형태로 변환해야 합니다.

즉, PNG 포맷으로 인코딩 → ByteData → 다시 Uint8List 변환 단계를 거칩니다.

아래처럼 toByteData(format: ui.ImageByteFormat.png)를 호출하면, PNG 포맷의 ByteData가 반환됩니다.

// 원하는 형태로 변환하기
final ByteData? pngBytes =
    await combinedImage.toByteData(format: ui.ImageByteFormat.png);

// Uint8List 형태로 반환하기
final Uint8List result = pngBytes!.buffer.asUint8List();

3-3. 소스코드 총 정리

import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';

class CustomMarkerUtil {
	/// 서로 다른 이미지를 합성 후 Uint8List로 반환하는 메서드
  static Future<Uint8List> imageToUint8List(
      String baseMarkerPath, String deviceImagePath, int size) async {
      
    // 1) jpg/png -> ui.Image
    final ui.Image baseImage = await _loadBaseMarker(baseMarkerPath, size);
    final ui.Image userImage = await _loadNetworkImage(deviceImagePath);

		// 2) Canvas로 서로 다른 이미지 합성하기
    final recorder = ui.PictureRecorder();

    final canvas = Canvas(
      recorder,
      Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()),
    );

    _drawBaseMarker(canvas, baseImage, size);
    _drawUserImage(canvas, userImage, size);

		// 3) Picture -> ui.Image -> Uint8List로 변환 후 반환하기
    final picture = recorder.endRecording();

    final ui.Image combinedImage = await picture.toImage(size, size);

    final ByteData? pngBytes =
        await combinedImage.toByteData(format: ui.ImageByteFormat.png);

    return pngBytes!.buffer.asUint8List();
  }

  /// jpg/png -> ui.Image
  static Future<ui.Image> _loadBaseMarker(String path, int size) async {
    final ByteData baseData = await rootBundle.load(path);

    final ui.Codec baseCodec = await ui.instantiateImageCodec(
      baseData.buffer.asUint8List(),
      targetWidth: size,
    );

    final ui.FrameInfo baseFrameInfo = await baseCodec.getNextFrame();

    return baseFrameInfo.image;
  }

  /// Canvas에 기본 마커 틀을 그리는 메서드
  static void _drawBaseMarker(Canvas canvas, ui.Image baseImage, int size) {
    paintImage(
      canvas: canvas,
      rect: Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()),
      image: baseImage,
    );
  }

  /// 기본 마커 틀이 그려져있는 Canvas에 유저 이미지 덧그리는 메서드
  static void _drawUserImage(Canvas canvas, ui.Image userImage, int size) {
    final double userImageSize = size / 1.7;
    final Rect userImageRect = Rect.fromCircle(
      center: Offset(size / 2, size / 2.45),
      radius: userImageSize / 2,
    );

    canvas.save();
    canvas.clipPath(Path()..addOval(userImageRect));
    paintImage(
      canvas: canvas,
      rect: userImageRect,
      image: userImage,
      fit: BoxFit.cover,
    );
    canvas.restore();
  }
}

마무리

Flutter의 Canvas로 서로 다른 이미지를 하나로 합성하는 방법에 대해 글을 작성해보았습니다.

저 같은 경우에는 GoogleMap의 Custom Marker 제작을 위한 이미지 합성 로직이기에
BitmapDescriptor.fromBytes(Uint8List) 로 사용하기 위한 Uint8List 형태로 반환했습니다.

자세한 구현 예시는 깃허브 프로젝트에서 확인하실 수 있으니, 관심 있으신 분들은 참고해주세요.
여기서는 글의 길이를 고려하여 이미지를 합성하는 핵심 로직에만 집중합니다.

잘못된 내용이 있거나 추가로 궁금하신 부분은 편하게 댓글 남겨주세요!
확인 후 답변 남겨놓도록 하겠습니다.

글 읽어주셔서 감사합니다.

profile
Interested in Flutter

0개의 댓글

관련 채용 정보