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 5cd287c6a..cc8c61dd8 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 @@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls import android.app.Activity import android.graphics.Rect import android.net.Uri +import android.util.Size import com.bumptech.glide.Glide import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.provider.FieldMap @@ -82,12 +83,14 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val uri = call.argument("uri")?.let { Uri.parse(it) } val mimeType = call.argument("mimeType") val sampleSize = call.argument("sampleSize") - val x = call.argument("x") - val y = call.argument("y") - val width = call.argument("width") - val height = call.argument("height") + val x = call.argument("regionX") + val y = call.argument("regionY") + val width = call.argument("regionWidth") + val height = call.argument("regionHeight") + val imageWidth = call.argument("imageWidth") + val imageHeight = call.argument("imageHeight") - if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null) { + if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null || imageWidth == null || imageHeight == null) { result.error("getRegion-args", "failed because of missing arguments", null) return } @@ -97,6 +100,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { mimeType, sampleSize, Rect(x, y, x + width, y + height), + Size(imageWidth, imageHeight), result, ) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index e916639b0..5f0231b2f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -5,6 +5,7 @@ import android.content.ContentUris import android.content.Context import android.database.Cursor import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build @@ -58,6 +59,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream +import java.io.IOException import java.util.* import kotlin.math.roundToLong @@ -70,6 +72,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } + "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } @@ -483,6 +486,34 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (uri == null) { + result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(input, null, options) + options.outMimeType?.let { metadataMap["MimeType"] = it } + options.outWidth.let { metadataMap["Width"] = it.toString() } + options.outHeight.let { metadataMap["Height"] = it.toString() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() } + options.outConfig?.let { metadataMap["Config"] = it.toString() } + } + } + } catch (e: IOException) { + // ignore + } + result.success(metadataMap) + } + private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { 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 5086513bf..bc35b47f3 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 @@ -6,22 +6,24 @@ import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.net.Uri -import android.util.Log +import android.util.Size import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt class RegionFetcher internal constructor( private val context: Context, ) { - private var lastDecoderRef: Pair? = null + private var lastDecoderRef: LastDecoderRef? = null fun fetch( uri: Uri, mimeType: String, sampleSize: Int, - rect: Rect, + regionRect: Rect, + imageSize: Size, result: MethodChannel.Result, ) { val options = BitmapFactory.Options().apply { @@ -29,8 +31,8 @@ class RegionFetcher internal constructor( } var currentDecoderRef = lastDecoderRef - if (currentDecoderRef != null && currentDecoderRef.first != uri) { - currentDecoderRef.second.recycle() + if (currentDecoderRef != null && currentDecoderRef.uri != uri) { + currentDecoderRef.decoder.recycle() currentDecoderRef = null } @@ -39,12 +41,27 @@ class RegionFetcher internal constructor( val newDecoder = StorageUtils.openInputStream(context, uri).use { input -> BitmapRegionDecoder.newInstance(input, false) } - currentDecoderRef = Pair(uri, newDecoder) + currentDecoderRef = LastDecoderRef(uri, newDecoder) } - val decoder = currentDecoderRef.second + val decoder = currentDecoderRef.decoder lastDecoderRef = currentDecoderRef - val data = decoder.decodeRegion(rect, options)?.let { + // with raw images, the known image size may not match the decoded image size + // so we scale the requested region accordingly + val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) { + val xf = decoder.width.toDouble() / imageSize.width + val yf = decoder.height.toDouble() / imageSize.height + Rect( + (regionRect.left * xf).roundToInt(), + (regionRect.top * yf).roundToInt(), + (regionRect.right * xf).roundToInt(), + (regionRect.bottom * yf).roundToInt(), + ) + } else { + regionRect + } + + val data = decoder.decodeRegion(effectiveRect, 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 @@ -58,10 +75,15 @@ class RegionFetcher internal constructor( if (data != null) { result.success(data) } else { - result.error("getRegion-null", "failed to decode region for uri=$uri rect=$rect", null) + result.error("getRegion-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) } } catch (e: Exception) { - result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri", e.message) + result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message) } } -} \ No newline at end of file +} + +private data class LastDecoderRef( + val uri: Uri, + val decoder: BitmapRegionDecoder, +) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 846315d4f..44b153617 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -123,7 +123,8 @@ class SourceImageEntry { fillVideoByMediaMetadataRetriever(context) if (isSized && hasDuration) return this } - if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { + // skip metadata-extractor for raw images because it reports the decoded dimensions instead of the raw dimensions + if (!MimeTypes.isRaw(sourceMimeType) && MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { fillByMetadataExtractor(context) if (isSized && foundExif) return this } @@ -224,8 +225,9 @@ class SourceImageEntry { private fun fillByBitmapDecode(context: Context) { try { StorageUtils.openInputStream(context, uri)?.use { input -> - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } BitmapFactory.decodeStream(input, null, options) width = options.outWidth height = options.outHeight diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 68ca8c974..ce3b59aca 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -144,11 +144,14 @@ class MediaStoreImageProvider : ImageProvider() { "contentId" to contentId, ) - if ((width <= 0 || height <= 0) && needSize(mimeType) + if (MimeTypes.isRaw(mimeType) + || (width <= 0 || height <= 0) && needSize(mimeType) || durationMillis == 0L && needDuration ) { - // some images are incorrectly registered in the Media Store, - // they are valid but miss some attributes, such as width, height, orientation + // Some images are incorrectly registered in the Media Store, + // missing some attributes such as width, height, orientation. + // Also, the reported size of raw images is inconsistent across devices + // and Android versions (sometimes the raw size, sometimes the decoded size). val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context) entryMap = entry.toMap() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 4571ad2e4..b102428af 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -16,7 +16,16 @@ object MimeTypes { const val WEBP = "image/webp" // raw raster + private const val ARW = "image/x-sony-arw" + private const val CR2 = "image/x-canon-cr2" private const val DNG = "image/x-adobe-dng" + private const val NEF = "image/x-nikon-nef" + private const val NRW = "image/x-nikon-nrw" + private const val ORF = "image/x-olympus-orf" + private const val PEF = "image/x-pentax-pef" + private const val RAF = "image/x-fuji-raf" + private const val RW2 = "image/x-panasonic-rw2" + private const val SRW = "image/x-samsung-srw" // vector const val SVG = "image/svg+xml" @@ -35,6 +44,13 @@ object MimeTypes { else -> isVideo(mimeType) } + fun isRaw(mimeType: String?): Boolean { + return when (mimeType) { + ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true + else -> false + } + } + // returns whether the specified MIME type represents // a raster image format that allows an alpha channel fun canHaveAlpha(mimeType: String?) = when (mimeType) { diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 6aa8dbe2e..1c6e1d02c 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -169,7 +169,26 @@ class ImageEntry { // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw; - bool get canTile => !isVideo && !isAnimated && ![MimeTypes.gif].contains(mimeType); + // Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported" + // but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below, + // and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested. + bool get canTile => + [ + MimeTypes.heic, + MimeTypes.heif, + MimeTypes.jpeg, + MimeTypes.webp, + MimeTypes.arw, + MimeTypes.cr2, + MimeTypes.nef, + MimeTypes.nrw, + MimeTypes.orf, + MimeTypes.pef, + MimeTypes.raf, + MimeTypes.rw2, + MimeTypes.srw, + ].contains(mimeType) && + !isAnimated; bool get isRaw => MimeTypes.rawImages.contains(mimeType); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 3b7d3cd03..0eec5e1ff 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; @@ -113,13 +114,15 @@ class ImageFileService { return Future.sync(() => null); } + // `rect`: region to decode, with coordinates in reference to `imageSize` static Future getRegion( String uri, String mimeType, int rotationDegrees, bool isFlipped, int sampleSize, - Rect rect, { + Rectangle regionRect, + Size imageSize, { Object taskKey, int priority, }) { @@ -130,10 +133,12 @@ class ImageFileService { 'uri': uri, 'mimeType': mimeType, 'sampleSize': sampleSize, - 'x': rect.left.toInt(), - 'y': rect.top.toInt(), - 'width': rect.width.toInt(), - 'height': rect.height.toInt(), + 'regionX': regionRect.left, + 'regionY': regionRect.top, + 'regionWidth': regionRect.width, + 'regionHeight': regionRect.height, + 'imageWidth': imageSize.width.toInt(), + 'imageHeight': imageSize.height.toInt(), }); return result as Uint8List; } on PlatformException catch (e) { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index c99b1c9fe..fbe5ca6f1 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -76,6 +76,19 @@ class MetadataService { return null; } + static Future getBitmapFactoryInfo(ImageEntry entry) async { + try { + // return map with all data available when decoding image bounds with `BitmapFactory` + final result = await platform.invokeMethod('getBitmapFactoryInfo', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + static Future getContentResolverMetadata(ImageEntry entry) async { try { // return map with all data available from the content resolver @@ -92,7 +105,7 @@ class MetadataService { static Future getExifInterfaceMetadata(ImageEntry entry) async { try { - // return map with all data available from the ExifInterface library + // return map with all data available from the `ExifInterface` library final result = await platform.invokeMethod('getExifInterfaceMetadata', { 'uri': entry.uri, }) as Map; @@ -105,7 +118,7 @@ class MetadataService { static Future getMediaMetadataRetrieverMetadata(ImageEntry entry) async { try { - // return map with all data available from the MediaMetadataRetriever + // return map with all data available from `MediaMetadataRetriever` final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', { 'uri': entry.uri, }) as Map; diff --git a/lib/widgets/common/image_providers/region_provider.dart b/lib/widgets/common/image_providers/region_provider.dart index a6a8c86d7..d99a2217d 100644 --- a/lib/widgets/common/image_providers/region_provider.dart +++ b/lib/widgets/common/image_providers/region_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'dart:ui' as ui show Codec; import 'package:aves/model/image_entry.dart'; @@ -22,7 +23,7 @@ class RegionProvider extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${key.uri}, rect=${key.rect}'); + yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}'); }, ); } @@ -37,7 +38,8 @@ class RegionProvider extends ImageProvider { key.rotationDegrees, key.isFlipped, key.sampleSize, - key.rect, + key.regionRect, + key.imageSize, taskKey: key, ); if (bytes == null) { @@ -63,7 +65,8 @@ class RegionProviderKey { final String uri, mimeType; final int rotationDegrees, sampleSize; final bool isFlipped; - final Rect rect; + final Rectangle regionRect; + final Size imageSize; final double scale; const RegionProviderKey({ @@ -72,14 +75,16 @@ class RegionProviderKey { @required this.rotationDegrees, @required this.isFlipped, @required this.sampleSize, - @required this.rect, + @required this.regionRect, + @required this.imageSize, this.scale = 1.0, }) : assert(uri != null), assert(mimeType != null), assert(rotationDegrees != null), assert(isFlipped != null), assert(sampleSize != null), - assert(rect != null), + assert(regionRect != null), + assert(imageSize != null), assert(scale != null); // do not store the entry as it is, because the key should be constant @@ -87,7 +92,7 @@ class RegionProviderKey { factory RegionProviderKey.fromEntry( ImageEntry entry, { @required int sampleSize, - @required Rect rect, + @required Rectangle rect, }) { return RegionProviderKey( uri: entry.uri, @@ -95,14 +100,15 @@ class RegionProviderKey { rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, sampleSize: sampleSize, - rect: rect, + regionRect: rect, + imageSize: Size(entry.width.toDouble(), entry.height.toDouble()), ); } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.rect == rect && other.scale == scale; + return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale; } @override @@ -113,12 +119,13 @@ class RegionProviderKey { isFlipped, mimeType, sampleSize, - rect, + regionRect, + imageSize, scale, ); @override String toString() { - return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, rect=$rect, scale=$scale)'; + return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)'; } } diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/fullscreen/debug/metadata.dart index 4b1ad5bd2..f05fbf623 100644 --- a/lib/widgets/fullscreen/debug/metadata.dart +++ b/lib/widgets/fullscreen/debug/metadata.dart @@ -18,7 +18,7 @@ class MetadataTab extends StatefulWidget { } class _MetadataTabState extends State { - Future _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; + Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; // MediaStore timestamp keys static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; @@ -33,6 +33,7 @@ class _MetadataTabState extends State { } void _loadMetadata() { + _bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); @@ -77,6 +78,10 @@ class _MetadataTabState extends State { return ListView( padding: EdgeInsets.all(8), children: [ + FutureBuilder( + future: _bitmapFactoryLoader, + builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'), + ), FutureBuilder( future: _contentResolverMetadataLoader, builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index f4ae2e24b..2afd375c3 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -37,9 +37,6 @@ class _TiledImageViewState extends State { ValueNotifier get viewStateNotifier => widget.viewStateNotifier; - // margin around visible area to fetch surrounding tiles in advance - static const preFetchMargin = 0.0; - // magic number used to derive sample size from scale static const scaleFactor = 2.0; @@ -79,8 +76,8 @@ class _TiledImageViewState extends State { Widget build(BuildContext context) { if (viewStateNotifier == null) return SizedBox.shrink(); - final displayWidth = entry.displaySize.width; - final displayHeight = entry.displaySize.height; + final displayWidth = entry.displaySize.width.round(); + final displayHeight = entry.displaySize.height.round(); return AnimatedBuilder( animation: viewStateNotifier, @@ -97,32 +94,40 @@ class _TiledImageViewState extends State { ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), ); - final viewRect = (viewOrigin & viewportSize).inflate(preFetchMargin); + final viewRect = viewOrigin & viewportSize; final tiles = []; var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { - 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); - final nextOrigin = regionOrigin.translate(layerRegionSize.width, layerRegionSize.height); - final thisRegionSize = Size( - layerRegionSize.width - (nextOrigin.dx >= displayWidth ? nextOrigin.dx - displayWidth : 0), - layerRegionSize.height - (nextOrigin.dy >= displayHeight ? nextOrigin.dy - displayHeight : 0), - ); - final tileRect = regionOrigin * scale & thisRegionSize * scale; + // for the largest sample size (matching the initial scale), the whole image is in view + // so we subsample the whole image instead of splitting it in tiles + final useTiles = sampleSize != _maxSampleSize; + final regionSide = (_tileSide * sampleSize).round(); + final layerRegionWidth = useTiles ? regionSide : displayWidth; + final layerRegionHeight = useTiles ? regionSide : displayHeight; + for (var x = 0; x < displayWidth; x += layerRegionWidth) { + for (var y = 0; y < displayHeight; y += layerRegionHeight) { + final nextX = x + layerRegionWidth; + final nextY = y + layerRegionHeight; + final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); + final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0); + final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); // only build visible tiles if (viewRect.overlaps(tileRect)) { - var regionRect = regionOrigin & thisRegionSize; + Rectangle regionRect; - // apply EXIF orientation if (_transform != null) { - regionRect = Rect.fromPoints( - MatrixUtils.transformPoint(_transform, regionRect.topLeft), - MatrixUtils.transformPoint(_transform, regionRect.bottomRight), + // apply EXIF orientation + final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); + final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft); + final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight); + regionRect = Rectangle.fromPoints( + Point(tl.dx.round(), tl.dy.round()), + Point(br.dx.round(), br.dy.round()), ); + } else { + regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); } tiles.add(RegionTile( @@ -164,7 +169,8 @@ class RegionTile extends StatefulWidget { // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates - final Rect tileRect, regionRect; + final Rect tileRect; + final Rectangle regionRect; final int sampleSize; const RegionTile({ @@ -268,6 +274,6 @@ class _RegionTileState extends State { super.debugFillProperties(properties); properties.add(IntProperty('contentId', widget.entry.contentId)); properties.add(IntProperty('sampleSize', widget.sampleSize)); - properties.add(DiagnosticsProperty('regionRect', widget.regionRect)); + properties.add(DiagnosticsProperty>('regionRect', widget.regionRect)); } }