From 892e64ef28527074cbb931683929a4e6032b9390 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 1 Feb 2025 00:08:36 +0100 Subject: [PATCH] #1384 improved subsampling and filter quality strategy --- CHANGELOG.md | 1 + lib/model/entry/extensions/images.dart | 12 +-- lib/widgets/common/fx/borders.dart | 4 +- lib/widgets/common/map/geo_map.dart | 2 +- .../action/entry_info_action_delegate.dart | 2 +- .../viewer/overlay/wallpaper_buttons.dart | 3 +- lib/widgets/viewer/visual/raster.dart | 79 ++++++++++++++----- lib/widgets/viewer/visual/vector.dart | 2 +- 8 files changed, 73 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4155969..2716adc25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. ### Changed +- improved subsampling and filter quality strategy - upgraded Flutter to stable v3.27.3 ### Fixed diff --git a/lib/model/entry/extensions/images.dart b/lib/model/entry/extensions/images.dart index bac9d14bf..ee6a1c9aa 100644 --- a/lib/model/entry/extensions/images.dart +++ b/lib/model/entry/extensions/images.dart @@ -67,13 +67,13 @@ extension ExtraAvesEntryImages on AvesEntry { return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail(); } - // magic number used to derive sample size from scale - static const scaleFactor = 2.0; - - static int sampleSizeForScale(double scale) { + static int sampleSizeForScale({ + required double magnifierScale, + required double devicePixelRatio, + }) { var sample = 0; - if (0 < scale && scale < 1) { - sample = highestPowerOf2((1 / scale) / scaleFactor); + if (0 < magnifierScale && magnifierScale < 1) { + sample = highestPowerOf2(1 / (magnifierScale * devicePixelRatio)); } return max(1, sample); } diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index dcd3fcc1a..ac4cfdcfc 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -5,10 +5,10 @@ class AvesBorder { static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26; // 1 device pixel for straight lines is fine - static double straightBorderWidth(BuildContext context) => 1 / View.of(context).devicePixelRatio; + static double straightBorderWidth(BuildContext context) => 1 / MediaQuery.devicePixelRatioOf(context); // 1 device pixel for curves is too thin - static double curvedBorderWidth(BuildContext context) => View.of(context).devicePixelRatio > 2 ? 0.5 : 1.0; + static double curvedBorderWidth(BuildContext context) => MediaQuery.devicePixelRatioOf(context) > 2 ? 0.5 : 1.0; static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide( color: _borderColor(context), diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 24a9194f3..a73b0886a 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -137,7 +137,7 @@ class _GeoMapState extends State { @override Widget build(BuildContext context) { - final devicePixelRatio = View.of(context).devicePixelRatio; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); void onMarkerLongPress(GeoEntry geoEntry, LatLng tapLocation) => _onMarkerLongPress( geoEntry: geoEntry, tapLocation: tapLocation, diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index cb5c9e743..037921af3 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -253,7 +253,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi final mappedGeoTiff = MappedGeoTiff( info: info, entry: targetEntry, - devicePixelRatio: View.of(context).devicePixelRatio, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), ); if (!mappedGeoTiff.canOverlay) return; diff --git a/lib/widgets/viewer/overlay/wallpaper_buttons.dart b/lib/widgets/viewer/overlay/wallpaper_buttons.dart index 3c058a441..3964b9d31 100644 --- a/lib/widgets/viewer/overlay/wallpaper_buttons.dart +++ b/lib/widgets/viewer/overlay/wallpaper_buttons.dart @@ -160,7 +160,8 @@ class WallpaperButtons extends StatelessWidget with FeedbackMixin { ); } - final sampleSize = ExtraAvesEntryImages.sampleSizeForScale(scale); + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final sampleSize = ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: scale, devicePixelRatio: devicePixelRatio); provider = entry.getRegion(sampleSize: sampleSize, region: storageRegion); displayRegion = Rect.fromLTWH( displayRegion.left / sampleSize, diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index 8d0396be1..734aefb6a 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -122,24 +122,15 @@ class _RasterImageViewState extends State { final viewportSized = viewportSize?.isEmpty == false; if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!); + final magnifierScale = viewState.scale!; return SizedBox.fromSize( - size: _displaySize * viewState.scale!, + size: _displaySize * magnifierScale, child: Stack( alignment: Alignment.center, children: [ if (entry.canHaveAlpha && viewportSized) _buildBackground(), _buildLoading(), - if (_useTiles) - ..._getTiles() - else - Image( - image: fullImageProvider, - gaplessPlayback: true, - errorBuilder: widget.errorBuilder, - width: (_displaySize * viewState.scale!).width, - fit: BoxFit.contain, - filterQuality: FilterQuality.medium, - ), + if (_useTiles) ..._buildTiles() else _buildFullImage(), ], ), ); @@ -147,11 +138,30 @@ class _RasterImageViewState extends State { ); } + Widget _buildFullImage() { + final magnifierScale = viewState.scale!; + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final quality = _qualityForScale( + magnifierScale: magnifierScale, + sampleSize: 1, + devicePixelRatio: devicePixelRatio, + ); + return Image( + image: fullImageProvider, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + width: (_displaySize * magnifierScale).width, + fit: BoxFit.contain, + filterQuality: quality, + ); + } + void _initTiling(Size viewportSize) { - _tileSide = viewportSize.shortestSide * ExtraAvesEntryImages.scaleFactor; + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + _tileSide = viewportSize.shortestSide * devicePixelRatio; // scale for initial state `contained` final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height); - _maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(containedScale); + _maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: containedScale, devicePixelRatio: devicePixelRatio); final rotationDegrees = entry.rotationDegrees; final isFlipped = entry.isFlipped; @@ -229,25 +239,31 @@ class _RasterImageViewState extends State { ); } - List _getTiles() { + List _buildTiles() { if (!_isTilingInitialized) return []; final displayWidth = _displaySize.width.round(); final displayHeight = _displaySize.height.round(); final viewRect = _getViewRect(displayWidth, displayHeight); - final scale = viewState.scale!; + final magnifierScale = viewState.scale!; + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; // for the largest sample size (matching the initial scale), the whole image is in view // so we subsample the whole image without tiling final fullImageRegionTile = _RegionTile( entry: entry, - tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale), + tileRect: Rect.fromLTWH(0, 0, displayWidth * magnifierScale, displayHeight * magnifierScale), regionRect: fullImageRegion, sampleSize: _maxSampleSize, + quality: _qualityForScale( + magnifierScale: magnifierScale, + sampleSize: _maxSampleSize, + devicePixelRatio: devicePixelRatio, + ), ); final tiles = [fullImageRegionTile]; - var minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(scale), _maxSampleSize); + final minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(magnifierScale: magnifierScale, devicePixelRatio: devicePixelRatio), _maxSampleSize); int nextSampleSize(int sampleSize) => (sampleSize / 2).floor(); for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) { final regionSide = (_tileSide * sampleSize).round(); @@ -259,7 +275,7 @@ class _RasterImageViewState extends State { regionSide: regionSide, displayWidth: displayWidth, displayHeight: displayHeight, - scale: scale, + scale: magnifierScale, viewRect: viewRect, ); if (rects != null) { @@ -269,6 +285,11 @@ class _RasterImageViewState extends State { tileRect: tileRect, regionRect: regionRect, sampleSize: sampleSize, + quality: _qualityForScale( + magnifierScale: magnifierScale, + sampleSize: sampleSize, + devicePixelRatio: devicePixelRatio, + ), )); } } @@ -321,6 +342,21 @@ class _RasterImageViewState extends State { } return (tileRect, regionRect); } + + // follow recommended thresholds from `FilterQuality` documentation + static FilterQuality _qualityForScale({ + required double magnifierScale, + required int sampleSize, + required double devicePixelRatio, + }) { + final entryScale = magnifierScale * devicePixelRatio; + final renderingScale = entryScale * sampleSize; + if (renderingScale > 1) { + return renderingScale > 10 ? FilterQuality.high : FilterQuality.medium; + } else { + return renderingScale < .5 ? FilterQuality.medium : FilterQuality.high; + } + } } class _RegionTile extends StatefulWidget { @@ -331,12 +367,14 @@ class _RegionTile extends StatefulWidget { final Rect tileRect; final Rectangle regionRect; final int sampleSize; + final FilterQuality quality; const _RegionTile({ required this.entry, required this.tileRect, required this.regionRect, required this.sampleSize, + required this.quality, }); @override @@ -405,6 +443,7 @@ class _RegionTileState extends State<_RegionTile> { width: tileRect.width, height: tileRect.height, fit: BoxFit.fill, + filterQuality: widget.quality, ); // apply EXIF orientation @@ -437,7 +476,7 @@ class _RegionTileState extends State<_RegionTile> { Text( '\ntile=(${tileRect.left.round()}, ${tileRect.top.round()}) ${tileRect.width.round()} x ${tileRect.height.round()}' '\nregion=(${regionRect.left.round()}, ${regionRect.top.round()}) ${regionRect.width.round()} x ${regionRect.height.round()}' - '\nsampleSize=${widget.sampleSize}', + '\nsampling=${widget.sampleSize} quality=${widget.quality.name}', style: const TextStyle(backgroundColor: Colors.black87), ), Positioned.fill( diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index 0a5479848..bcf930f5a 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -106,7 +106,7 @@ class _VectorImageViewState extends State { Widget build(BuildContext context) { if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null); - final devicePixelRatio = View.of(context).devicePixelRatio; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) {