IVS Broadcast SDK와 함께 배경 교체 사용 - Amazon IVS

IVS Broadcast SDK와 함께 배경 교체 사용

배경 교체는 실시간 스트리밍 제작자가 배경을 변경할 수 있도록 하는 카메라 필터의 일종입니다. 다음 다이어그램에서 볼 수 있듯이 배경 교체 작업은 다음과 같습니다.

  1. 라이브 카메라 피드에서 카메라 이미지를 가져옵니다.

  2. Google ML Kit를 사용하여 전경 구성 요소와 배경 구성 요소로 구분합니다.

  3. 생성된 분할 마스크를 사용자 지정 배경 이미지와 결합합니다.

  4. 브로드캐스트용 사용자 지정 이미지 소스에 전달합니다.

배경 교체 구현을 위한 워크플로입니다.

이 섹션에서는 웹 브로드캐스트 SDK를 사용하여 비디오를 게시 및 구독하는 방법을 이미 잘 알고 있다고 가정합니다.

라이브 스트림의 배경을 사용자 지정 이미지로 바꾸려면 MediaPipe 이미지 Segmenter셀카 분할 모델을 사용하세요. 이 기계 학습 모델은 비디오 프레임에서 전경 또는 배경에 있는 픽셀을 식별합니다. 그런 다음 비디오 피드의 전경 픽셀을 새 배경을 나타내는 사용자 지정 이미지에 복사하여 모델의 결과로 라이브 스트림의 배경을 바꿀 수 있습니다.

배경 교체를 IVS 실시간 스트리밍 웹 브로드캐스트 SDK와 통합하려면 다음이 작업을 수행해야 합니다.

  1. MediaPipe와 Webpack을 설치하세요. (이 예에서는 Webpack을 번들러로 사용하지만 원하는 번들러를 사용할 수 있습니다.)

  2. index.html을 생성합니다.

  3. 미디어 요소를 추가하세요.

  4. 스크립트 태그를 추가하세요.

  5. app.js을 생성합니다.

  6. 사용자 지정 배경 이미지를 불러오세요.

  7. ImageSegmenter의 인스턴스를 만듭니다.

  8. 캔버스로 비디오 피드를 렌더링하세요.

  9. 배경 교체 로직을 생성하세요.

  10. Webpack 구성 파일을 생성하세요.

  11. JavaScript 파일을 번들링하세요.

MediaPipe 및 Webpack 설치

시작하려면 @mediapipe/tasks-visionwebpack npm 패키지를 설치하세요. 아래 예제에서는 Webpack을 JavaScript 번들러로 사용합니다. 원하는 경우 다른 번들러를 사용할 수 있습니다.

npm i @mediapipe/tasks-vision webpack webpack-cli

또한 webpack을 빌드 스크립트로 지정하도록 package.json을 업데이트해야 합니다.

"scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "webpack" },

index.html 생성

다음으로 HTML 표준 문안을 생성하고 웹 브로드캐스트 SDK를 스크립트 태그로 가져오세요. 다음 코드에서는 사용 중인 브로드캐스트 SDK 버전으로 <SDK version>을 바꿔야 합니다.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- Import the SDK --> <script src="https://web-broadcast.live-video.net/<SDK version>/amazon-ivs-web-broadcast.js"></script> </head> <body> </body> </html>

미디어 요소 추가

다음으로 본문 태그에 비디오 요소 하나와 캔버스 요소 두 개를 추가합니다. 비디오 요소는 라이브 카메라 피드를 포함하며 MediaPipe 이미지 Segmenter에 대한 입력으로 사용합니다. 첫 번째 캔버스 요소는 브로드캐스트될 피드의 미리보기를 렌더링하는 데 사용됩니다. 두 번째 캔버스 요소는 배경으로 사용할 사용자 지정 이미지를 렌더링하는 데 사용됩니다. 사용자 지정 이미지가 있는 두 번째 캔버스는 최종 캔버스에 프로그래밍 방식으로 픽셀을 복사하기 위한 소스로만 사용되므로 보기에서 숨겨집니다.

