Skip to content

Commit ee52c47

Browse files
authored
Blur: Make bounding box snap to nearest MCU properly show what region gets blurred (#6943)
* blur bounding box snap to nearest MCU properly show what region gets blurred * Make bounding box not grow when moved, only snap its position to nearest MCU borderline * Refetch image properties on applying blur, crop * Add strings for toasts
1 parent 1c5f915 commit ee52c47

3 files changed

Lines changed: 123 additions & 16 deletions

File tree

app/src/main/java/fr/free/nrw/commons/edit/BlurOverlayView.kt

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ import android.view.ScaleGestureDetector
1414
import android.view.View
1515
import android.widget.ImageView
1616
import fr.free.nrw.commons.ajpegtran.blur.BlurRegion
17+
import kotlin.math.ceil
18+
import kotlin.math.floor
1719
import kotlin.math.max
1820
import kotlin.math.min
21+
import kotlin.math.round
1922
import androidx.core.graphics.withMatrix
23+
import fr.free.nrw.commons.ajpegtran.Properties
2024

2125
/**
2226
* Custom overlay view to allow users to draw and select multiple rectangular
@@ -61,13 +65,15 @@ class BlurOverlayView @JvmOverloads constructor(
6165
private val edgeHandleRadiusDp = 2f
6266
private val cornerTouchSlopDp = 24f
6367
private val edgeTouchSlopDp = 20f
64-
private val minRegionSizeDp = 20f
68+
private val borderStrokeWidth = 1.5f
69+
private val minRegionSizeDp = 10f
6570
private lateinit var handlePaint: Paint
6671
private lateinit var activeHandlePaint: Paint
6772
private lateinit var handleBorderPaint: Paint
6873
private var moveRegionIndex = -1
6974
private var lastTouchX = 0f
7075
private var lastTouchY = 0f
76+
private var imageProperties: Properties? = null
7177

7278
private enum class Handle {
7379
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT,
@@ -96,7 +102,7 @@ class BlurOverlayView @JvmOverloads constructor(
96102
borderPaint = Paint().apply {
97103
color = Color.CYAN
98104
style = Paint.Style.STROKE
99-
strokeWidth = 4.0f
105+
strokeWidth = borderStrokeWidth
100106
isAntiAlias = true
101107
}
102108

@@ -460,8 +466,10 @@ class BlurOverlayView @JvmOverloads constructor(
460466

461467
MotionEvent.ACTION_UP -> {
462468
// Rectangle movement.
463-
if (moveRegionIndex != -1)
469+
if (moveRegionIndex != -1) {
470+
snapRectToMCU(regions[moveRegionIndex])
464471
moveRegionIndex = -1
472+
}
465473
// Delete marker tap.
466474
if (deleteBoxIndex != -1) {
467475
if (deleteBoxIndex < regions.size) {
@@ -474,6 +482,7 @@ class BlurOverlayView @JvmOverloads constructor(
474482

475483
// End resize.
476484
if (resizeRegionIndex != -1) {
485+
snapRectToMCU(regions[resizeRegionIndex])
477486
parent?.requestDisallowInterceptTouchEvent(false)
478487
resizeRegionIndex = -1
479488
resizeHandle = null
@@ -484,6 +493,7 @@ class BlurOverlayView @JvmOverloads constructor(
484493
// End drawing.
485494
currentActiveBox?.let { activeBox ->
486495
if (activeBox.width() > 15 && activeBox.height() > 15) {
496+
snapRectToMCU(activeBox)
487497
regions.add(activeBox)
488498
}
489499
currentActiveBox = null
@@ -617,6 +627,73 @@ class BlurOverlayView @JvmOverloads constructor(
617627
}
618628
}
619629

630+
/**
631+
* Initializes the [imageProperties] with current image properties.
632+
* */
633+
fun setImageProperties(properties: Properties) {
634+
imageProperties = properties
635+
}
636+
637+
/**
638+
* Snaps [rect] to MCU boundaries in drawable-pixel space.
639+
* If the rect's dimensions are already MCU-aligned, snaps position to the nearest MCU grid line,
640+
* While preserving size.
641+
* Otherwise, expands outward to cover full MCU blocks.
642+
*/
643+
private fun snapRectToMCU(rect: RectF) {
644+
645+
// Pre-check.
646+
val props = imageProperties ?: return
647+
val drawable = imageView?.drawable ?: return
648+
if (props.MCU_Width <= 0 || props.MCU_Height <= 0) return
649+
650+
// MCU size in drawable-pixel space.
651+
val mcuW = props.MCU_Width.toFloat() * drawable.intrinsicWidth / props.width
652+
val mcuH = props.MCU_Height.toFloat() * drawable.intrinsicHeight / props.height
653+
val maxW = drawable.intrinsicWidth.toFloat()
654+
val maxH = drawable.intrinsicHeight.toFloat()
655+
656+
val widthRemainder = rect.width() % mcuW
657+
val widthAligned = widthRemainder < 0.01f || widthRemainder > (mcuW - 0.01f)
658+
val heightRemainder = rect.height() % mcuH
659+
val heightAligned = heightRemainder < 0.01f || heightRemainder > (mcuH - 0.01f)
660+
661+
// Already MCU-sized, Snap to nearest border position only, preserve size.
662+
if (widthAligned && heightAligned) {
663+
val w = rect.width()
664+
val h = rect.height()
665+
rect.left = round(rect.left / mcuW) * mcuW
666+
rect.top = round(rect.top / mcuH) * mcuH
667+
rect.right = rect.left + w
668+
rect.bottom = rect.top + h
669+
670+
// Shift back if pushed past total image size.
671+
if (rect.right > maxW) {
672+
rect.offset(maxW - rect.right, 0f)
673+
}
674+
if (rect.bottom > maxH) {
675+
rect.offset(0f, maxH - rect.bottom)
676+
}
677+
if (rect.left < 0f) {
678+
rect.offset(-rect.left, 0f)
679+
}
680+
if (rect.top < 0f) {
681+
rect.offset(0f, -rect.top)
682+
}
683+
} else {
684+
// Not aligned, Expand outward to full MCU blocks.
685+
rect.left = floor(rect.left.toDouble() / mcuW).toFloat() * mcuW
686+
rect.top = floor(rect.top.toDouble() / mcuH).toFloat() * mcuH
687+
rect.right = ceil(rect.right.toDouble() / mcuW).toFloat() * mcuW
688+
rect.bottom = ceil(rect.bottom.toDouble() / mcuH).toFloat() * mcuH
689+
690+
// Clamp to drawable bounds.
691+
rect.left = max(0f, rect.left)
692+
rect.top = max(0f, rect.top)
693+
rect.right = min(maxW, rect.right)
694+
rect.bottom = min(maxH, rect.bottom)
695+
}
696+
}
620697

621698
fun resetZoom() {
622699
val iv = imageView ?: return

app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import androidx.core.net.toUri
1818
import androidx.core.view.WindowInsetsCompat
1919
import androidx.exifinterface.media.ExifInterface
2020
import androidx.lifecycle.ViewModelProvider
21+
import fr.free.nrw.commons.R
22+
import fr.free.nrw.commons.ajpegtran.Properties
2123
import fr.free.nrw.commons.databinding.ActivityEditBinding
2224
import fr.free.nrw.commons.theme.BaseActivity
2325
import fr.free.nrw.commons.utils.applyEdgeToEdgeBottomInsets
@@ -49,6 +51,7 @@ class EditActivity : BaseActivity() {
4951
private var originalBitmapWidth = 0
5052
private var originalBitmapHeight = 0
5153
private var maxAvailableHeight = 0f
54+
private var properties: Properties? = null
5255

5356
override fun onCreate(savedInstanceState: Bundle?) {
5457
super.onCreate(savedInstanceState)
@@ -59,7 +62,7 @@ class EditActivity : BaseActivity() {
5962
imageUri = intent.getStringExtra("image") ?: ""
6063
vm = ViewModelProvider(this)[EditViewModel::class.java]
6164
vm.initJpegtran(applicationContext, imageUri)
62-
65+
fetchAndUpdateImageProperties()
6366
val sourceExif = try {
6467
ExifInterface(imageUri)
6568
} catch (e: Exception) {
@@ -141,6 +144,23 @@ class EditActivity : BaseActivity() {
141144
}
142145
}
143146

147+
/**
148+
* Gets the current image properties and update those properties in [BlurOverlayView].
149+
* */
150+
private fun fetchAndUpdateImageProperties() {
151+
try {
152+
properties = vm.getProperties(File(imageUri).toUri())
153+
binding.blurOverlay.setImageProperties(properties!!)
154+
} catch (e: Exception) {
155+
Timber.e(e, "Error getting image properties: ${e.localizedMessage}")
156+
Toast.makeText(
157+
this@EditActivity,
158+
getString(R.string.error_getting_image_properties),
159+
Toast.LENGTH_LONG
160+
).show()
161+
}
162+
}
163+
144164
/**
145165
* Toggles between the main toolbar (Rotate/Crop/Save) and the edit toolbar (Apply/Cancel).
146166
*
@@ -270,8 +290,8 @@ class EditActivity : BaseActivity() {
270290

271291
if (regions.isEmpty()) {
272292
Toast.makeText(
273-
this,
274-
"Please draw at least one rectangle on the photo",
293+
this@EditActivity,
294+
getString(R.string.error_blur_no_rectangle),
275295
Toast.LENGTH_SHORT
276296
).show()
277297
return
@@ -289,11 +309,12 @@ class EditActivity : BaseActivity() {
289309
applyPendingRotation()
290310
// Reload the image displaying the applied blur.
291311
updateImagePreview()
312+
fetchAndUpdateImageProperties()
292313
} catch (e: Exception) {
293-
Timber.e(e, "Failed to apply blur")
314+
Timber.e(e, "Failed to apply blur ${e.localizedMessage}")
294315
Toast.makeText(
295316
this@EditActivity,
296-
"Error applying blur: ${e.localizedMessage}",
317+
getString(R.string.error_applying_blur),
297318
Toast.LENGTH_LONG
298319
).show()
299320
}
@@ -308,9 +329,8 @@ class EditActivity : BaseActivity() {
308329
// Apply pending rotation if any.
309330
applyPendingRotation()
310331

311-
val properties = vm.getProperties(File(imageUri).toUri())
312-
val actualWidth = properties.width
313-
val actualHeight = properties.height
332+
val actualWidth = properties!!.width
333+
val actualHeight = properties!!.height
314334
val cropRect = binding.cropOverlay.getCropRect()
315335
val cropCoords = convertViewCropToImageCrop(cropRect, actualWidth, actualHeight)
316336

@@ -325,12 +345,13 @@ class EditActivity : BaseActivity() {
325345
imageUri = croppedFile.absolutePath
326346
// Update the image preview.
327347
updateImagePreview()
348+
fetchAndUpdateImageProperties()
328349
}
329350
} catch (e: Exception) {
330-
Timber.e(e, "applyCrop: Failed to apply crop")
351+
Timber.e(e, "applyCrop: Failed to apply crop ${e.localizedMessage}")
331352
Toast.makeText(
332-
this,
333-
"Failed to apply crop: ${e.localizedMessage}",
353+
this@EditActivity,
354+
getString(R.string.failed_to_apply_crop),
334355
Toast.LENGTH_LONG
335356
).show()
336357
}
@@ -430,10 +451,13 @@ class EditActivity : BaseActivity() {
430451
setResult(RESULT_OK, resultIntent)
431452
finish()
432453
} catch (e: Exception) {
433-
Timber.e(e, "saveEditedImage: Exception occurred during save process")
454+
Timber.e(
455+
e,
456+
"saveEditedImage: Exception occurred during save process ${e.localizedMessage}"
457+
)
434458
Toast.makeText(
435459
this@EditActivity,
436-
"Failed to save image: ${e.localizedMessage}",
460+
getString(R.string.failed_to_save_image),
437461
Toast.LENGTH_LONG
438462
).show()
439463
}

app/src/main/res/values/strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,4 +906,10 @@ Upload your first media by tapping on the add button.</string>
906906
<string name="license_cc0_long_description">This license allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, with no conditions.</string>
907907
<string name="license_cc_by_long_description">This license allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, even for commercial purposes, as long as attribution is given to the creator.</string>
908908
<string name="license_cc_by_sa_long_description">This license allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, even for commercial purposes, as long as attribution is given to the creator and the new work is licensed under identical terms.</string>
909+
910+
<string name="error_getting_image_properties">Error getting image properties:</string>
911+
<string name="error_blur_no_rectangle">Please draw at least one rectangle on the photo</string>
912+
<string name="error_applying_blur">Error applying blur</string>
913+
<string name="failed_to_apply_crop">Failed to apply crop</string>
914+
<string name="failed_to_save_image">Failed to save image</string>
909915
</resources>

0 commit comments

Comments
 (0)