diff --git a/ai-catalog/app/src/main/res/values/strings.xml b/ai-catalog/app/src/main/res/values/strings.xml index 4e923dfc..78b40f54 100644 --- a/ai-catalog/app/src/main/res/values/strings.xml +++ b/ai-catalog/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Open sample Image generation with Imagen Generate images with Imagen, Google image generation model - Imagen Editing using Inpainting + Inpainting & Outpainting with Imagen Generate images and edit only specific areas of a generated image with Inpainting Magic Selfie with Imagen and ML Kit Change the background of your selfies with Imagen and the ML Kit Segmentation API diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/data/ImagenEditingDataSource.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/data/ImagenEditingDataSource.kt index 90559012..cc31c204 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/data/ImagenEditingDataSource.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/data/ImagenEditingDataSource.kt @@ -18,6 +18,7 @@ package com.android.ai.samples.imagenediting.data import android.graphics.Bitmap import com.google.firebase.Firebase import com.google.firebase.ai.ai +import com.google.firebase.ai.type.Dimensions import com.google.firebase.ai.type.GenerativeBackend import com.google.firebase.ai.type.ImagenAspectRatio import com.google.firebase.ai.type.ImagenEditMode @@ -120,4 +121,25 @@ class ImagenEditingDataSource @Inject constructor() { ) return imageResponse.images.first().asBitmap() } + + /** + * Outpaints an image to the target dimensions using the Firebase Imagen API. + * This function extends the original image by generating content around it + * based on the provided prompt and target dimensions. + * + * @param sourceImage The original bitmap image to be outpainted. + * @param targetDimensions The desired dimensions of the outpainted image. + * @param prompt An optional text prompt to guide the outpainting process. + * @return The outpainted bitmap image. + */ + @OptIn(PublicPreviewAPI::class) + suspend fun outpaintImage(sourceImage: Bitmap, targetDimensions: Dimensions, prompt: String = ""): Bitmap { + val imageResponse = editingModel.outpaintImage( + image = sourceImage.toImagenInlineImage(), + newDimensions = targetDimensions, + prompt = prompt, + ) + + return imageResponse.images.first().asBitmap() + } } diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt deleted file mode 100644 index 6f1aae15..00000000 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2025 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.ai.samples.imagenediting.ui - -import android.graphics.Bitmap -import android.graphics.Canvas as AndroidCanvas -import android.graphics.Paint as AndroidPaint -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.asAndroidPath -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.graphics.createBitmap -import com.android.ai.samples.imagenediting.R - -@Composable -fun ImagenEditingGeneratedContent( - uiState: ImagenEditingUIState, - modifier: Modifier = Modifier, - onImageClick: (Bitmap) -> Unit = {}, - onMaskFinalized: (source: Bitmap, mask: Bitmap) -> Unit, -) { - var currentDrawingPath by remember { mutableStateOf(Path()) } - var pathVersion by remember { mutableIntStateOf(0) } - var bitmapToMask by remember { mutableStateOf(null) } - - Box( - modifier = modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant), - contentAlignment = Alignment.Center, - ) { - when (uiState) { - ImagenEditingUIState.Initial -> { - Text( - text = stringResource(R.string.editing_placeholder_prompt_entry), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - ) - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - - ImagenEditingUIState.Loading -> { - CircularProgressIndicator() - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - - is ImagenEditingUIState.ImageGenerated -> { - // Set the bitmap that can be masked - bitmapToMask = uiState.bitmap - Image( - bitmap = uiState.bitmap.asImageBitmap(), - contentDescription = uiState.contentDescription, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .clickable { - currentDrawingPath = Path() - pathVersion++ - onImageClick(uiState.bitmap) - }, - ) - - DrawingCanvas( - currentDrawingPath = currentDrawingPath, - pathVersion = pathVersion, - onPathUpdate = { newPath, newVersion -> - currentDrawingPath = newPath - pathVersion = newVersion - }, - modifier = Modifier.fillMaxSize(), - ) - bitmapToMask?.let { currentSourceBitmap -> - Button( - onClick = { - val maskBitmap = createMaskBitmap( - currentSourceBitmap.width, - currentSourceBitmap.height, - currentDrawingPath, - ) - onMaskFinalized(currentSourceBitmap, maskBitmap) - // Optionally reset the path after finalizing - currentDrawingPath = Path() - pathVersion++ - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - enabled = !currentDrawingPath.isEmpty, - ) { - Text(stringResource(R.string.editing_finalize_mask_button)) - } - } - } - - is ImagenEditingUIState.ImageMasked -> { - bitmapToMask = null - - Box(modifier = Modifier.fillMaxSize()) { - Image( - bitmap = uiState.originalBitmap.asImageBitmap(), - contentDescription = uiState.contentDescription, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .clickable { - bitmapToMask = uiState.originalBitmap - currentDrawingPath = Path() - pathVersion++ - onImageClick(uiState.originalBitmap) - }, - ) - Image( - bitmap = uiState.maskBitmap.asImageBitmap(), - contentDescription = "Mask Overlay", - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .graphicsLayer(alpha = 0.5f), - ) - } - } - - is ImagenEditingUIState.Error -> { - Text( - text = uiState.message ?: stringResource(R.string.editing_error_message_unknown), - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), - textAlign = TextAlign.Center, - ) - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - } - } -} - -@Composable -private fun DrawingCanvas(currentDrawingPath: Path, pathVersion: Int, onPathUpdate: (Path, Int) -> Unit, modifier: Modifier = Modifier) { - var internalPath by remember(pathVersion) { mutableStateOf(currentDrawingPath) } - var internalVersion by remember { mutableIntStateOf(pathVersion) } - val pathToDraw = remember(internalVersion) { internalPath } - - Canvas( - modifier = modifier - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { offset -> - internalPath = Path().apply { moveTo(offset.x, offset.y) } - internalVersion++ - onPathUpdate(internalPath, internalVersion) - }, - onDrag = { change, _ -> - internalPath.lineTo(change.position.x, change.position.y) - internalVersion++ - onPathUpdate(internalPath, internalVersion) - change.consume() - }, - ) - }, - ) { - if (!pathToDraw.isEmpty) { - drawPath( - path = pathToDraw, - color = Color.White.copy(alpha = 0.7f), - style = Stroke( - width = 40f, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - } - } -} -private fun createMaskBitmap(width: Int, height: Int, composePath: Path?): Bitmap { - val maskBitmap = createBitmap(width, height) - val canvas = AndroidCanvas(maskBitmap) - canvas.drawColor(android.graphics.Color.BLACK) - - composePath?.let { - if (!it.isEmpty) { - val androidPath = it.asAndroidPath() - val paint = AndroidPaint().apply { - color = android.graphics.Color.WHITE // Drawn area is white in the mask - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeWidth = 40f - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - canvas.drawPath(androidPath, paint) - } - } - return maskBitmap -} diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt index 47bbc20d..13a323a4 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt @@ -17,8 +17,10 @@ package com.android.ai.samples.imagenediting.ui +import android.graphics.Bitmap import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size @@ -47,6 +49,7 @@ import com.android.ai.samples.imagenediting.R fun GenerationInput( uiState: ImagenEditingUIState, onGenerateClick: (String) -> Unit, + onOutpaintClick: (prompt: String) -> Unit, onInpaintClick: (prompt: String) -> Unit, enabled: Boolean, modifier: Modifier = Modifier, @@ -81,21 +84,39 @@ fun GenerationInput( ) if (uiState !is ImagenEditingUIState.ImageMasked) { - Button( - onClick = { - onGenerateClick(promptTextField) - }, - enabled = canGenerate, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Icons.Default.SmartToy, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.editing_generate_button)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + onGenerateClick(promptTextField) + }, + enabled = canGenerate, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.editing_generate_button)) + } + + val bitmap: Bitmap? = (uiState as? ImagenEditingUIState.ImageGenerated)?.bitmap + if (bitmap != null) { + Button( + onClick = { + onOutpaintClick(promptTextField) + }, + enabled = enabled && bitmap.width < 4096 && bitmap.height < 4096, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Default.AutoFixHigh, contentDescription = null) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.editing_expand_button)) + } + } } } @@ -109,7 +130,7 @@ fun GenerationInput( modifier = Modifier.fillMaxWidth(), ) { Icon( - Icons.Default.AutoFixHigh, // Using a different icon for inpainting + Icons.Default.AutoFixHigh, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize), ) diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt index cb7a4af4..70fccce5 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.imagenediting.R +import com.google.firebase.ai.type.Dimensions @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,6 +74,7 @@ fun ImagenEditingScreen(viewModel: ImagenEditingViewModel = hiltViewModel()) { bitmapForMasking = bitmapForMasking, onGenerateClick = viewModel::generateImage, onInpaintClick = { source, mask, prompt -> viewModel.inpaintImage(source, mask, prompt) }, + onOutpaintClick = { source, dimensions, prompt -> viewModel.outpaintImage(source, dimensions, prompt) }, onImageToMaskClicked = { bitmap -> viewModel.onStartMasking(bitmap) }, onImageMaskReady = { source, mask -> viewModel.onImageMaskReady(source, mask) }, onCancelMasking = viewModel::onCancelMasking, @@ -89,6 +91,7 @@ private fun ImagenEditingScreenContent( bitmapForMasking: Bitmap?, onGenerateClick: (String) -> Unit, onInpaintClick: (source: Bitmap, mask: Bitmap, prompt: String) -> Unit, + onOutpaintClick: (source: Bitmap, dimensions: Dimensions, prompt: String) -> Unit, onImageToMaskClicked: (Bitmap) -> Unit, onImageMaskReady: (source: Bitmap, mask: Bitmap) -> Unit, onCancelMasking: () -> Unit, @@ -147,6 +150,15 @@ private fun ImagenEditingScreenContent( onInpaintClick(uiState.originalBitmap, uiState.maskBitmap, prompt) } }, + onOutpaintClick = { prompt -> + if (uiState is ImagenEditingUIState.ImageGenerated) { + val newDimensions = Dimensions( + width = uiState.bitmap.width * 2, + height = uiState.bitmap.height * 2, + ) + onOutpaintClick(uiState.bitmap, newDimensions, prompt) + } + }, enabled = !isGenerating, modifier = Modifier.fillMaxWidth(), ) diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingUIState.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingUIState.kt index 0a37a73b..e70a50a0 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingUIState.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingUIState.kt @@ -16,12 +16,14 @@ package com.android.ai.samples.imagenediting.ui import android.graphics.Bitmap +import com.google.firebase.ai.type.Dimensions sealed interface ImagenEditingUIState { data object Initial : ImagenEditingUIState data object Loading : ImagenEditingUIState data class ImageGenerated( val bitmap: Bitmap, + val dimensions: Dimensions, val contentDescription: String, ) : ImagenEditingUIState diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt index f259b160..815a4b1d 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt @@ -19,6 +19,7 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.imagenediting.data.ImagenEditingDataSource +import com.google.firebase.ai.type.Dimensions import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -42,7 +43,11 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I viewModelScope.launch { try { val bitmap = imagenDataSource.generateImage(prompt) - _uiState.value = ImagenEditingUIState.ImageGenerated(bitmap, contentDescription = prompt) + _uiState.value = ImagenEditingUIState.ImageGenerated( + bitmap, + Dimensions(bitmap.width, bitmap.height), + contentDescription = prompt, + ) } catch (e: Exception) { _uiState.value = ImagenEditingUIState.Error(e.message) } @@ -61,6 +66,7 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I ) _uiState.value = ImagenEditingUIState.ImageGenerated( bitmap = inpaintedBitmap, + dimensions = Dimensions(inpaintedBitmap.width, inpaintedBitmap.height), contentDescription = "Inpainted image based on prompt: $prompt", ) } catch (e: Exception) { @@ -69,6 +75,31 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I } } + fun outpaintImage(sourceImage: Bitmap, dimensions: Dimensions, prompt: String) { + if (dimensions.width > 4096 || dimensions.height > 4096) { + _uiState.value = ImagenEditingUIState.Error("Dimensions are too large. Maximum dimensions are 4096x4096") + return + } + + _uiState.value = ImagenEditingUIState.Loading + viewModelScope.launch { + try { + val outpaintedBitmap = imagenDataSource.outpaintImage( + sourceImage = sourceImage, + targetDimensions = dimensions, + prompt = prompt, + ) + _uiState.value = ImagenEditingUIState.ImageGenerated( + bitmap = outpaintedBitmap, + dimensions = Dimensions(outpaintedBitmap.width, outpaintedBitmap.height), + contentDescription = "Outpainted image based on prompt: $prompt", + ) + } catch (e: Exception) { + _uiState.value = ImagenEditingUIState.Error(e.localizedMessage ?: "An unknown error occurred during outpainting") + } + } + } + fun onStartMasking(bitmap: Bitmap) { _bitmapForMasking.value = bitmap _showMaskEditor.value = true diff --git a/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml b/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml index cfa2181b..8ac8a968 100644 --- a/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml +++ b/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml @@ -34,4 +34,5 @@ The generated image The generated mask Draw a mask + Expand \ No newline at end of file