<div class="row local-container"> <video id="webcam" autoplay style="display: none"></video> </div> <div class="row local-container"> <canvas id="canvas" width="640px" height="480px"></canvas> <div class="column" id="local-media"></div> <div class="static-controls hidden" id="local-controls"> <button class="button" id="mic-control">Mute Mic</button> <button class="button" id="camera-control">Mute Camera</button> </div> </div> <div class="row local-container"> <canvas id="background" width="640px" height="480px" style="display: none"></canvas> </div>

스크립트 태그 추가

스크립트 태그를 추가하여 배경 교체 코드를 포함하는 번들 JavaScript 파일을 로드하고 스테이지에 게시합니다.

<script src="./dist/bundle.js"></script>

app.js 생성

다음으로 JavaScript 파일을 생성하여 HTML 페이지에서 만든 캔버스 및 비디오 요소의 요소 객체를 가져옵니다. ImageSegmenterFilesetResolver 모듈을 가져옵니다. ImageSegmenter 모듈은 분할 작업을 수행하는 데 사용됩니다.

const canvasElement = document.getElementById("canvas"); const background = document.getElementById("background"); const canvasCtx = canvasElement.getContext("2d"); const backgroundCtx = background.getContext("2d"); const video = document.getElementById("webcam"); import { ImageSegmenter, FilesetResolver } from "@mediapipe/tasks-vision";

다음으로, 사용자 카메라에서 MediaStream을 검색하는 init() 함수를 생성하고, 카메라 프레임 로드가 완료될 때마다 콜백 함수를 호출합니다. 스테이지에 참여 및 퇴장할 수 있는 버튼에 이벤트 리스너를 추가합니다.

스테이지에 참가할 때는 segmentationStream이라는 변수를 전달합니다. 캔버스 요소에서 캡처한 비디오 스트림에 배경을 나타내는 사용자 지정 이미지 위에 전경 이미지가 오버레이되어 있습니다. 나중에 이 사용자 지정 스트림을 사용하여 스테이지에 게시할 수 있는 LocalStageStream의 인스턴스를 생성할 수 있습니다.

const init = async () => { await initializeDeviceSelect(); cameraButton.addEventListener("click", () => { const isMuted = !cameraStageStream.isMuted; cameraStageStream.setMuted(isMuted); cameraButton.innerText = isMuted ? "Show Camera" : "Hide Camera"; }); micButton.addEventListener("click", () => { const isMuted = !micStageStream.isMuted; micStageStream.setMuted(isMuted); micButton.innerText = isMuted ? "Unmute Mic" : "Mute Mic"; }); localCamera = await getCamera(videoDevicesList.value); const segmentationStream = canvasElement.captureStream(); joinButton.addEventListener("click", () => { joinStage(segmentationStream); }); leaveButton.addEventListener("click", () => { leaveStage(); }); };

사용자 지정 배경 이미지 로드

init 함수 아래쪽에 initBackgroundCanvas라는 함수를 호출하는 코드를 추가합니다. 이 함수는 로컬 파일에서 사용자 지정 이미지를 로드하여 캔버스에 렌더링합니다. 다음 단계에서 이 함수를 정의할 것입니다. 사용자 카메라에서 검색한 MediaStream을 비디오 객체에 할당합니다. 나중에 이 비디오 객체는 이미지 Segmenter로 전달됩니다. 또한 비디오 프레임 로드가 완료될 때마다 호출되도록 콜백 함수로서 이름이 renderVideoToCanvas인 함수를 설정하세요. 이후 단계에서 이 함수를 정의할 것입니다.

initBackgroundCanvas(); video.srcObject = localCamera; video.addEventListener("loadeddata", renderVideoToCanvas);

