Add blur faces to media send v3.

This commit is contained in:
Alex Hart
2026-08-03 15:43:47 -03:00
parent af44dc3929
commit c70443cc75
13 changed files with 327 additions and 40 deletions
@@ -39,8 +39,9 @@ import org.signal.core.util.ThrottledDebouncer;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.AndroidFaceDetector;
import org.signal.imageeditor.core.ColorableRenderer;
import org.signal.imageeditor.core.FaceDetector;
import org.signal.imageeditor.core.ImageEditorView;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.SelectableRenderer;
@@ -48,7 +49,6 @@ import org.signal.imageeditor.core.TappableRenderer;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.model.EditorModel;
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
import org.signal.imageeditor.core.renderers.FaceBlurRenderer;
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
import org.signal.imageeditor.core.renderers.UriGlideRenderer;
import org.signal.mediasend.MediaConstraints;
@@ -76,6 +76,7 @@ import java.io.ByteArrayOutputStream;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -840,34 +841,12 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
}
private void renderFaceBlurs(@NonNull FaceDetectionResult result) {
List<FaceDetector.Face> faces = result.faces;
if (faces.isEmpty()) {
if (result.faces.isEmpty()) {
cachedFaceDetection = null;
return;
}
imageEditorView.getModel().pushUndoPoint();
Matrix faceMatrix = new Matrix();
for (FaceDetector.Face face : faces) {
Renderer faceBlurRenderer = new FaceBlurRenderer();
EditorElement element = new EditorElement(faceBlurRenderer, EditorModel.Z_MASK);
Matrix localMatrix = element.getLocalMatrix();
faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face.getBounds(), Matrix.ScaleToFit.FILL);
localMatrix.set(result.position);
localMatrix.preConcat(faceMatrix);
element.getFlags().setEditable(false)
.setSelectable(false)
.persist();
imageEditorView.getModel().addElementWithoutPushUndo(element);
}
imageEditorView.getModel().addFaceBlurs(result.faces, result.imageSize, result.position);
imageEditorView.invalidate();
cachedFaceDetection = new Pair<>(getUri(), result);
@@ -1101,16 +1080,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
}
private static class FaceDetectionResult {
private final List<FaceDetector.Face> faces;
private final Matrix position;
private final List<RectF> faces;
private final Point imageSize;
private final Matrix position;
private FaceDetectionResult(@NonNull List<FaceDetector.Face> faces, @NonNull Point imageSize, @NonNull Matrix position) {
this.faces = faces;
this.position = new Matrix(position);
Matrix imageProjectionMatrix = new Matrix();
imageProjectionMatrix.setRectToRect(new RectF(0, 0, imageSize.x, imageSize.y), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.FILL);
this.position.preConcat(imageProjectionMatrix);
this.faces = faces.stream().map(FaceDetector.Face::getBounds).collect(Collectors.toList());
this.imageSize = imageSize;
this.position = new Matrix(position);
}
}
@@ -325,6 +325,10 @@ class MediaSendViewModel(
is MediaEditScreenEvent.BrushWidthChanged -> {
setBrushWidth(mediaEditScreenEvent.tool, mediaEditScreenEvent.fraction)
}
is MediaEditScreenEvent.ToggleBlurFaces -> {
setBlurFacesEnabled(mediaEditScreenEvent.enabled)
}
}
}
@@ -350,6 +354,22 @@ class MediaSendViewModel(
return imageControllers.getOrCreate(uri, editorState.model)
}
/**
* Masks or unmasks the faces in the focused image. Enabling this is what triggers the detection itself, which the
* editor's own state reports on, so that the work outlives the composition it was requested from.
*/
private fun setBlurFacesEnabled(enabled: Boolean) {
val controller = focusedImageController() ?: return
if (enabled) {
viewModelScope.launch {
controller.blurFaces(MediaSendDependencies.application)
}
} else {
controller.clearFaceBlurs()
}
}
private fun setBrushWidth(tool: BrushTool, fraction: Float) {
val brushWidths = state.value.brushWidths.with(tool, fraction)
@@ -5,6 +5,7 @@
package org.signal.mediasend.edit
import android.content.Context
import android.graphics.Paint
import android.net.Uri
import androidx.compose.runtime.Stable
@@ -13,6 +14,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.SelectableRenderer
import org.signal.imageeditor.core.TappableRenderer
@@ -21,8 +24,10 @@ import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.FaceDetectionResult
import org.signal.mediasend.edit.image.HSVColorBarState
import org.signal.mediasend.edit.image.ImageEditorState
import org.signal.mediasend.edit.image.detectFaces
/**
* Holds the editor state for a single image (modes, undo, selection, etc.).
@@ -107,6 +112,22 @@ internal class ImageController(
val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT }
val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR }
var isDetectingFaces: Boolean by mutableStateOf(false)
private set
/**
* Whether the image's faces are masked, which is what the blur-faces toggle reflects. Taken from the model rather than
* the toggle so that undoing or clearing the masks turns it back off.
*/
val isBlurringFaces: Boolean by derivedStateOf {
// Reading the revision is what re-derives this when masks are added to or removed from the model.
imageEditorState.revision
isDetectingFaces || editorModel.hasFaceRenderer()
}
private var cachedFaceDetection: FaceDetectionResult? = null
private var isFaceBlurRequested: Boolean = false
val brushTool: BrushTool? by derivedStateOf {
when (mode) {
Mode.DRAW -> BrushTool.MARKER
@@ -284,6 +305,63 @@ internal class ImageController(
}
}
/**
* Masks every face in the image, finding them first unless a previous detection still describes the image as it is now
* cropped. Suspends for as long as the detection takes, which [isDetectingFaces] reports so the screen can say so.
*/
suspend fun blurFaces(context: Context) {
isFaceBlurRequested = true
if (isDetectingFaces) {
return
}
val cached = cachedFaceDetection?.takeIf { it.matches(editorModel) }
if (cached != null) {
applyFaceBlurs(cached)
return
}
isDetectingFaces = true
val result = try {
withContext(Dispatchers.Default) {
detectFaces(context, editorModel, imageEditorState.typefaceProvider)
}
} finally {
isDetectingFaces = false
}
// The toggle can go back off while detection runs, and finding faces after that must not mask them anyway.
if (isFaceBlurRequested) {
applyFaceBlurs(result)
}
}
fun clearFaceBlurs() {
isFaceBlurRequested = false
if (!editorModel.hasFaceRenderer()) {
return
}
editorModel.clearFaceRenderers()
drawSessionDirty = true
imageEditorState.invalidate()
}
private fun applyFaceBlurs(result: FaceDetectionResult) {
if (result.faces.isEmpty()) {
// Not worth keeping: a re-run once the image has loaded, or after a crop, may well find something.
cachedFaceDetection = null
return
}
editorModel.addFaceBlurs(result.faces, result.renderSize, result.cropPosition)
cachedFaceDetection = result
drawSessionDirty = true
imageEditorState.invalidate()
}
fun setDrawColor(color: Int) {
imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color
}
@@ -55,6 +55,7 @@ import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.MediaSendMetrics
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.edit.document.DocumentPage
import org.signal.mediasend.edit.image.BlurFacesBar
import org.signal.mediasend.edit.image.BrushWidthBar
import org.signal.mediasend.edit.image.BrushWidthPreview
import org.signal.mediasend.edit.image.DrawModeColorBar
@@ -298,6 +299,15 @@ internal fun MediaEditScreen(
DrawModeColorBar(imageEditorController = controller)
}
}
if (controller.isUserBlurring) {
MediaEditControl(faded = isDragging) {
BlurFacesBar(
checked = controller.isBlurringFaces,
onCheckedChange = { onEvent(MediaEditScreenEvent.ToggleBlurFaces(it)) }
)
}
}
}
if (isSmallWindowBreakpoint) {
@@ -389,6 +399,8 @@ internal fun MediaEditScreen(
if (state.isSavingMedia) {
MediaEditScreenDialogs.SavingToStorageProgressDialog()
}
MediaEditScreenDialogs.DetectingFacesProgressDialog(visible = imageController?.isDetectingFaces == true)
}
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.R
import kotlin.time.Duration.Companion.milliseconds
object MediaEditScreenDialogs {
@@ -121,6 +122,19 @@ object MediaEditScreenDialogs {
fun SavingToStorageProgressDialog() {
Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.MediaSendDialogs__saving_media))
}
/**
* Covers face detection, which blocks the editor for as long as it runs. Detection on a small image can finish in a
* frame or two, so the spinner waits before showing itself rather than flashing.
*/
@Composable
fun DetectingFacesProgressDialog(visible: Boolean) {
Dialogs.IndeterminateProgressDialog(
visible = visible,
delayDuration = 200.milliseconds,
minimumDisplayDuration = 400.milliseconds
)
}
}
@Preview
@@ -22,6 +22,7 @@ sealed interface MediaEditScreenEvent {
data object ToggleMediaQuality : MediaEditScreenEvent
data object ToggleViewOnce : MediaEditScreenEvent
data class BrushWidthChanged(val tool: BrushTool, val fraction: Float) : MediaEditScreenEvent
data class ToggleBlurFaces(val enabled: Boolean) : MediaEditScreenEvent
data object SaveMedia : MediaEditScreenEvent
data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent
data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediasend.R
/**
* Takes the place of the color bar while the user is blurring, offering to mask every face in the image for them.
*/
@Composable
internal fun BlurFacesBar(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.widthIn(max = 256.dp)
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface5, shape = CircleShape)
.padding(start = 24.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
) {
Text(text = stringResource(R.string.BlurFacesBar__blur_faces))
Spacer(modifier = Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
@DayNightPreviews
@Composable
private fun BlurFacesBarPreview() {
Previews.Preview {
BlurFacesBar(
checked = true,
onCheckedChange = {}
)
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import android.content.Context
import android.graphics.Matrix
import android.graphics.Point
import android.graphics.RectF
import androidx.annotation.WorkerThread
import org.signal.imageeditor.core.AndroidFaceDetector
import org.signal.imageeditor.core.RendererContext
import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.UriGlideRenderer
/** Widest render we hand to the detector. Faces are found just as well in a smaller image, and far faster. */
private const val MAX_DETECTION_WIDTH = 1000
/**
* The faces found in an image, along with the render they were found in.
*/
internal class FaceDetectionResult(
val faces: List<RectF>,
val renderSize: Point,
val cropPosition: Matrix
) {
private val cropPositionValues = FloatArray(9).also { cropPosition.getValues(it) }
/**
* Whether these results still describe [model]. A crop or rotation made since detection moves every face, so the
* image has to be found again rather than masked from stale bounds.
*/
fun matches(model: EditorModel): Boolean {
val currentValues = FloatArray(9).also { model.inverseCropPosition.getValues(it) }
return cropPositionValues.contentEquals(currentValues)
}
}
/**
* Renders [model] and finds the faces in the result.
*
* The main image's children are left out of that render, so edits already made -- previous masks included -- neither
* hide a face from the detector nor get mistaken for one.
*/
@WorkerThread
internal fun detectFaces(
context: Context,
model: EditorModel,
typefaceProvider: RendererContext.TypefaceProvider
): FaceDetectionResult {
val cropPosition = model.inverseCropPosition
val mainImage = model.mainImage
// Nothing to detect in an image that has not finished loading: the render would only capture its placeholder.
if (mainImage == null || (mainImage.renderer as? UriGlideRenderer)?.bitmap == null) {
return FaceDetectionResult(emptyList(), Point(0, 0), cropPosition)
}
mainImage.flags.setChildrenVisible(false)
val render = try {
model.render(context, model.getOutputSizeMaxWidth(MAX_DETECTION_WIDTH), typefaceProvider)
} finally {
mainImage.flags.reset()
}
return try {
FaceDetectionResult(
faces = AndroidFaceDetector().detect(render).map { it.bounds },
renderSize = Point(render.width, render.height),
cropPosition = cropPosition
)
} finally {
render.recycle()
}
}
@@ -74,7 +74,7 @@ internal class ImageEditorState(
revision++
}
private val defaultTypefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate ->
val typefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate ->
Typeface.DEFAULT
}
@@ -126,7 +126,7 @@ internal class ImageEditorState(
fun getOrCreateRendererContext(context: Context, canvas: Canvas): RendererContext {
val current = rendererContext
if (current != null && current.canvas === canvas) return current
return RendererContext(context, canvas, rendererReady, rendererInvalidate, defaultTypefaceProvider).also {
return RendererContext(context, canvas, rendererReady, rendererInvalidate, typefaceProvider).also {
rendererContext = it
}
}
@@ -67,6 +67,9 @@
<!-- Content description for button to enable brush mode -->
<string name="DrawModeColorBar__brush">Brush</string>
<!-- Label for the toggle that blurs every face in the image being edited -->
<string name="BlurFacesBar__blur_faces">Blur faces</string>
<!-- Content description for the button that cycles the text being edited between styles -->
<string name="TextModeColorBar__toggle_between_text_styles">Toggle between text styles</string>
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.scribbles;
package org.signal.imageeditor.core;
import android.graphics.Bitmap;
import android.graphics.Canvas;
@@ -17,7 +17,7 @@ import java.util.stream.Stream;
/**
* Detects faces with the built in Android face detection.
*/
final class AndroidFaceDetector implements FaceDetector {
public final class AndroidFaceDetector implements FaceDetector {
private static final String TAG = Log.tag(AndroidFaceDetector.class);
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.scribbles;
package org.signal.imageeditor.core;
import android.graphics.Bitmap;
import android.graphics.RectF;
@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
import java.util.List;
interface FaceDetector {
public interface FaceDetector {
List<Face> detect(@NonNull Bitmap bitmap);
interface Face {
@@ -25,6 +25,7 @@ import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -916,6 +917,48 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
updateUndoRedoAvailableState(undoRedoStacks);
}
/**
* Masks each of {@code faces} with a blur, as a single undoable edit.
*
* @param faces Bounds of each face, in the coordinate space of a render of this model at {@code renderSize}.
* @param renderSize Size of the render the faces were detected in.
* @param position The inverse crop position at the time of that render, which is what keeps the masks on the faces
* when the image is cropped or rotated.
*/
public void addFaceBlurs(@NonNull List<RectF> faces, @NonNull Point renderSize, @NonNull Matrix position) {
if (faces.isEmpty()) {
return;
}
pushUndoPoint();
Matrix renderProjection = new Matrix();
renderProjection.setRectToRect(new RectF(0, 0, renderSize.x, renderSize.y), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.FILL);
Matrix facePosition = new Matrix(position);
facePosition.preConcat(renderProjection);
Matrix faceMatrix = new Matrix();
for (RectF face : faces) {
EditorElement element = new EditorElement(new FaceBlurRenderer(), Z_MASK);
Matrix localMatrix = element.getLocalMatrix();
faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face, Matrix.ScaleToFit.FILL);
localMatrix.set(facePosition);
localMatrix.preConcat(faceMatrix);
element.getFlags().setEditable(false)
.setSelectable(false)
.persist();
addElementWithoutPushUndo(element);
}
invalidate.run();
}
public void clearFaceRenderers() {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage != null) {