diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 411a9c8d64..f841ffbafd 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -350,6 +350,7 @@ dontMock BIES binarize binarized +binarizing unclip phonemizations phonemizes @@ -428,7 +429,17 @@ zipdepth xiaobei xiaoxiao xiaoyi +xiaoni yunjian yunxi yunxia yunyang +migan +inpaint +inpainted +inpainter +inpainting +Picsart +FFHQ +grayscale +Premul diff --git a/apps/computer-vision/app.json b/apps/computer-vision/app.json index 3a47ccac25..6e56cdea40 100644 --- a/apps/computer-vision/app.json +++ b/apps/computer-vision/app.json @@ -17,7 +17,9 @@ "supportsTablet": true, "bundleIdentifier": "com.anonymous.computervision", "infoPlist": { - "NSCameraUsageDescription": "Process photo from camera" + "NSCameraUsageDescription": "Process photo from camera", + "NSLocalNetworkUsageDescription": "Connect to the Metro dev server and local model files on your computer", + "NSBonjourServices": ["_http._tcp"] } }, "android": { diff --git a/apps/computer-vision/app/_layout.tsx b/apps/computer-vision/app/_layout.tsx index db3837fe98..f40943ea8b 100644 --- a/apps/computer-vision/app/_layout.tsx +++ b/apps/computer-vision/app/_layout.tsx @@ -48,6 +48,13 @@ export default function Layout() { title: 'Style Transfer', }} /> + router.navigate('styleTransfer/')}> Style Transfer + router.navigate('inpainting/')}> + Image Inpainting + router.navigate('textToImage/')}> Text to Image diff --git a/apps/computer-vision/app/inpainting/index.tsx b/apps/computer-vision/app/inpainting/index.tsx new file mode 100644 index 0000000000..afbe5cbe28 --- /dev/null +++ b/apps/computer-vision/app/inpainting/index.tsx @@ -0,0 +1,285 @@ +import React, { useState } from 'react'; +import { View, Text, Platform, TouchableOpacity, StyleSheet } from 'react-native'; +// The gesture-handler ScrollView yields to the brush pan over the photo, so +// painting never scrolls the page and scrolling still works everywhere else. +import { ScrollView } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { commonStyles, theme } from '../../theme'; +import { + useImage, + Skia, + ColorType, + AlphaType, + type SkImage as SkiaImageType, +} from '@shopify/react-native-skia'; +import { useInpainter, models } from 'react-native-executorch'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import { getImage, skImageToBuffer } from '../../utils'; +import { ModelPicker, type ModelOption } from '../../components/ModelPicker'; +import { ImageViewport } from '../../components/ImageViewport'; +import { ModelStatus } from '../../components/ModelStatus'; +import { LatencyIndicator } from '../../components/LatencyIndicator'; +import { Button } from '../../components/Button'; +import { MaskCanvas, rasterizeMask, type MaskStroke } from '../../components/MaskCanvas'; + +const MODEL_OPTIONS: ModelOption[] = [ + { label: 'MI-GAN 512 (XNNPACK FP16)', value: models.inpainting.MIGAN.SIZE_512.XNNPACK_FP16 }, + { + label: 'MI-GAN 512 (CoreML FP16)', + value: models.inpainting.MIGAN.SIZE_512.COREML_FP16, + disabled: Platform.OS !== 'ios', + }, + { + label: 'MI-GAN 512 (Vulkan FP16)', + value: models.inpainting.MIGAN.SIZE_512.VULKAN_FP16, + disabled: Platform.OS !== 'android', + }, + { label: 'MI-GAN 256 (XNNPACK FP16)', value: models.inpainting.MIGAN.SIZE_256.XNNPACK_FP16 }, + { + label: 'MI-GAN 256 (CoreML FP16)', + value: models.inpainting.MIGAN.SIZE_256.COREML_FP16, + disabled: Platform.OS !== 'ios', + }, + { + label: 'MI-GAN 256 (Vulkan FP16)', + value: models.inpainting.MIGAN.SIZE_256.VULKAN_FP16, + disabled: Platform.OS !== 'android', + }, + { + label: 'MI-GAN Faces 256 (XNNPACK FP16)', + value: models.inpainting.MIGAN.FACES_256.XNNPACK_FP16, + }, + { + label: 'MI-GAN Faces 256 (CoreML FP16)', + value: models.inpainting.MIGAN.FACES_256.COREML_FP16, + disabled: Platform.OS !== 'ios', + }, + { + label: 'MI-GAN Faces 256 (Vulkan FP16)', + value: models.inpainting.MIGAN.FACES_256.VULKAN_FP16, + disabled: Platform.OS !== 'android', + }, +]; + +/** Brush diameters in image pixels. */ +const BRUSH_SIZES = [16, 32, 64] as const; + +function InpaintingContent() { + const insets = useSafeAreaInsets(); + const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[0].value); + const [imageUri, setImageUri] = useState(null); + const [strokes, setStrokes] = useState([]); + const [brushWidth, setBrushWidth] = useState(BRUSH_SIZES[1]); + const [isProcessing, setIsProcessing] = useState(false); + const [resultImage, setResultImage] = useState(null); + const [showOriginal, setShowOriginal] = useState(false); + const [latency, setLatency] = useState(null); + const [error, setError] = useState(null); + + const skiaImage = useImage(imageUri, (err) => setError(err.message || String(err))); + + const { + isReady, + downloadProgress, + error: loadError, + inpaint, + inpaintWorklet, + } = useInpainter(selectedModel); + + const resetResult = () => { + setResultImage(null); + setShowOriginal(false); + setLatency(null); + }; + + const handlePickImage = async (useCamera: boolean) => { + setError(null); + try { + const uri = await getImage(useCamera); + if (uri) { + setImageUri(uri); + setStrokes([]); + resetResult(); + } + } catch (e: any) { + setError(e.message || String(e)); + } + }; + + const runInpainting = async (sync: boolean) => { + if (!skiaImage || !inpaint || !inpaintWorklet) return; + if (!sync) setIsProcessing(true); + setError(null); + try { + const image = skImageToBuffer(skiaImage); + const mask = rasterizeMask(strokes, image.width, image.height); + if (!mask) throw new Error('Could not rasterize the mask'); + + const start = Date.now(); + const output = sync ? inpaintWorklet(image, mask) : await inpaint(image, mask); + setLatency(Date.now() - start); + + const info = { + width: output.width, + height: output.height, + colorType: ColorType.RGBA_8888, + alphaType: AlphaType.Premul, + }; + const result = Skia.Image.MakeImage(info, Skia.Data.fromBytes(output.data), output.width * 4); + if (!result) throw new Error('Failed to decode the inpainted image in Skia'); + setResultImage(result); + setShowOriginal(false); + } catch (e: any) { + setError(e.message || String(e)); + } finally { + if (!sync) setIsProcessing(false); + } + }; + + const activeError = loadError ? String(loadError) : error; + const showingResult = resultImage !== null && !showOriginal; + const activeImage = showingResult ? resultImage : skiaImage; + const canRun = !!skiaImage && isReady && !isProcessing && strokes.length > 0; + + return ( + + + Pick a photo, paint over what should disappear, and run the model to fill it in. + + + { + setSelectedModel(model); + resetResult(); + setError(null); + }} + /> + + + + handlePickImage(false)}> + {skiaImage && !showingResult && ( + + )} + + + {skiaImage && !showingResult && ( + + Brush + {BRUSH_SIZES.map((size) => ( + setBrushWidth(size)} + > + + {size}px + + + ))} + { + setStrokes([]); + resetResult(); + }} + disabled={strokes.length === 0} + > + Clear + + + )} + + +