로컬 파일에서 이미지를 로드하는 initBackgroundCanvas 함수를 구현해 보겠습니다. 이 예에서는 해변 이미지를 사용자 지정 배경으로 사용합니다. 사용자 지정 이미지가 있는 캔버스는 카메라 피드를 포함한 캔버스 요소의 전경 픽셀과 병합되므로 디스플레이에서 숨겨집니다.

const initBackgroundCanvas = () => { let img = new Image(); img.src = "beach.jpg"; img.onload = () => { backgroundCtx.clearRect(0, 0, canvas.width, canvas.height); backgroundCtx.drawImage(img, 0, 0); }; };

ImageSegmenter 인스턴스 생성

다음으로 이미지를 분할하고 결과를 마스크로 반환하는 ImageSegmenter 인스턴스를 생성합니다. ImageSegmenter의 인스턴스를 생성할 때는 셀카 분할 모델을 사용하게 됩니다.

const createImageSegmenter = async () => { const audio = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.2/wasm"); imageSegmenter = await ImageSegmenter.createFromOptions(audio, { baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite", delegate: "GPU", }, runningMode: "VIDEO", outputCategoryMask: true, }); };

캔버스로 비디오 피드 렌더링

다음으로, 비디오 피드를 다른 캔버스 요소에 렌더링하는 함수를 생성합니다. Canvas 2D API를 사용하여 전경 픽셀을 추출하려면 비디오 피드를 캔버스로 렌더링해야 합니다. 이 작업을 수행하는 동안 segmentforVideo 메서드를 사용하여 비디오 프레임의 배경에서 전경을 구분하여 비디오 프레임을 ImageSegmenter 인스턴스로 전달합니다. segmentforVideo 메서드가 반환되면 사용자 지정 콜백 함수 replaceBackground를 호출하여 배경 교체를 수행합니다.

const renderVideoToCanvas = async () => { if (video.currentTime === lastWebcamTime) { window.requestAnimationFrame(renderVideoToCanvas); return; } lastWebcamTime = video.currentTime; canvasCtx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); if (imageSegmenter === undefined) { return; } let startTimeMs = performance.now(); imageSegmenter.segmentForVideo(video, startTimeMs, replaceBackground); };

배경 교체 로직 생성

사용자 지정 배경 이미지를 카메라 피드의 전경과 병합하여 배경을 대체하는 replaceBackground 함수를 생성하세요. 함수는 먼저 이전에 만든 두 캔버스 요소에서 사용자 지정 배경 이미지와 비디오 피드의 기본 픽셀 데이터를 검색합니다. 그런 다음 ImageSegmenter에서 제공한 마스크를 반복하여 전경에 있는 픽셀을 나타냅니다. 마스크를 반복하면서 사용자의 카메라 피드가 포함된 픽셀을 해당 배경 픽셀 데이터에 선택적으로 복사합니다. 이 작업이 완료되면 전경을 복사한 최종 픽셀 데이터를 배경으로 변환하고 캔버스에 그립니다.

