From 895087f6043f20e3b1830e2de1dbb6bb038c32e4 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 5 Nov 2020 15:00:27 +0900 Subject: [PATCH] tiling improvements (WIP) --- .../aves/channel/calls/ImageFileHandler.kt | 7 +- .../aves/channel/calls/RegionFetcher.kt | 67 ++++++++++++------- lib/utils/math_utils.dart | 8 ++- lib/widgets/fullscreen/image_view.dart | 4 ++ lib/widgets/fullscreen/tiled_view.dart | 43 ++++++------ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index c172f8abc..5cd287c6a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -20,6 +20,8 @@ import kotlin.math.roundToInt class ImageFileHandler(private val activity: Activity) : MethodCallHandler { private val density = activity.resources.displayMetrics.density + private val regionFetcher = RegionFetcher(activity) + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) } @@ -90,14 +92,13 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - RegionFetcher( - activity, + regionFetcher.fetch( uri, mimeType, sampleSize, Rect(x, y, x + width, y + height), result, - ).fetch() + ) } private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt index a63c65666..5086513bf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt @@ -1,50 +1,67 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.net.Uri +import android.util.Log import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream class RegionFetcher internal constructor( - private val activity: Activity, - private val uri: Uri, - private val mimeType: String, - private val sampleSize: Int, - private val rect: Rect, - private val result: MethodChannel.Result, + private val context: Context, ) { + private var lastDecoderRef: Pair? = null - fun fetch() { - val options = BitmapFactory.Options().apply { inSampleSize = sampleSize } + fun fetch( + uri: Uri, + mimeType: String, + sampleSize: Int, + rect: Rect, + result: MethodChannel.Result, + ) { + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + } + + var currentDecoderRef = lastDecoderRef + if (currentDecoderRef != null && currentDecoderRef.first != uri) { + currentDecoderRef.second.recycle() + currentDecoderRef = null + } try { - StorageUtils.openInputStream(activity, uri).use { input -> - val decoder = BitmapRegionDecoder.newInstance(input, false) - val data = decoder.decodeRegion(rect, options)?.let { - val stream = ByteArrayOutputStream() - // we compress the bitmap because Dart Image.memory cannot decode the raw bytes - // Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency - if (MimeTypes.canHaveAlpha(mimeType)) { - it.compress(Bitmap.CompressFormat.PNG, 0, stream) - } else { - it.compress(Bitmap.CompressFormat.JPEG, 100, stream) - } - stream.toByteArray() + if (currentDecoderRef == null) { + val newDecoder = StorageUtils.openInputStream(context, uri).use { input -> + BitmapRegionDecoder.newInstance(input, false) } - if (data != null) { - result.success(data) + currentDecoderRef = Pair(uri, newDecoder) + } + val decoder = currentDecoderRef.second + lastDecoderRef = currentDecoderRef + + val data = decoder.decodeRegion(rect, options)?.let { + val stream = ByteArrayOutputStream() + // we compress the bitmap because Dart Image.memory cannot decode the raw bytes + // Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency + if (MimeTypes.canHaveAlpha(mimeType)) { + it.compress(Bitmap.CompressFormat.PNG, 0, stream) } else { - result.error("getRegion-null", "failed to decode region for uri=$uri rect=$rect", null) + it.compress(Bitmap.CompressFormat.JPEG, 100, stream) } + stream.toByteArray() + } + if (data != null) { + result.success(data) + } else { + result.error("getRegion-null", "failed to decode region for uri=$uri rect=$rect", null) } } catch (e: Exception) { - result.error("getRegion-read-exception", "failed to get image from uri=$uri", e.message) + result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri", e.message) } } } \ No newline at end of file diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index c07ff5745..c83bde20b 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -2,6 +2,10 @@ import 'dart:math'; const double _piOver180 = pi / 180.0; -double toDegrees(double radians) => radians / _piOver180; +final double log2 = log(2); -double toRadians(double degrees) => degrees * _piOver180; +double toDegrees(num radians) => radians / _piOver180; + +double toRadians(num degrees) => degrees * _piOver180; + +int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / log2).floor()); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 887160827..6ae604ab7 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -43,6 +43,7 @@ class _ImageViewState extends State { StreamSubscription _subscription; static const backgroundDecoration = BoxDecoration(color: Colors.transparent); + static const maxScale = 2.0; ImageEntry get entry => widget.entry; @@ -140,6 +141,7 @@ class _ImageViewState extends State { loadFailedChild: _buildError(), backgroundDecoration: backgroundDecoration, controller: _photoViewController, + maxScale: maxScale, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), @@ -166,6 +168,7 @@ class _ImageViewState extends State { childSize: entry.displaySize, backgroundDecoration: backgroundDecoration, controller: _photoViewController, + maxScale: maxScale, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), @@ -204,6 +207,7 @@ class _ImageViewState extends State { childSize: entry.displaySize, backgroundDecoration: backgroundDecoration, controller: _photoViewController, + maxScale: maxScale, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => onTap?.call(), diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index 186e4244d..732f9c8ad 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -27,8 +27,9 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { - double _initialScale; + double _tileSide, _initialScale; int _maxSampleSize; + Matrix4 _transform; ImageEntry get entry => widget.entry; @@ -36,10 +37,11 @@ class _TiledImageViewState extends State { ValueNotifier get viewStateNotifier => widget.viewStateNotifier; - static const tileSide = 200.0; - // margin around visible area to fetch surrounding tiles in advance - static const preFetchMargin = 50.0; + static const preFetchMargin = 0.0; + + // magic number used to derive sample size from scale + static const scaleFactor = 2.0; @override void initState() { @@ -57,8 +59,20 @@ class _TiledImageViewState extends State { } void _init() { + _tileSide = viewportSize.shortestSide * scaleFactor; _initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height); _maxSampleSize = _sampleSizeForScale(_initialScale); + + final rotationDegrees = entry.rotationDegrees; + final isFlipped = entry.isFlipped; + _transform = null; + if (rotationDegrees != 0 || isFlipped) { + _transform = Matrix4.identity() + ..translate(entry.width / 2.0, entry.height / 2.0) + ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) + ..rotateZ(-toRadians(rotationDegrees.toDouble())) + ..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0); + } } @override @@ -67,16 +81,6 @@ class _TiledImageViewState extends State { final displayWidth = entry.displaySize.width; final displayHeight = entry.displaySize.height; - final rotationDegrees = entry.rotationDegrees; - final isFlipped = entry.isFlipped; - Matrix4 transform; - if (rotationDegrees != 0 || isFlipped) { - transform = Matrix4.identity() - ..translate(entry.width / 2.0, entry.height / 2.0) - ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) - ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-displayWidth / 2.0, -displayHeight / 2.0); - } return AnimatedBuilder( animation: viewStateNotifier, @@ -98,7 +102,7 @@ class _TiledImageViewState extends State { final tiles = []; var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { - final layerRegionSize = Size.square(tileSide * sampleSize); + final layerRegionSize = Size.square(_tileSide * sampleSize); for (var x = 0.0; x < displayWidth; x += layerRegionSize.width) { for (var y = 0.0; y < displayHeight; y += layerRegionSize.height) { final regionOrigin = Offset(x, y); @@ -114,10 +118,10 @@ class _TiledImageViewState extends State { var regionRect = regionOrigin & thisRegionSize; // apply EXIF orientation - if (transform != null) { + if (_transform != null) { regionRect = Rect.fromPoints( - MatrixUtils.transformPoint(transform, regionRect.topLeft), - MatrixUtils.transformPoint(transform, regionRect.bottomRight), + MatrixUtils.transformPoint(_transform, regionRect.topLeft), + MatrixUtils.transformPoint(_transform, regionRect.bottomRight), ); } @@ -149,7 +153,7 @@ class _TiledImageViewState extends State { int _sampleSizeForScale(double scale) { var sample = 0; if (0 < scale && scale < 1) { - sample = pow(2, (log(1 / scale) / log(2)).floor()); + sample = highestPowerOf2((1 / scale) / scaleFactor); } return max(1, sample); } @@ -157,6 +161,7 @@ class _TiledImageViewState extends State { class RegionTile extends StatelessWidget { final ImageEntry entry; + // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates final Rect tileRect, regionRect;