Image Editor - Further crop improvements.

* Thumb accuracy improved.
* When out of bounds from drag, try to fix by adjusting translation.
* Update undo state when listener changes.
This commit is contained in:
Alan Evans 2019-05-20 12:02:40 -03:00 committed by GitHub
parent 5a4c2fc7b0
commit 7f0c998b24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 235 additions and 32 deletions

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.imageeditor;
import android.graphics.Matrix; import android.graphics.Matrix;
import android.graphics.PointF; import android.graphics.PointF;
import android.graphics.RectF;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.imageeditor.model.EditorElement; import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
@ -37,22 +38,32 @@ class ThumbDragEditSession extends ElementEditSession {
float x = controlPoint.opposite().getX(); float x = controlPoint.opposite().getX();
float y = controlPoint.opposite().getY(); float y = controlPoint.opposite().getY();
editorMatrix.postTranslate(-x, -y); float dx = endPointElement[0].x - startPointElement[0].x;
float dy = endPointElement[0].y - startPointElement[0].y;
float xEnd = controlPoint.getX() + dx;
float yEnd = controlPoint.getY() + dy;
boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter(); boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter();
float defaultScale = aspectLocked ? 2 : 1; float defaultScale = aspectLocked ? 2 : 1;
float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (endPointElement[0].x - x) / (startPointElement[0].x - x); float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (xEnd - x) / (controlPoint.getX() - x);
float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (endPointElement[0].y - y) / (startPointElement[0].y - y); float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (yEnd - y) / (controlPoint.getY() - y);
scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite());
}
private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) {
float x = around.getX();
float y = around.getY();
editorMatrix.postTranslate(-x, -y);
if (aspectLocked) { if (aspectLocked) {
float minScale = Math.min(scaleX, scaleY); float minScale = Math.min(scaleX, scaleY);
editorMatrix.postScale(minScale, minScale); editorMatrix.postScale(minScale, minScale);
} else { } else {
editorMatrix.postScale(scaleX, scaleY); editorMatrix.postScale(scaleX, scaleY);
} }
editorMatrix.postTranslate(x, y); editorMatrix.postTranslate(x, y);
} }

View File

@ -0,0 +1,113 @@
package org.thoughtcrime.securesms.imageeditor.model;
import android.graphics.Matrix;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
final class Bisect {
static final float ACCURACY = 0.001f;
private static final int MAX_ITERATIONS = 16;
interface Predicate {
boolean test();
}
interface ModifyElement {
void applyFactor(@NonNull Matrix matrix, float factor);
}
/**
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
* If it returns true, it will animate the element to the closest true value found to that boundary.
*
* @param element The element to modify.
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
* @param atMost A value believed to be in bounds.
* @param predicate The out of bounds predicate.
* @param modifyElement Apply the latest value to the element local matrix.
* @param invalidate For animation if finds a result.
* @return true iff finds a result.
*/
static boolean bisectToTest(@NonNull EditorElement element,
float outOfBoundsValue,
float atMost,
@NonNull Predicate predicate,
@NonNull ModifyElement modifyElement,
@NonNull Runnable invalidate)
{
Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement);
if (closestSuccesful != null) {
element.animateLocalTo(closestSuccesful, invalidate);
return true;
} else {
return false;
}
}
/**
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
* Returns new local matrix for the element if a solution is found.
*
* @param element The element to modify.
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
* @param atMost A value believed to be in bounds.
* @param predicate The out of bounds predicate.
* @param modifyElement Apply the latest value to the element local matrix.
* @return matrix to replace local matrix iff finds a result, null otherwise.
*/
static @Nullable Matrix bisectToTest(@NonNull EditorElement element,
float outOfBoundsValue,
float atMost,
@NonNull Predicate predicate,
@NonNull ModifyElement modifyElement)
{
Matrix elementMatrix = element.getLocalMatrix();
Matrix original = new Matrix(elementMatrix);
Matrix closestSuccessful = new Matrix();
boolean haveResult = false;
int attempt = 0;
float successValue = 0;
float inBoundsValue = atMost;
float nextValueToTry = inBoundsValue;
do {
attempt++;
modifyElement.applyFactor(elementMatrix, nextValueToTry);
try {
if (predicate.test()) {
inBoundsValue = nextValueToTry;
// if first success or closer to out of bounds than the current closest
if (!haveResult || Math.abs(nextValueToTry) < Math.abs(successValue)) {
haveResult = true;
successValue = nextValueToTry;
closestSuccessful.set(elementMatrix);
}
} else {
if (attempt == 1) {
// failure on first attempt means inBoundsValue is actually out of bounds and so no solution
return null;
}
outOfBoundsValue = nextValueToTry;
}
} finally {
// reset
elementMatrix.set(original);
}
nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f;
} while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY);
if (haveResult) {
return closestSuccessful;
}
return null;
}
}

View File