function replaceBackground(result) { let imageData = canvasCtx.getImageData(0, 0, video.videoWidth, video.videoHeight).data; let backgroundData = backgroundCtx.getImageData(0, 0, video.videoWidth, video.videoHeight).data; const mask = result.categoryMask.getAsFloat32Array(); let j = 0; for (let i = 0; i < mask.length; ++i) { const maskVal = Math.round(mask[i] * 255.0); j += 4; // Only copy pixels on to the background image if the mask indicates they are in the foreground if (maskVal < 255) { backgroundData[j] = imageData[j]; backgroundData[j + 1] = imageData[j + 1]; backgroundData[j + 2] = imageData[j + 2]; backgroundData[j + 3] = imageData[j + 3]; } } // Convert the pixel data to a format suitable to be drawn to a canvas const uint8Array = new Uint8ClampedArray(backgroundData.buffer); const dataNew = new ImageData(uint8Array, video.videoWidth, video.videoHeight); canvasCtx.putImageData(dataNew, 0, 0); window.requestAnimationFrame(renderVideoToCanvas); }

참고로 위의 모든 로직이 포함된 전체 app.js 파일은 다음과 같습니다.

/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ // All helpers are expose on 'media-devices.js' and 'dom.js' const { setupParticipant } = window; const { Stage, LocalStageStream, SubscribeType, StageEvents, ConnectionState, StreamType } = IVSBroadcastClient; const canvasElement = document.getElementById("canvas"); const background = document.getElementById("background"); const canvasCtx = canvasElement.getContext("2d"); const backgroundCtx = background.getContext("2d"); const video = document.getElementById("webcam"); import { ImageSegmenter, FilesetResolver } from "@mediapipe/tasks-vision"; let cameraButton = document.getElementById("camera-control"); let micButton = document.getElementById("mic-control"); let joinButton = document.getElementById("join-button"); let leaveButton = document.getElementById("leave-button"); let controls = document.getElementById("local-controls"); let audioDevicesList = document.getElementById("audio-devices"); let videoDevicesList = document.getElementById("video-devices"); // Stage management let stage; let joining = false; let connected = false; let localCamera; let localMic; let cameraStageStream; let micStageStream; let imageSegmenter; let lastWebcamTime = -1; const init = async () => { await initializeDeviceSelect(); cameraButton.addEventListener("click", () => { const isMuted = !cameraStageStream.isMuted; cameraStageStream.setMuted(isMuted); cameraButton.innerText = isMuted ? "Show Camera" : "Hide Camera"; }); micButton.addEventListener("click", () => { const isMuted = !micStageStream.isMuted; micStageStream.setMuted(isMuted); micButton.innerText = isMuted ? "Unmute Mic" : "Mute Mic"; }); localCamera = await getCamera(videoDevicesList.value); const segmentationStream = canvasElement.captureStream(); joinButton.addEventListener("click", () => { joinStage(segmentationStream); }); leaveButton.addEventListener("click", () => { leaveStage(); }); initBackgroundCanvas(); video.srcObject = localCamera; video.addEventListener("loadeddata", renderVideoToCanvas); }; const joinStage = async (segmentationStream) => { if (connected || joining) { return; } joining = true; const token = document.getElementById("token").value; if (!token) { window.alert("Please enter a participant token"); joining = false; return; } // Retrieve the User Media currently set on the page localMic = await getMic(audioDevicesList.value); cameraStageStream = new LocalStageStream(segmentationStream.getVideoTracks()[0]); micStageStream = new LocalStageStream(localMic.getAudioTracks()[0]); const strategy = { stageStreamsToPublish() { return [cameraStageStream, micStageStream]; }, shouldPublishParticipant() { return true; }, shouldSubscribeToParticipant() { return SubscribeType.AUDIO_VIDEO; }, }; stage = new Stage(token, strategy); // Other available events: // https://aws.github.io/amazon-ivs-web-broadcast/docs/sdk-guides/stages#events stage.on(StageEvents.STAGE_CONNECTION_STATE_CHANGED, (state) => { connected = state === ConnectionState.CONNECTED; if (connected) { joining = false; controls.classList.remove("hidden"); } else { controls.classList.add("hidden"); } }); stage.on(StageEvents.STAGE_PARTICIPANT_JOINED, (participant) => { console.log("Participant Joined:", participant); }); stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED, (participant, streams) => { console.log("Participant Media Added: ", participant, streams); let streamsToDisplay = streams; if (participant.isLocal) { // Ensure to exclude local audio streams, otherwise echo will occur streamsToDisplay = streams.filter((stream) => stream.streamType === StreamType.VIDEO); } const videoEl = setupParticipant(participant); streamsToDisplay.forEach((stream) => videoEl.srcObject.addTrack(stream.mediaStreamTrack)); }); stage.on(StageEvents.STAGE_PARTICIPANT_LEFT, (participant) => { console.log("Participant Left: ", participant); teardownParticipant(participant); }); try { await stage.join(); } catch (err) { joining = false; connected = false; console.error(err.message); } }; const leaveStage = async () => { stage.leave(); joining = false; connected = false; cameraButton.innerText = "Hide Camera"; micButton.innerText = "Mute Mic"; controls.classList.add("hidden"); }; function replaceBackground(result) { let imageData = canvasCtx.getImageData(0, 0, video.videoWidth, video.videoHeight).data; let backgroundData = backgroundCtx.getImageData(0, 0, video.videoWidth, video.videoHeight).data; const mask = result.categoryMask.getAsFloat32Array(); let j = 0; for (let i = 0; i < mask.length; ++i) { const maskVal = Math.round(mask[i] * 255.0); j += 4; if (maskVal < 255) { backgroundData[j] = imageData[j]; backgroundData[j + 1] = imageData[j + 1]; backgroundData[j + 2] = imageData[j + 2]; backgroundData[j + 3] = imageData[j + 3]; } } const uint8Array = new Uint8ClampedArray(backgroundData.buffer); const dataNew = new ImageData(uint8Array, video.videoWidth, video.videoHeight); canvasCtx.putImageData(dataNew, 0, 0); window.requestAnimationFrame(renderVideoToCanvas); } const createImageSegmenter = async () => { const audio = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.2/wasm"); imageSegmenter = await ImageSegmenter.createFromOptions(audio, { baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite", delegate: "GPU", }, runningMode: "VIDEO", outputCategoryMask: true, }); }; const renderVideoToCanvas = async () => { if (video.currentTime === lastWebcamTime) { window.requestAnimationFrame(renderVideoToCanvas); return; } lastWebcamTime = video.currentTime; canvasCtx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); if (imageSegmenter === undefined) { return; } let startTimeMs = performance.now(); imageSegmenter.segmentForVideo(video, startTimeMs, replaceBackground); }; const initBackgroundCanvas = () => { let img = new Image(); img.src = "beach.jpg"; img.onload = () => { backgroundCtx.clearRect(0, 0, canvas.width, canvas.height); backgroundCtx.drawImage(img, 0, 0); }; }; createImageSegmenter(); init();

Webpack 구성 파일 생성

이 구성을 Webpack 구성 파일에 추가하여 app.js을 번들링하면 가져오기 호출이 제대로 작동합니다.

const path = require("path"); module.exports = { entry: ["./app.js"], output: { filename: "bundle.js", path: path.resolve(__dirname, "dist"), }, };

JavaScript 파일 번들링

npm run build

index.html이 들어 있는 디렉토리에서 간단한 HTTP 서버를 시작하고 localhost:8000을 열어서 결과를 확인합니다.

python3 -m http.server -d ./

Android

실시간 스트림의 배경을 바꾸기 위해 Google ML Kit의 셀카 분할 API를 사용할 수 있습니다. 셀카 분할 API는 카메라 이미지를 입력으로 받아들이고 이미지의 각 픽셀에 대한 신뢰도 점수를 제공하는 마스크를 반환합니다. 이를 통해 이미지가 전경에 있었는지 배경에 있었는지 알 수 있습니다. 그런 다음 신뢰도 점수를 기반으로 배경 이미지 또는 전경 이미지에서 해당 픽셀 색상을 검색할 수 있습니다. 이 프로세스는 마스크의 모든 신뢰도 점수를 검사할 때까지 계속됩니다. 그 결과 전경 픽셀과 배경 이미지의 픽셀을 포함한 새로운 픽셀 색상 배열이 만들어집니다.

배경 교체를 IVS 실시간 스트리밍 Android 브로드캐스트 SDK와 통합하려면 다음 작업을 수행해야 합니다.

  1. CameraX 라이브러리와 Google ML Kit를 설치합니다.

  2. 표준 문안 변수를 초기화합니다.

  3. 사용자 지정 이미지 소스를 생성합니다.

  4. 카메라 프레임을 관리합니다.

  5. 카메라 프레임을 Google ML Kit로 전달하세요.

  6. 카메라 프레임 전경을 사용자 지정 배경에 오버레이하세요.

  7. 새 이미지를 사용자 지정 이미지 소스에 제공하세요.

CameraX 라이브러리 및 Google ML Kit 설치

라이브 카메라 피드에서 이미지를 추출하려면 Android의 CameraX 라이브러리를 사용하세요. CameraX 라이브러리와 Google ML Kit를 설치하려면 모듈의 build.gradle 파일에 다음을 추가하세요. ${camerax_version}${google_ml_kit_version}을 각각 최신 버전의 CameraXGoogle ML Kit 라이브러리로 교체하세요.

implementation "com.google.mlkit:segmentation-selfie:${google_ml_kit_version}" implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}"

다음 라이브러리를 가져옵니다.

import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.lifecycle.ProcessCameraProvider import com.google.mlkit.vision.segmentation.selfie.SelfieSegmenterOptions

표준 문안 변수 초기화

ImageAnalysis의 인스턴스와 다음 ExecutorService의 인스턴스를 초기화합니다.

private lateinit var binding: ActivityMainBinding private lateinit var cameraExecutor: ExecutorService private var analysisUseCase: ImageAnalysis? = null

STREAM_MODE에서 Segmenter 인스턴스를 초기화하세요.

private val options = SelfieSegmenterOptions.Builder() .setDetectorMode(SelfieSegmenterOptions.STREAM_MODE) .build() private val segmenter = Segmentation.getClient(options)

사용자 지정 이미지 소스 생성

활동의 onCreate 메서드에서 DeviceDiscovery 개체의 인스턴스를 생성하고 사용자 지정 이미지 소스를 생성하세요. 사용자 지정 이미지 소스에서 제공한 Surface로 사용자 지정 배경 이미지 위에 전경이 오버레이된 최종 이미지를 받게 됩니다. 그런 다음 사용자 지정 이미지 소스를 사용하여 ImageLocalStageStream의 인스턴스를 생성합니다. 그러면 ImageLocalStageStream의 인스턴스(이 예제에서는 filterStream으로 이름이 지정됨)를 스테이지에 게시할 수 있습니다. 스테이지 설정에 대한 지침은 IVS Android 브로드캐스트 SDK 가이드를 참조하세요. 마지막으로 카메라를 관리하는 데 사용할 스레드도 생성하세요.

var deviceDiscovery = DeviceDiscovery(applicationContext) var customSource = deviceDiscovery.createImageInputSource( BroadcastConfiguration.Vec2( 720F, 1280F )) var surface: Surface = customSource.inputSurface var filterStream = ImageLocalStageStream(customSource) cameraExecutor = Executors.newSingleThreadExecutor()

카메라 프레임 관리

다음으로 카메라를 초기화하는 함수를 생성합니다. 이 함수는 CameraX 라이브러리를 사용하여 라이브 카메라 피드에서 이미지를 추출합니다. 먼저 cameraProviderFuture라는 ProcessCameraProvider 인스턴스를 생성합니다. 이 객체는 카메라 공급자를 확보한 미래의 결과를 나타냅니다. 그런 다음 프로젝트에서 이미지를 비트맵으로 로드합니다. 이 예제에서는 해변 이미지를 배경으로 사용하지만 원하는 어떤 이미지라도 사용할 수 있습니다.

그런 다음 cameraProviderFuture에 리스너를 추가합니다. 카메라를 사용할 수 있게 되거나 카메라 공급자를 구하는 과정에서 오류가 발생하면 이 리스너에 알림이 전송됩니다.

private fun startCamera(surface: Surface) { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) val imageResource = R.drawable.beach val bgBitmap: Bitmap = BitmapFactory.decodeResource(resources, imageResource) var resultBitmap: Bitmap; cameraProviderFuture.addListener({ val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() if (mediaImage != null) { val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) resultBitmap = overlayForeground(mask, maskWidth, maskHeight, inputBitmap, backgroundPixels) canvas = surface.lockCanvas(null); canvas.drawBitmap(resultBitmap, 0f, 0f, null) surface.unlockCanvasAndPost(canvas); } .addOnFailureListener { exception -> Log.d("App", exception.message!!) } .addOnCompleteListener { imageProxy.close() } } }; val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA try { // Unbind use cases before rebinding cameraProvider.unbindAll() // Bind use cases to camera cameraProvider.bindToLifecycle(this, cameraSelector, analysisUseCase) } catch(exc: Exception) { Log.e(TAG, "Use case binding failed", exc) } }, ContextCompat.getMainExecutor(this)) }

리스너 내에서 라이브 카메라 피드의 각 개별 프레임에 액세스할 수 있도록 ImageAnalysis.Builder를 생성하세요. 백 프레셔 전략을 STRATEGY_KEEP_ONLY_LATEST로 설정합니다. 이렇게 하면 한 번에 하나의 카메라 프레임만 처리에 전송되도록 할 수 있습니다. 각 개별 카메라 프레임을 비트맵으로 변환하면 픽셀을 추출하여 나중에 사용자 지정 배경 이미지와 결합할 수 있습니다.

val imageAnalyzer = ImageAnalysis.Builder() analysisUseCase = imageAnalyzer .setTargetResolution(Size(360, 640)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() analysisUseCase?.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy -> val mediaImage = imageProxy.image val tempBitmap = imageProxy.toBitmap(); val inputBitmap = tempBitmap.rotate(imageProxy.imageInfo.rotationDegrees.toFloat())

카메라 프레임을 Google ML Kit로 전달

다음으로, InputImage를 생성하여 처리를 위한 Segmenter 인스턴스에 전달합니다. ImageAnalysis의 인스턴스에서 제공하는 ImageProxyInputImage를 생성할 수 있습니다. Segmenter에 InputImage가 제공되면 픽셀이 전경이나 배경에 있을 가능성을 나타내는 신뢰도 점수가 포함된 마스크를 반환합니다. 이 마스크는 너비 및 높이 속성도 제공하며, 이 속성을 사용하여 이전에 로드한 사용자 지정 배경 이미지의 배경 픽셀을 포함하는 새 배열을 생성할 수 있습니다.

if (mediaImage != null) { val inputImage = InputImage.fromMediaImag segmenter.process(inputImage) .addOnSuccessListener { segmentationMask -> val mask = segmentationMask.buffer val maskWidth = segmentationMask.width val maskHeight = segmentationMask.height val backgroundPixels = IntArray(maskWidth * maskHeight) bgBitmap.getPixels(backgroundPixels, 0, maskWidth, 0, 0, maskWidth, maskHeight)

카메라 프레임 전경을 사용자 지정 배경에 오버레이

신뢰도 점수가 포함된 마스크, 비트맵의 카메라 프레임, 사용자 지정 배경 이미지의 색상 픽셀을 사용하면 전경을 사용자 지정 배경에 오버레이하는 데 필요한 모든 것을 얻을 수 있습니다. 그 다음 파라미터로 overlayForeground 함수를 호출합니다.

resultBitmap = overlayForeground(mask, maskWidth, maskHeight, inputBitmap, backgroundPixels)

이 함수는 마스크를 반복하고 신뢰도 값을 확인하여 배경 이미지에서 해당 픽셀 색상을 가져올지 아니면 카메라 프레임에서 가져올지 결정합니다. 신뢰도 값이 마스크의 픽셀이 배경에 있을 가능성이 높다는 것을 나타내면 배경 이미지에서 해당 픽셀 색상을 가져오고, 그렇지 않으면 카메라 프레임에서 해당 픽셀 색상을 가져와 전경을 만듭니다. 함수가 마스크 반복을 마치면 새 색상 픽셀 배열을 사용하여 새 비트맵을 생성하고 반환합니다. 이 새 비트맵은 사용자 지정 배경에 오버레이된 전경을 포함하고 있습니다.

private fun overlayForeground( byteBuffer: ByteBuffer, maskWidth: Int, maskHeight: Int, cameraBitmap: Bitmap, backgroundPixels: IntArray ): Bitmap { @ColorInt val colors = IntArray(maskWidth * maskHeight) val cameraPixels = IntArray(maskWidth * maskHeight) cameraBitmap.getPixels(cameraPixels, 0, maskWidth, 0, 0, maskWidth, maskHeight) for (i in 0 until maskWidth * maskHeight) { val backgroundLikelihood: Float = 1 - byteBuffer.getFloat() // Apply the virtual background to the color if it's not part of the foreground if (backgroundLikelihood > 0.9) { // Get the corresponding pixel color from the background image // Set the color in the mask based on the background image pixel color colors[i] = backgroundPixels.get(i) } else { // Get the corresponding pixel color from the camera frame // Set the color in the mask based on the camera image pixel color colors[i] = cameraPixels.get(i) } } return Bitmap.createBitmap( colors, maskWidth, maskHeight, Bitmap.Config.ARGB_8888 ) }

새 이미지를 사용자 지정 이미지 소스에 제공

그런 다음 사용자 지정 이미지 소스에서 제공한 Surface에 새 비트맵을 쓸 수 있습니다. 그러면 스테이지로 브로드캐스트합니다.

resultBitmap = overlayForeground(mask, inputBitmap, mutableBitmap, bgBitmap) canvas = surface.lockCanvas(null); canvas.drawBitmap(resultBitmap, 0f, 0f, null)

카메라 프레임을 가져와서 Segmenter로 전달하고 백그라운드에 오버레이하는 전체 기능은 다음과 같습니다.

@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) private fun startCamera(surface: Surface) { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) val imageResource = R.drawable.clouds val bgBitmap: Bitmap = BitmapFactory.decodeResource(resources, imageResource) var resultBitmap: Bitmap; cameraProviderFuture.addListener({ // Used to bind the lifecycle of cameras to the lifecycle owner val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() val imageAnalyzer = ImageAnalysis.Builder() analysisUseCase = imageAnalyzer .setTargetResolution(Size(720, 1280)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() analysisUseCase!!.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy -> val mediaImage = imageProxy.image val tempBitmap = imageProxy.toBitmap(); val inputBitmap = tempBitmap.rotate(imageProxy.imageInfo.rotationDegrees.toFloat()) if (mediaImage != null) { val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) segmenter.process(inputImage) .addOnSuccessListener { segmentationMask -> val mask = segmentationMask.buffer val maskWidth = segmentationMask.width val maskHeight = segmentationMask.height val backgroundPixels = IntArray(maskWidth * maskHeight) bgBitmap.getPixels(backgroundPixels, 0, maskWidth, 0, 0, maskWidth, maskHeight) resultBitmap = overlayForeground(mask, maskWidth, maskHeight, inputBitmap, backgroundPixels) canvas = surface.lockCanvas(null); canvas.drawBitmap(resultBitmap, 0f, 0f, null) surface.unlockCanvasAndPost(canvas); } .addOnFailureListener { exception -> Log.d("App", exception.message!!) } .addOnCompleteListener { imageProxy.close() } } }; val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA try { // Unbind use cases before rebinding cameraProvider.unbindAll() // Bind use cases to camera cameraProvider.bindToLifecycle(this, cameraSelector, analysisUseCase) } catch(exc: Exception) { Log.e(TAG, "Use case binding failed", exc) } }, ContextCompat.getMainExecutor(this)) }