絵文字デコード先のビットマップの大きさを入力データのアスペクト比に合わせる

This commit is contained in:
tateisu 2023-05-05 12:12:49 +09:00
parent 1ccd05e08a
commit a0243ee8b9
6 changed files with 164 additions and 76 deletions

View File

@ -6,9 +6,11 @@ import jp.juggler.util.data.encodeUTF8
import java.io.InputStream
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
import kotlin.math.sqrt
class ApngFrames private constructor(
private val pixelSizeMax: Int = 0,
private val pixelSizeMax: Float = 0f,
private val debug: Boolean = false,
) : ApngDecoderCallback, MyGifDecoderCallback {
@ -29,43 +31,64 @@ class ApngFrames private constructor(
color = 0
}
// return w,h
fun scaleEmojiSize(
srcW: Float,
srcH: Float,
maxSize: Float,
): Pair<Float, Float> = when {
// 入力サイズの情報がない
srcW <= 0f || srcH <= 0f -> Pair(maxSize, maxSize)
else -> {
val sqMax = maxSize * maxSize
val sqOriginal = srcW * srcH
val aspect = srcW / srcH
when {
// 既に十分小さい
sqOriginal <= sqMax -> Pair(srcW, srcH)
// アスペクト比に応じたスケーリング
else -> Pair(
sqrt(sqMax * aspect),
sqrt(sqMax / aspect),
)
}
}
}
private fun createBlankBitmap(w: Int, h: Int) =
Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
private fun scale(max: Int, num: Int, den: Int) =
(max.toFloat() * num.toFloat() / den.toFloat() + 0.5f).toInt()
private fun scaleBitmap(
sizeMax: Int,
sizeMax: Float,
src: Bitmap,
recycleSrc: Boolean = true, // true: ownership of "src" will be moved or recycled.
): Bitmap {
if (sizeMax <= 0) {
return when {
recycleSrc -> src
else -> src.copy(Bitmap.Config.ARGB_8888, false)
}
}
val wSrc = src.width
val hSrc = src.height
if (sizeMax <= 0 || (wSrc <= sizeMax && hSrc <= sizeMax)) {
return if (recycleSrc) {
src
} else {
src.copy(Bitmap.Config.ARGB_8888, false)
val (wDst, hDst) = scaleEmojiSize(
wSrc.toFloat(),
hSrc.toFloat(),
sizeMax,
)
val wDstInt = max(1, round(wDst).toInt())
val hDstInt = max(1, round(hDst).toInt())
if (wSrc <= wDstInt && hSrc <= hDstInt ) {
return when {
recycleSrc -> src
else -> src.copy(Bitmap.Config.ARGB_8888, false)
}
}
val wDst: Int
val hDst: Int
if (wSrc >= hSrc) {
wDst = sizeMax
hDst = max(1, scale(sizeMax, hSrc, wSrc))
} else {
hDst = sizeMax
wDst = max(1, scale(sizeMax, wSrc, hSrc))
}
//Log.v(TAG,"scaleBitmap: $wSrc,$hSrc => $wDst,$hDst")
val b2 = createBlankBitmap(wDst, hDst)
val b2 = createBlankBitmap(wDstInt, hDstInt)
val canvas = Canvas(b2)
val rectSrc = Rect(0, 0, wSrc, hSrc)
val rectDst = Rect(0, 0, wDst, hDst)
val rectDst = Rect(0, 0, wDstInt, hDstInt)
canvas.drawBitmap(src, rectSrc, rectDst, sPaintDontBlend)
if (recycleSrc) src.recycle()
@ -83,12 +106,12 @@ class ApngFrames private constructor(
Bitmap.Config.ARGB_8888
)
private fun toAndroidBitmap(src: ApngBitmap, sizeMax: Int) =
private fun toAndroidBitmap(src: ApngBitmap, sizeMax: Float) =
scaleBitmap(sizeMax, toAndroidBitmap(src))
private fun parseApng(
inStream: InputStream,
pixelSizeMax: Int,
pixelSizeMax: Float,
debug: Boolean = false,
): ApngFrames {
val result = ApngFrames(pixelSizeMax, debug)
@ -105,7 +128,7 @@ class ApngFrames private constructor(
private fun parseWebP(
inStream: InputStream,
pixelSizeMax: Int,
pixelSizeMax: Float,
debug: Boolean = false,
): ApngFrames {
val result = ApngFrames(pixelSizeMax, debug)
@ -122,7 +145,7 @@ class ApngFrames private constructor(
private fun parseGif(
inStream: InputStream,
pixelSizeMax: Int,
pixelSizeMax: Float,
debug: Boolean = false,
): ApngFrames {
val result = ApngFrames(pixelSizeMax, debug)
@ -166,7 +189,7 @@ class ApngFrames private constructor(
}
fun parse(
pixelSizeMax: Int,
pixelSizeMax: Float,
debug: Boolean = false,
opener: () -> InputStream?,
): ApngFrames? {
@ -284,7 +307,7 @@ class ApngFrames private constructor(
val animationControl = this.animationControl
val frames = this.frames
if (animationControl == null || frames == null || frames.isEmpty()) {
if (animationControl == null || frames.isNullOrEmpty()) {
// ここは通らないはず…
result.bitmap = null
result.delay = Long.MAX_VALUE
@ -409,6 +432,7 @@ class ApngFrames private constructor(
frameControl.width,
frameControl.height
)
else -> null
}

View File

@ -0,0 +1,70 @@
package jp.juggler.subwaytooter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import jp.juggler.util.data.*
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
@RunWith(AndroidJUnit4::class)
class TestBitmapSample {
/**
* BitmapFactory.Options.inSampleSize の取り扱いの確認
*/
@Test
fun test() {
val srcSize = 1024
val baSrc = run {
val bitmap = Bitmap.createBitmap(srcSize, srcSize, Bitmap.Config.ARGB_8888)
try {
ByteArrayOutputStream().use { outStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outStream)
outStream.toByteArray()
}
} finally {
bitmap.recycle()
}
}
for (n in 0..32) {
val options = BitmapFactory.Options().apply {
inScaled = false
outWidth = 0
outHeight = 0
inJustDecodeBounds = false
inSampleSize = n
}
val expectedSizeA: Int
val expectedSizeB: Int
when (n) {
// ドキュメントには "If set to a value > 1" とあり、1以下の値はリサイズに影響しない
0, 1 -> {
expectedSizeA = srcSize
expectedSizeB = srcSize
}
// 2以上の場合、ドキュメントには
// "Note: the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2."
// とあるが、実際に試すと端末により width = srcSize/n となることがある。
else -> {
expectedSizeA = srcSize.div(n.takeHighestOneBit())
expectedSizeB = srcSize.div(n)
}
}
val bitmap = BitmapFactory.decodeByteArray(baSrc, 0, baSrc.size, options)!!
try {
when (bitmap.width) {
expectedSizeA -> Unit
expectedSizeB -> Unit
else -> fail("inSampleSize=$n, srcSize=$srcSize, resultWidth=${bitmap.width}")
}
} finally {
bitmap.recycle()
}
}
}
}

View File

@ -72,7 +72,7 @@ class ActText : AppCompatActivity() {
}
}
class SearchHilightSpan(color: Int) : BackgroundColorSpan(color)
class SearchResultSpan(color: Int) : BackgroundColorSpan(color)
private var account: SavedAccount? = null
@ -360,7 +360,7 @@ class ActText : AppCompatActivity() {
private fun searchHighlight(newPos: Int?) {
views.etText.text?.let { e ->
for (span in e.getSpans(0, e.length, SearchHilightSpan::class.java)) {
for (span in e.getSpans(0, e.length, SearchResultSpan::class.java)) {
try {
e.removeSpan(span)
} catch (ignored: Throwable) {
@ -372,7 +372,7 @@ class ActText : AppCompatActivity() {
else -> R.attr.colorButtonBgCw
}
e.setSpan(
SearchHilightSpan(attrColor(attrId)),
SearchResultSpan(attrColor(attrId)),
pos,
pos + searchKeywordLength,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,

View File

@ -9,21 +9,23 @@ import android.os.Handler
import android.os.SystemClock
import com.caverock.androidsvg.SVG
import jp.juggler.apng.ApngFrames
import jp.juggler.apng.ApngFrames.Companion.scaleEmojiSize
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.table.EmojiCacheDbOpenHelper
import jp.juggler.subwaytooter.table.daoImageAspect
import jp.juggler.util.coroutine.EmptyScope
import jp.juggler.util.data.*
import jp.juggler.util.log.*
import jp.juggler.util.data.clip
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.util.*
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
import kotlin.math.max
class CustomEmojiCache(
val context: Context,
@ -248,7 +250,7 @@ class CustomEmojiCache(
ts = elapsedTime
val frames = try {
data?.let { decodeAPNG(it, request.url) }
data?.let { decodeImage(it, request.url) }
} catch (ex: Throwable) {
log.e(ex, "decode failed.")
null
@ -327,13 +329,13 @@ class CustomEmojiCache(
}
}
private fun decodeAPNG(data: ByteArray, url: String): ApngFrames? {
private fun decodeImage(data: ByteArray, url: String): ApngFrames? {
val errors = ArrayList<Throwable>()
val maxSize = PrefS.spEmojiPixels.toInt().clip(16, 1024)
val maxSize = PrefS.spEmojiPixels.toInt().clip(16, 1024).toFloat()
try {
// APNGをデコード AWebPも
// APNG,AWebP,AGIF
val x = ApngFrames.parse(maxSize) { ByteArrayInputStream(data) }
if (x != null) return x
error("ApngFrames.parse returns null.")
@ -354,7 +356,7 @@ class CustomEmojiCache(
// SVGのロードを試みる
try {
val b = decodeSVG(url, data, maxSize.toFloat())
val b = decodeSVG(url, data, maxSize)
if (b != null) return ApngFrames(b)
error("decodeSVG returns null.")
} catch (ex: Throwable) {
@ -373,22 +375,27 @@ class CustomEmojiCache(
private fun decodeBitmap(
data: ByteArray,
@Suppress("SameParameterValue") pixelMax: Int,
maxPixels: Float,
): Bitmap? {
options.inJustDecodeBounds = true
options.inScaled = false
options.outWidth = 0
options.outHeight = 0
BitmapFactory.decodeByteArray(data, 0, data.size, options)
var w = options.outWidth
var h = options.outHeight
if (w <= 0 || h <= 0) error("decodeBitmap: can't decode bounds.")
var srcW = options.outWidth
var srcH = options.outHeight
if (srcW <= 0 || srcH <= 0) error("decodeBitmap: can't decode bounds.")
val (preferW, preferH) = scaleEmojiSize(
srcW.toFloat(),
srcH.toFloat(),
maxPixels
)
var bits = 0
while (w > pixelMax || h > pixelMax) {
while (srcW > preferW || srcW > preferH) {
++bits
w = w shr 1
h = h shr 1
srcW = srcW shr 1
srcH = srcH shr 1
}
options.inJustDecodeBounds = false
options.inSampleSize = 1 shl bits
@ -398,33 +405,20 @@ class CustomEmojiCache(
private fun decodeSVG(
url: String,
data: ByteArray,
@Suppress("SameParameterValue") pixelMax: Float,
maxSize: Float,
): Bitmap? {
try {
val svg = SVG.getFromInputStream(ByteArrayInputStream(data))
// the width in pixels, or -1 if there is no width available.
// the height in pixels, or -1 if there is no height available.
val srcW = svg.documentWidth
val srcH = svg.documentHeight
val aspect = if (srcW <= 0f || srcH <= 0f) {
// widthやheightの情報がない
1f
} else {
srcW / srcH
}
val dstW: Float
val dstH: Float
if (aspect >= 1f) {
dstW = pixelMax
dstH = pixelMax / aspect
} else {
dstH = pixelMax
dstW = pixelMax * aspect
}
val wCeil = ceil(dstW)
val hCeil = ceil(dstH)
val (wDst, hDst) = scaleEmojiSize(
// the width in pixels, or -1 if there is no width available.
svg.documentWidth,
// the height in pixels, or -1 if there is no height available.
svg.documentHeight,
maxSize
)
val wCeil = max(1f, ceil(wDst))
val hCeil = max(1f, ceil(hDst))
// Create a Bitmap to render our SVG to
val b = Bitmap.createBitmap(wCeil.toInt(), hCeil.toInt(), Bitmap.Config.ARGB_8888)
@ -433,10 +427,10 @@ class CustomEmojiCache(
svg.renderToCanvas(
canvas,
if (aspect >= 1f) {
RectF(0f, hCeil - dstH, dstW, dstH) // 後半はw,hを指定する
if (wDst >= hDst) {
RectF(0f, hCeil - hDst, wDst, hDst) // 後半はw,hを指定する
} else {
RectF(wCeil - dstW, 0f, dstW, dstH) // 後半はw,hを指定する
RectF(wCeil - wDst, 0f, wDst, hDst) // 後半はw,hを指定する
}
)
return b

View File

@ -181,7 +181,7 @@ class ActList : AppCompatActivity(), CoroutineScope {
val job = async(AppDispatchers.IO) {
try {
ApngFrames.parse(128) { resources?.openRawResource(resId) }
ApngFrames.parse(128f) { resources?.openRawResource(resId) }
} catch (ex: Throwable) {
ex.printStackTrace()
null

View File

@ -62,7 +62,7 @@ class ActViewer : AsyncActivity() {
apngFrames = withContext(AppDispatchers.IO) {
try {
ApngFrames.parse(
1024,
1024f,
debug = true
) { resources?.openRawResource(resId) }
} catch (ex: Throwable) {