Skip to content

Commit 1eea521

Browse files
committed
Better algorithm for thumbnail quality reduction
1 parent 280b32d commit 1eea521

File tree

1 file changed

+65
-19
lines changed

1 file changed

+65
-19
lines changed

app/src/main/java/us/shandian/giga/postprocessing/ImageUtils.kt

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ package us.shandian.giga.postprocessing
77

88
import android.graphics.Bitmap
99
import java.io.ByteArrayOutputStream
10-
import java.util.Base64
10+
import kotlin.math.max
11+
import kotlin.math.min
12+
import kotlin.math.sqrt
1113
import org.schabi.newpipe.ktx.scale
1214

1315
object ImageUtils {
@@ -31,35 +33,79 @@ object ImageUtils {
3133
)
3234

3335
fun compressToSize(original: Bitmap, maxSizeBytes: Int): CompressedImage? {
36+
// Strategy:
37+
// 1. Compress once and measure binary size; compute base64 size via formula:
38+
// base64Len = 4 * ceil(binaryLen / 3)
39+
// See https://de.wikipedia.org/wiki/Base64#Platzbedarf (en wiki doesn't have formula)
40+
// 2. If too big, try an adaptive quality reduction proportional to the ratio:
41+
// newQuality ≈ quality * (maxSize / measuredBase64Size)
42+
// 3. If quality hits minimum and still too big,
43+
// compute scale factor ≈ sqrt(maxSize / measuredBase64Size)
44+
// to reduce width/height (area scales ~ scale^2).
45+
// Repeat until fits or min dimension reached.
46+
val MIN_DIMENSION = 50
47+
val MIN_QUALITY = 70
3448
var quality = 100
3549
var scale = 1.0f
3650
var width = original.width
3751
var height = original.height
38-
var compressedSize: Int
3952

40-
do {
41-
var bitmap = original.copy(original.config ?: Bitmap.Config.ARGB_8888, false)
42-
if (scale < 1.0f) {
43-
bitmap = bitmap.scale(width = width, height = height)
53+
while (width > MIN_DIMENSION && height > MIN_DIMENSION) { // loop for scaling down
54+
// Prepare bitmap at current dimensions
55+
val bitmap = if (scale < 1.0f) {
56+
original.scale(width = width, height = height)
57+
} else {
58+
// use a copy to ensure compress works on mutable config if needed
59+
original.copy(original.config ?: Bitmap.Config.ARGB_8888, false)
4460
}
45-
do {
61+
62+
while (true) { // loop for iterative quality adjustments for this size
4663
val outputStream = ByteArrayOutputStream()
4764
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
48-
compressedSize = Base64.getEncoder().encodeToString(outputStream.toByteArray()).length
49-
quality -= 5 // Decrease quality by 5% for the next iteration
50-
} while (compressedSize > maxSizeBytes && quality > 70)
51-
if (compressedSize <= maxSizeBytes) {
52-
return CompressedImage(bitmap, quality, width, height)
53-
}
54-
if (scale > 0.5f) {
55-
scale -= 0.1f
56-
} else {
57-
scale *= 0.9f
65+
val binarySize = outputStream.size() // actual compressed bytes
66+
// base64 size formula: 4 * ceil(binarySize / 3)
67+
// is the same as ((binarySize + 2) / 3) * 4 which is more efficient
68+
val base64Size = ((binarySize + 2) / 3) * 4
69+
70+
if (base64Size <= maxSizeBytes) {
71+
return CompressedImage(bitmap, quality, width, height)
72+
}
73+
74+
// Try to compute an adaptive new quality based on ratio assuming linear scaling
75+
val ratio = maxSizeBytes.toDouble() / base64Size.toDouble()
76+
val computedQuality = max(
77+
MIN_QUALITY,
78+
min(quality - 5, (quality * ratio).toInt())
79+
)
80+
81+
if (computedQuality >= quality) {
82+
// If quality cannot be effectively reduced further, break to scale image down
83+
break
84+
}
85+
quality = computedQuality
5886
}
87+
88+
// If quality reductions were insufficient, reduce scale using sqrt of ratio
89+
// Re-compress once to get a size for scaling decision (conservative)
90+
val probeStream = ByteArrayOutputStream()
91+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, probeStream)
92+
val probeBinary = probeStream.size()
93+
val probeBase64 = ((probeBinary + 2) / 3) * 4
94+
95+
// avoid division by zero, should not happen but just in case
96+
if (probeBase64 == 0) return null
97+
98+
// Desired overall scale factor: sqrt(maxSize / observedSize)
99+
val desiredRatio = maxSizeBytes.toDouble() / probeBase64.toDouble()
100+
val scaleFactor = sqrt(desiredRatio).coerceAtMost(0.95)
101+
102+
// Update scale and dimensions, reset quality to allow better quality at smaller size
103+
scale *= scaleFactor.toFloat()
59104
width = (original.width * scale).toInt()
60105
height = (original.height * scale).toInt()
61-
quality = 100 // Reset quality for the next size reduction
62-
} while (width > 50 && height > 50) // Prevent too much downscaling
106+
quality = 100
107+
}
108+
63109
return null
64110
}
65111
}

0 commit comments

Comments
 (0)