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