@ -76,6 +76,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) { public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) {
this.undoRedoStackListener = undoRedoStackListener; this.undoRedoStackListener = undoRedoStackListener;
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
} }
/** /**
@ -265,6 +267,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
if (!tryToScaleToFit(cropEditorElement, 0.9f)) { if (!tryToScaleToFit(cropEditorElement, 0.9f)) {
tryToScaleToFit(mainImage, 2f); tryToScaleToFit(mainImage, 2f);
} }
} else {
tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix());
} }
if (!currentCropIsAcceptable()) { if (!currentCropIsAcceptable()) {
@ -287,33 +291,104 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
* @return true if successfully scaled the element. false if the element was left unchanged. * @return true if successfully scaled the element. false if the element was left unchanged.
*/ */
private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) { private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) {
Matrix elementMatrix = element.getLocalMatrix(); return Bisect.bisectToTest(element,
Matrix original = new Matrix(elementMatrix); 1,
Matrix lastSuccessful = new Matrix(); scaleAtMost,
boolean success = false; this::cropIsWithinMainImageBounds,
float unsuccessfulScale = 1; (matrix, scale) -> matrix.preScale(scale, scale),
int attempt = 0; invalidate);
}
do { /**
float tryScale = (scaleAtMost + unsuccessfulScale) / 2f; * Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
elementMatrix.set(original); * If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y.
elementMatrix.preScale(tryScale, tryScale); *
* @param element The element to be translated. If successful, it will be animated to the correct position.
* @param translateXAtMost The maximum translation to apply in the x axis.
* @param translateYAtMost The maximum translation to apply in the y axis.
* @return a matrix if successfully translated the element. null if the element unable to be translated to fit.
*/
private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) {
return Bisect.bisectToTest(element,
0,
1,
this::cropIsWithinMainImageBounds,
(matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost));
}
if (cropIsWithinMainImageBounds(editorElementHierarchy)) { /**
scaleAtMost = tryScale; * Tries to fix an element that is out of bounds by adjusting it's translation.
success = true; *
lastSuccessful.set(elementMatrix); * @param element Element to move.
* @param lastKnownGoodPosition Last known good position of element.
* @return true iff fixed the element.
*/
private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) {
final Matrix elementMatrix = element.getLocalMatrix();
final Matrix original = new Matrix(elementMatrix);
final float[] current = new float[9];
final float[] lastGood = new float[9];
Matrix matrix;
elementMatrix.getValues(current);
lastKnownGoodPosition.getValues(lastGood);
final float xTranslate = current[2] - lastGood[2];
final float yTranslate = current[5] - lastGood[5];
if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) {
return false;
}
float pass1X;
float pass1Y;
float pass2X;
float pass2Y;
// try the fix by the smallest user translation first
if (Math.abs(xTranslate) < Math.abs(yTranslate)) {
// try to bisect along x
pass1X = -xTranslate;
pass1Y = 0;
// then y
pass2X = 0;
pass2Y = -yTranslate;
} else { } else {
unsuccessfulScale = tryScale; // try to bisect along y
} pass1X = 0;
attempt++; pass1Y = -yTranslate;
} while (attempt < 16 && Math.abs(scaleAtMost - unsuccessfulScale) > 0.001f);
elementMatrix.set(original); // then x
if (success) { pass2X = -xTranslate;
element.animateLocalTo(lastSuccessful, invalidate); pass2Y = 0;
} }
return success;
matrix = tryToTranslateToFit(element, pass1X, pass1Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
// apply pass 1 fully
elementMatrix.postTranslate(pass1X, pass1Y);
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
elementMatrix.set(original);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
return false;
} }
public void dragDropRelease() { public void dragDropRelease() {
@ -321,7 +396,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
} }
/** /**
* Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless it's original size was less than that) * Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that)
* and all points must be within the bounds. * and all points must be within the bounds.
*/ */
private boolean currentCropIsAcceptable() { private boolean currentCropIsAcceptable() {
@ -338,7 +413,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
return compareRatios(outputSize, thinnestRatio) >= 0 && return compareRatios(outputSize, thinnestRatio) >= 0 &&
outputPixelCount >= minimumPixelCount && outputPixelCount >= minimumPixelCount &&
cropIsWithinMainImageBounds(editorElementHierarchy); cropIsWithinMainImageBounds();
} }
/** /**
@ -359,8 +434,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
/** /**
* @return true if and only if the current crop rect is fully in the bounds. * @return true if and only if the current crop rect is fully in the bounds.
*/ */
private static boolean cropIsWithinMainImageBounds(@NonNull EditorElementHierarchy hierarchy) { private boolean cropIsWithinMainImageBounds() {
return Bounds.boundsRemainInBounds(hierarchy.imageMatrixRelativeToCrop()); return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop());
} }
/** /**

View File

@ -27,4 +27,8 @@ final class InBoundsMemory {
} }
cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate); cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate);
} }
Matrix getLastKnownGoodMainImageMatrix() {
return new Matrix(lastGoodMainImage);
}
} }