mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add blur faces to media send v3.
This commit is contained in:
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user