react-native-facefusion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +259 -0
- package/android/build.gradle +129 -0
- package/android/src/main/AndroidManifest.xml +66 -0
- package/android/src/main/cpp/CMakeLists.txt +73 -0
- package/android/src/main/java/com/facefusion/BitmapDecode.kt +112 -0
- package/android/src/main/java/com/facefusion/ContentGate.kt +86 -0
- package/android/src/main/java/com/facefusion/DeviceProbe.kt +158 -0
- package/android/src/main/java/com/facefusion/FaceCrop.kt +57 -0
- package/android/src/main/java/com/facefusion/FacefusionModule.kt +396 -0
- package/android/src/main/java/com/facefusion/FacefusionPackage.kt +37 -0
- package/android/src/main/java/com/facefusion/FacefusionPreviewView.kt +30 -0
- package/android/src/main/java/com/facefusion/FacefusionPreviewViewManager.kt +25 -0
- package/android/src/main/java/com/facefusion/GallerySave.kt +82 -0
- package/android/src/main/java/com/facefusion/ModelDownload.kt +351 -0
- package/android/src/main/java/com/facefusion/ModelPaths.kt +120 -0
- package/android/src/main/java/com/facefusion/PhotoSwap.kt +125 -0
- package/android/src/main/java/com/facefusion/PipeGuard.kt +74 -0
- package/android/src/main/java/com/facefusion/PreviewSurfaceHolder.kt +104 -0
- package/android/src/main/java/com/facefusion/SourceFaces.kt +125 -0
- package/android/src/main/java/com/facefusion/SwapConfig.kt +31 -0
- package/android/src/main/java/com/facefusion/TargetFaces.kt +88 -0
- package/android/src/main/java/com/facefusion/VideoSwap.kt +501 -0
- package/android/src/main/java/com/facefusion/VideoSwapService.kt +85 -0
- package/android/src/main/java/com/facefusion/mobile/NativePipe.kt +192 -0
- package/lib/module/FacefusionPreview.js +6 -0
- package/lib/module/FacefusionPreview.js.map +1 -0
- package/lib/module/FacefusionPreview.native.js +15 -0
- package/lib/module/FacefusionPreview.native.js.map +1 -0
- package/lib/module/NativeFacefusion.js +53 -0
- package/lib/module/NativeFacefusion.js.map +1 -0
- package/lib/module/detectSourceFaces.js +6 -0
- package/lib/module/detectSourceFaces.js.map +1 -0
- package/lib/module/detectSourceFaces.native.js +15 -0
- package/lib/module/detectSourceFaces.native.js.map +1 -0
- package/lib/module/detectTargetFaces.js +6 -0
- package/lib/module/detectTargetFaces.js.map +1 -0
- package/lib/module/detectTargetFaces.native.js +13 -0
- package/lib/module/detectTargetFaces.native.js.map +1 -0
- package/lib/module/index.js +11 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/models.js +16 -0
- package/lib/module/models.js.map +1 -0
- package/lib/module/models.native.js +43 -0
- package/lib/module/models.native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/probeDevice.js +6 -0
- package/lib/module/probeDevice.js.map +1 -0
- package/lib/module/probeDevice.native.js +14 -0
- package/lib/module/probeDevice.native.js.map +1 -0
- package/lib/module/saveToGallery.js +6 -0
- package/lib/module/saveToGallery.js.map +1 -0
- package/lib/module/saveToGallery.native.js +15 -0
- package/lib/module/saveToGallery.native.js.map +1 -0
- package/lib/module/swapPhoto.js +6 -0
- package/lib/module/swapPhoto.js.map +1 -0
- package/lib/module/swapPhoto.native.js +16 -0
- package/lib/module/swapPhoto.native.js.map +1 -0
- package/lib/module/swapVideo.js +12 -0
- package/lib/module/swapVideo.js.map +1 -0
- package/lib/module/swapVideo.native.js +29 -0
- package/lib/module/swapVideo.native.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/FacefusionPreview.d.ts +4 -0
- package/lib/typescript/src/FacefusionPreview.d.ts.map +1 -0
- package/lib/typescript/src/FacefusionPreview.native.d.ts +142 -0
- package/lib/typescript/src/FacefusionPreview.native.d.ts.map +1 -0
- package/lib/typescript/src/NativeFacefusion.d.ts +291 -0
- package/lib/typescript/src/NativeFacefusion.d.ts.map +1 -0
- package/lib/typescript/src/detectSourceFaces.d.ts +3 -0
- package/lib/typescript/src/detectSourceFaces.d.ts.map +1 -0
- package/lib/typescript/src/detectSourceFaces.native.d.ts +11 -0
- package/lib/typescript/src/detectSourceFaces.native.d.ts.map +1 -0
- package/lib/typescript/src/detectTargetFaces.d.ts +3 -0
- package/lib/typescript/src/detectTargetFaces.d.ts.map +1 -0
- package/lib/typescript/src/detectTargetFaces.native.d.ts +9 -0
- package/lib/typescript/src/detectTargetFaces.native.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/models.d.ts +7 -0
- package/lib/typescript/src/models.d.ts.map +1 -0
- package/lib/typescript/src/models.native.d.ts +31 -0
- package/lib/typescript/src/models.native.d.ts.map +1 -0
- package/lib/typescript/src/probeDevice.d.ts +3 -0
- package/lib/typescript/src/probeDevice.d.ts.map +1 -0
- package/lib/typescript/src/probeDevice.native.d.ts +10 -0
- package/lib/typescript/src/probeDevice.native.d.ts.map +1 -0
- package/lib/typescript/src/saveToGallery.d.ts +2 -0
- package/lib/typescript/src/saveToGallery.d.ts.map +1 -0
- package/lib/typescript/src/saveToGallery.native.d.ts +9 -0
- package/lib/typescript/src/saveToGallery.native.d.ts.map +1 -0
- package/lib/typescript/src/swapPhoto.d.ts +3 -0
- package/lib/typescript/src/swapPhoto.d.ts.map +1 -0
- package/lib/typescript/src/swapPhoto.native.d.ts +12 -0
- package/lib/typescript/src/swapPhoto.native.d.ts.map +1 -0
- package/lib/typescript/src/swapVideo.d.ts +6 -0
- package/lib/typescript/src/swapVideo.d.ts.map +1 -0
- package/lib/typescript/src/swapVideo.native.d.ts +20 -0
- package/lib/typescript/src/swapVideo.native.d.ts.map +1 -0
- package/package.json +132 -0
- package/patches/ffjni-analyse-faces.patch +63 -0
- package/scripts/check-tarball.sh +68 -0
- package/scripts/fetch-upstream.sh +91 -0
- package/src/FacefusionPreview.native.tsx +18 -0
- package/src/FacefusionPreview.tsx +9 -0
- package/src/NativeFacefusion.ts +324 -0
- package/src/detectSourceFaces.native.tsx +17 -0
- package/src/detectSourceFaces.tsx +10 -0
- package/src/detectTargetFaces.native.tsx +15 -0
- package/src/detectTargetFaces.tsx +10 -0
- package/src/index.tsx +25 -0
- package/src/models.native.tsx +45 -0
- package/src/models.tsx +23 -0
- package/src/probeDevice.native.tsx +13 -0
- package/src/probeDevice.tsx +7 -0
- package/src/saveToGallery.native.tsx +16 -0
- package/src/saveToGallery.tsx +9 -0
- package/src/swapPhoto.native.tsx +20 -0
- package/src/swapPhoto.tsx +12 -0
- package/src/swapVideo.native.tsx +40 -0
- package/src/swapVideo.tsx +31 -0
- package/third_party/facefusion-mobile/NOTICE +49 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Rect
|
|
6
|
+
import android.view.Surface
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The live-preview sink for [PhotoSwap] and [VideoSwap]: a global holder for whichever
|
|
10
|
+
* [FacefusionPreviewView] is currently mounted, if any.
|
|
11
|
+
*
|
|
12
|
+
* **Why a singleton rather than a parameter threaded through the swap call.** `g_pipe` is
|
|
13
|
+
* already a single C++ global — only one swap can run at a time ([PipeGuard]) — so there is
|
|
14
|
+
* never more than one frame stream to preview. A JS-side prop or a second TurboModule call
|
|
15
|
+
* to "attach" a surface would need to cross the bridge with a view reference; a plain object
|
|
16
|
+
* both sides can reach avoids that, and mirrors how [NativePipe] itself is one process-wide
|
|
17
|
+
* object rather than an instance per swap.
|
|
18
|
+
*
|
|
19
|
+
* **Never touches [NativePipe] or the vendored C++.** `bgr`/`w`/`h` here are exactly the
|
|
20
|
+
* bytes [VideoSwap] and [PhotoSwap] already hold in JVM memory after [NativePipe.processFrame]
|
|
21
|
+
* returns — this only has to get them onto a [Surface]'s [Canvas], which is a plain Android
|
|
22
|
+
* API. That is what keeps this out of the "never pass frames across the JS bridge" rule: no
|
|
23
|
+
* pixel ever reaches JS, only the mounted `<FacefusionPreview />` view does, and rendering
|
|
24
|
+
* happens entirely on the native side of it.
|
|
25
|
+
*/
|
|
26
|
+
object PreviewSurfaceHolder {
|
|
27
|
+
|
|
28
|
+
// Caps preview redraws independent of how fast the swap loop itself runs -- a 720p frame
|
|
29
|
+
// decoded to a Bitmap and blitted to a Canvas is not free, and the swap loop's own pace
|
|
30
|
+
// (tens of fps) is faster than a screen needs to look "live". ~15 fps is smooth to the eye
|
|
31
|
+
// and cheap enough not to be the thing that slows the swap down.
|
|
32
|
+
private const val MIN_INTERVAL_NANOS = 66_000_000L
|
|
33
|
+
|
|
34
|
+
@Volatile
|
|
35
|
+
private var surface: Surface? = null
|
|
36
|
+
|
|
37
|
+
@Volatile
|
|
38
|
+
private var lastDrawNanos = 0L
|
|
39
|
+
|
|
40
|
+
/** Called by [FacefusionPreviewView] once its [Surface] exists. */
|
|
41
|
+
fun attach(newSurface: Surface) {
|
|
42
|
+
surface = newSurface
|
|
43
|
+
lastDrawNanos = 0L
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Called by [FacefusionPreviewView] when its [Surface] is torn down. Idempotent. */
|
|
47
|
+
fun detach(oldSurface: Surface) {
|
|
48
|
+
// Only clear if it's still the same surface -- a fast unmount+remount (view recycling)
|
|
49
|
+
// could otherwise have a stale detach() race ahead of the new attach() and wipe it.
|
|
50
|
+
if (surface === oldSurface) surface = null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Draws one BGR frame, letterboxed to fit the attached surface. A no-op — one [Volatile]
|
|
55
|
+
* read — when nothing is mounted, so [VideoSwap]'s loop pays nothing when no preview is
|
|
56
|
+
* showing. Also a no-op inside the throttle window.
|
|
57
|
+
*
|
|
58
|
+
* Safe to call from any thread; [VideoSwap] and [PhotoSwap] both call it from their own
|
|
59
|
+
* worker thread, never the UI thread.
|
|
60
|
+
*/
|
|
61
|
+
fun draw(bgr: ByteArray, w: Int, h: Int) {
|
|
62
|
+
val target = surface ?: return
|
|
63
|
+
if (!target.isValid) return
|
|
64
|
+
|
|
65
|
+
val now = System.nanoTime()
|
|
66
|
+
if (now - lastDrawNanos < MIN_INTERVAL_NANOS) return
|
|
67
|
+
|
|
68
|
+
val argb = com.facefusion.mobile.NativePipe.bgrToArgb(bgr, w, h, w, h)
|
|
69
|
+
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
70
|
+
bitmap.setPixels(argb, 0, w, 0, 0, w, h)
|
|
71
|
+
|
|
72
|
+
// lockCanvas/unlockCanvasAndPost can throw if the surface is torn down concurrently with
|
|
73
|
+
// this call (SurfaceHolder.Callback runs on the UI thread, this runs on the swap
|
|
74
|
+
// worker) -- caught rather than synchronized against, since a dropped preview frame
|
|
75
|
+
// during teardown is harmless and a lock here would block the swap loop on the UI thread.
|
|
76
|
+
try {
|
|
77
|
+
val canvas: Canvas = target.lockCanvas(null)
|
|
78
|
+
try {
|
|
79
|
+
val dst = fitRect(w, h, canvas.width, canvas.height)
|
|
80
|
+
canvas.drawColor(android.graphics.Color.BLACK)
|
|
81
|
+
canvas.drawBitmap(bitmap, null, dst, null)
|
|
82
|
+
} finally {
|
|
83
|
+
target.unlockCanvasAndPost(canvas)
|
|
84
|
+
}
|
|
85
|
+
lastDrawNanos = now
|
|
86
|
+
} catch (_: Exception) {
|
|
87
|
+
// Surface gone or going -- next attach() (or the next frame, if it recovers) picks
|
|
88
|
+
// back up. Nothing here is worth crashing a swap over.
|
|
89
|
+
} finally {
|
|
90
|
+
bitmap.recycle()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Centers a `srcW`x`srcH` rect inside `dstW`x`dstH`, preserving aspect ratio. */
|
|
95
|
+
private fun fitRect(srcW: Int, srcH: Int, dstW: Int, dstH: Int): Rect {
|
|
96
|
+
if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) return Rect(0, 0, dstW, dstH)
|
|
97
|
+
val scale = minOf(dstW.toFloat() / srcW, dstH.toFloat() / srcH)
|
|
98
|
+
val w = (srcW * scale).toInt()
|
|
99
|
+
val h = (srcH * scale).toInt()
|
|
100
|
+
val left = (dstW - w) / 2
|
|
101
|
+
val top = (dstH - h) / 2
|
|
102
|
+
return Rect(left, top, left + w, top + h)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import com.facefusion.mobile.NativePipe
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One face found in an image.
|
|
10
|
+
*
|
|
11
|
+
* The box is in the pixel coordinates of **the bitmap that was actually analysed**, which
|
|
12
|
+
* is not the file on disk: a source photo is decoded capped at [BitmapDecode.SOURCE_MAX],
|
|
13
|
+
* a photo target at [BitmapDecode.TARGET_MAX], and a video target not capped at all (its
|
|
14
|
+
* own frame size). That is deliberate — it is the same space [PhotoSwap]/[VideoSwap] crop
|
|
15
|
+
* in, which is what lets a box picked here be handed straight back as
|
|
16
|
+
* `SwapOptions.targetFaceBox` with no conversion.
|
|
17
|
+
*
|
|
18
|
+
* It also means a caller cannot work out the scale on their own, so [imageWidth] and
|
|
19
|
+
* [imageHeight] travel with every face. Without them, drawing this box over the original
|
|
20
|
+
* file — the obvious thing to do with a detection API — silently lands in the wrong place
|
|
21
|
+
* on any photo bigger than the cap, and there is no way to detect that from the outside.
|
|
22
|
+
*/
|
|
23
|
+
data class DetectedFace(
|
|
24
|
+
val left: Float,
|
|
25
|
+
val top: Float,
|
|
26
|
+
val right: Float,
|
|
27
|
+
val bottom: Float,
|
|
28
|
+
/** Detector confidence, `0..1`. */
|
|
29
|
+
val score: Float,
|
|
30
|
+
/** Width of the analysed bitmap, the space [left]/[right] are in. */
|
|
31
|
+
val imageWidth: Int,
|
|
32
|
+
/** Height of the analysed bitmap, the space [top]/[bottom] are in. */
|
|
33
|
+
val imageHeight: Int,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detects every face in a source photo, and crops to one of them for [PhotoSwap]/
|
|
38
|
+
* [VideoSwap] to use as the identity instead of always "the largest" — see ADR-0012
|
|
39
|
+
* (`docs/06-decisions/0012-source-face-picker-patches-ffjni.md`).
|
|
40
|
+
*
|
|
41
|
+
* [NativePipe.analyseFaces] is the one native symbol in this project that is not
|
|
42
|
+
* upstream's own — a small, tracked patch (`docs/02-upstream.md` "Patches"), because
|
|
43
|
+
* reaching the already-warm pipeline for a detect-only pass needed a new export inside
|
|
44
|
+
* `ffjni.cpp` itself. Everything below this point is our own layer, same as the rest of
|
|
45
|
+
* the app, and needed no further native changes: choosing a face is done by cropping the
|
|
46
|
+
* source bitmap and calling the existing, unmodified `setSource`, not by a new native
|
|
47
|
+
* "pick face N" entry point.
|
|
48
|
+
*/
|
|
49
|
+
object SourceFaces {
|
|
50
|
+
|
|
51
|
+
/** Mirrors [PhotoSwap.ModelsMissing] — same required models a swap needs. */
|
|
52
|
+
class ModelsMissing(message: String) : Exception(message)
|
|
53
|
+
|
|
54
|
+
/** How much wider/taller than the detected box to crop, so `setSource`'s own detector
|
|
55
|
+
* sees the same kind of context it would in an uncropped photo instead of a box drawn
|
|
56
|
+
* exactly to the model's own edges — a hair too tight can lose the chin or forehead. */
|
|
57
|
+
private const val MARGIN = 0.4f
|
|
58
|
+
|
|
59
|
+
fun detect(context: Context, sourcePath: String, cfg: SwapConfig): List<DetectedFace> {
|
|
60
|
+
NativePipe.loadError?.let {
|
|
61
|
+
throw IllegalStateException("libffnative.so did not load: $it")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val tier = ModelPaths.tier(context)
|
|
65
|
+
val missing = ModelPaths.missing(context, tier)
|
|
66
|
+
if (missing.isNotEmpty()) {
|
|
67
|
+
throw ModelsMissing(
|
|
68
|
+
"Models missing for $tier: ${missing.joinToString(", ")} — call downloadModels() first"
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
val bitmap = decode(sourcePath)
|
|
73
|
+
return PipeGuard.run(context, tier, cfg) {
|
|
74
|
+
val bgr = NativePipe.argbToBgr(pixelsOf(bitmap), bitmap.width, bitmap.height)
|
|
75
|
+
val flat = NativePipe.analyseFaces(bgr, bitmap.width, bitmap.height)
|
|
76
|
+
?: throw IllegalStateException(NativePipe.lastError())
|
|
77
|
+
(flat.indices step 5).map { i ->
|
|
78
|
+
DetectedFace(
|
|
79
|
+
flat[i],
|
|
80
|
+
flat[i + 1],
|
|
81
|
+
flat[i + 2],
|
|
82
|
+
flat[i + 3],
|
|
83
|
+
flat[i + 4],
|
|
84
|
+
bitmap.width,
|
|
85
|
+
bitmap.height,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Crops [bitmap] to the box `[left, top, right, bottom]` (as returned by [detect] /
|
|
93
|
+
* [NativePipe.analyseFaces]), expanded by [MARGIN] on every side and clamped to the
|
|
94
|
+
* bitmap's own bounds. The result is what gets handed to [NativePipe.setSource] — since
|
|
95
|
+
* it contains only (or overwhelmingly) the chosen face, `setSource`'s own unmodified
|
|
96
|
+
* "pick the largest face" logic naturally lands on it.
|
|
97
|
+
*/
|
|
98
|
+
fun cropToFace(bitmap: Bitmap, box: FloatArray): Bitmap {
|
|
99
|
+
require(box.size == 4) { "box must be [left, top, right, bottom]" }
|
|
100
|
+
val (boxLeft, boxTop, boxRight, boxBottom) = box
|
|
101
|
+
val w = boxRight - boxLeft
|
|
102
|
+
val h = boxBottom - boxTop
|
|
103
|
+
val left = (boxLeft - w * MARGIN).toInt().coerceIn(0, bitmap.width - 1)
|
|
104
|
+
val top = (boxTop - h * MARGIN).toInt().coerceIn(0, bitmap.height - 1)
|
|
105
|
+
val right = (boxRight + w * MARGIN).toInt().coerceIn(left + 1, bitmap.width)
|
|
106
|
+
val bottom = (boxBottom + h * MARGIN).toInt().coerceIn(top + 1, bitmap.height)
|
|
107
|
+
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Duplicated from PhotoSwap rather than shared — see that file's note on the same choice.
|
|
111
|
+
/**
|
|
112
|
+
* Capped at [BitmapDecode.SOURCE_MAX] — **the same cap [PhotoSwap] decodes the source
|
|
113
|
+
* with, and that is load-bearing.** The boxes this returns are in the decoded image's
|
|
114
|
+
* pixel coordinates and come back as `SwapOptions.sourceFaceBox`; if the two paths
|
|
115
|
+
* decoded at different scales, a picked face would crop the wrong region.
|
|
116
|
+
*/
|
|
117
|
+
private fun decode(path: String): Bitmap =
|
|
118
|
+
BitmapDecode.decode(path, BitmapDecode.SOURCE_MAX)
|
|
119
|
+
|
|
120
|
+
private fun pixelsOf(bitmap: Bitmap): IntArray {
|
|
121
|
+
val pixels = IntArray(bitmap.width * bitmap.height)
|
|
122
|
+
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
|
123
|
+
return pixels
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JS-tunable knobs for a swap, mirroring `ffpipe::Config`'s defaults for the `hyperswap`
|
|
5
|
+
* swapper ([ModelPaths.SWAPPER]) field for field.
|
|
6
|
+
*
|
|
7
|
+
* [maskPadding] is `[top, right, bottom, left]`, each `0..100`; out-of-range values are
|
|
8
|
+
* clamped again on the C++ side (`initEx` in `ffjni.cpp`), so this is a convenience default,
|
|
9
|
+
* not the only safety net.
|
|
10
|
+
*/
|
|
11
|
+
data class SwapConfig(
|
|
12
|
+
val swapperWeight: Float = 0.5f,
|
|
13
|
+
val maskBlur: Float = 0.3f,
|
|
14
|
+
val maskPadding: List<Int> = listOf(0, 0, 0, 0),
|
|
15
|
+
val detectorScore: Float = 0.5f,
|
|
16
|
+
val landmarkerScore: Float = 0.5f,
|
|
17
|
+
val pixelBoost: Int = 1,
|
|
18
|
+
val largestFaceOnly: Boolean = false,
|
|
19
|
+
val faceEnhance: Boolean = false,
|
|
20
|
+
val faceEnhancerBlend: Float = 0.8f,
|
|
21
|
+
) {
|
|
22
|
+
/**
|
|
23
|
+
* Identifies this config for [PipeGuard]'s "does the pipeline need reloading" check.
|
|
24
|
+
* A plain string rather than relying on `data class` equality because the values that
|
|
25
|
+
* matter are the ones that cross into `initEx`, not Kotlin identity.
|
|
26
|
+
*/
|
|
27
|
+
fun signature(): String = listOf(
|
|
28
|
+
swapperWeight, maskBlur, maskPadding.joinToString(","), detectorScore,
|
|
29
|
+
landmarkerScore, pixelBoost, largestFaceOnly, faceEnhance, faceEnhancerBlend,
|
|
30
|
+
).joinToString("|")
|
|
31
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.media.MediaMetadataRetriever
|
|
7
|
+
import com.facefusion.mobile.NativePipe
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detects every face in a *target* — a photo, or the first frame of a video — so a UI can
|
|
11
|
+
* let the user pick which one gets swapped, instead of every face found (or just the
|
|
12
|
+
* largest, via [SwapConfig.largestFaceOnly]). Pass the chosen box back as
|
|
13
|
+
* `targetFaceBox`; [PhotoSwap]/[VideoSwap] crop to it with [FaceCrop] the same way
|
|
14
|
+
* [SourceFaces] crops for `setSource`, so this needed no native change either.
|
|
15
|
+
*
|
|
16
|
+
* Structurally a twin of [SourceFaces.detect] — duplicated rather than shared, same as
|
|
17
|
+
* [VideoSwap]'s own note on why it doesn't share code with [PhotoSwap]: each is a small,
|
|
18
|
+
* self-contained entry point, and the one real difference (this one also has to handle a
|
|
19
|
+
* video target) doesn't belong in the source-only file.
|
|
20
|
+
*/
|
|
21
|
+
object TargetFaces {
|
|
22
|
+
|
|
23
|
+
/** Mirrors [PhotoSwap.ModelsMissing] — same required models a swap needs. */
|
|
24
|
+
class ModelsMissing(message: String) : Exception(message)
|
|
25
|
+
|
|
26
|
+
fun detect(context: Context, targetPath: String, cfg: SwapConfig): List<DetectedFace> {
|
|
27
|
+
NativePipe.loadError?.let {
|
|
28
|
+
throw IllegalStateException("libffnative.so did not load: $it")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
val tier = ModelPaths.tier(context)
|
|
32
|
+
val missing = ModelPaths.missing(context, tier)
|
|
33
|
+
if (missing.isNotEmpty()) {
|
|
34
|
+
throw ModelsMissing(
|
|
35
|
+
"Models missing for $tier: ${missing.joinToString(", ")} — call downloadModels() first"
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val bitmap = decode(targetPath)
|
|
40
|
+
return PipeGuard.run(context, tier, cfg) {
|
|
41
|
+
val bgr = NativePipe.argbToBgr(pixelsOf(bitmap), bitmap.width, bitmap.height)
|
|
42
|
+
val flat = NativePipe.analyseFaces(bgr, bitmap.width, bitmap.height)
|
|
43
|
+
?: throw IllegalStateException(NativePipe.lastError())
|
|
44
|
+
(flat.indices step 5).map { i ->
|
|
45
|
+
DetectedFace(
|
|
46
|
+
flat[i],
|
|
47
|
+
flat[i + 1],
|
|
48
|
+
flat[i + 2],
|
|
49
|
+
flat[i + 3],
|
|
50
|
+
flat[i + 4],
|
|
51
|
+
bitmap.width,
|
|
52
|
+
bitmap.height,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A photo decodes directly; a video falls back to its first frame, already rotated
|
|
59
|
+
* upright by [MediaMetadataRetriever] itself — the same orientation [VideoSwap]'s own
|
|
60
|
+
* loop presents to `processFrame`, which is what makes a box detected here line up with
|
|
61
|
+
* what that loop sees per frame, with no rotation math needed on this side at all. */
|
|
62
|
+
private fun decode(path: String): Bitmap {
|
|
63
|
+
// Capped at [BitmapDecode.TARGET_MAX] -- the same cap [PhotoSwap] decodes a photo
|
|
64
|
+
// target with, which is what keeps a box picked here aligned with what the swap
|
|
65
|
+
// actually crops. `decodeOrNull`, not `decode`, because "not a still image" is how the
|
|
66
|
+
// video branch below is reached.
|
|
67
|
+
BitmapDecode.decodeOrNull(path, BitmapDecode.TARGET_MAX)?.let { return it }
|
|
68
|
+
|
|
69
|
+
// A video frame arrives at the clip's own resolution, uncapped -- and deliberately so:
|
|
70
|
+
// VideoSwap's loop processes frames at that resolution too, so capping here would
|
|
71
|
+
// misalign every box against the frames the swap actually sees. Codec limits keep this
|
|
72
|
+
// bounded in practice where a photo's megapixels are not.
|
|
73
|
+
val retriever = MediaMetadataRetriever()
|
|
74
|
+
try {
|
|
75
|
+
retriever.setDataSource(path)
|
|
76
|
+
return retriever.frameAtTime
|
|
77
|
+
?: throw IllegalArgumentException("Could not decode image or video frame: $path")
|
|
78
|
+
} finally {
|
|
79
|
+
retriever.release()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun pixelsOf(bitmap: Bitmap): IntArray {
|
|
84
|
+
val pixels = IntArray(bitmap.width * bitmap.height)
|
|
85
|
+
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
|
86
|
+
return pixels
|
|
87
|
+
}
|
|
88
|
+
}
|