#1384 improved subsampling and filter quality strategy

This commit is contained in:
Thibault Deckers 2025-02-01 00:08:36 +01:00
parent b54ed21c93
commit 892e64ef28
8 changed files with 73 additions and 32 deletions

View file

@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- improved subsampling and filter quality strategy
- upgraded Flutter to stable v3.27.3 - upgraded Flutter to stable v3.27.3
### Fixed ### Fixed

View file

@ -67,13 +67,13 @@ extension ExtraAvesEntryImages on AvesEntry {
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail(); return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
} }
// magic number used to derive sample size from scale static int sampleSizeForScale({
static const scaleFactor = 2.0; required double magnifierScale,
required double devicePixelRatio,
static int sampleSizeForScale(double scale) { }) {
var sample = 0; var sample = 0;
if (0 < scale && scale < 1) { if (0 < magnifierScale && magnifierScale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor); sample = highestPowerOf2(1 / (magnifierScale * devicePixelRatio));
} }
return max<int>(1, sample); return max<int>(1, sample);
} }

View file

@ -5,10 +5,10 @@ class AvesBorder {
static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26; static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26;
// 1 device pixel for straight lines is fine // 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 // 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( static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide(
color: _borderColor(context), color: _borderColor(context),

View file

@ -137,7 +137,7 @@ class _GeoMapState extends State<GeoMap> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = View.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress( void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress(
geoEntry: geoEntry, geoEntry: geoEntry,
tapLocation: tapLocation, tapLocation: tapLocation,

View file

@ -253,7 +253,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
final mappedGeoTiff = MappedGeoTiff( final mappedGeoTiff = MappedGeoTiff(
info: info, info: info,
entry: targetEntry, entry: targetEntry,
devicePixelRatio: View.of(context).devicePixelRatio, devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
); );
if (!mappedGeoTiff.canOverlay) return; if (!mappedGeoTiff.canOverlay) return;

View file

@ -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); provider = entry.getRegion(sampleSize: sampleSize, region: storageRegion);
displayRegion = Rect.fromLTWH( displayRegion = Rect.fromLTWH(
displayRegion.left / sampleSize, displayRegion.left / sampleSize,

View file

@ -122,24 +122,15 @@ class _RasterImageViewState extends State<RasterImageView> {
final viewportSized = viewportSize?.isEmpty == false; final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!); if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
final magnifierScale = viewState.scale!;
return SizedBox.fromSize( return SizedBox.fromSize(
size: _displaySize * viewState.scale!, size: _displaySize * magnifierScale,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (entry.canHaveAlpha && viewportSized) _buildBackground(), if (entry.canHaveAlpha && viewportSized) _buildBackground(),
_buildLoading(), _buildLoading(),
if (_useTiles) if (_useTiles) ..._buildTiles() else _buildFullImage(),
..._getTiles()
else
Image(
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (_displaySize * viewState.scale!).width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
], ],
), ),
); );
@ -147,11 +138,30 @@ class _RasterImageViewState extends State<RasterImageView> {
); );
} }
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) { void _initTiling(Size viewportSize) {
_tileSide = viewportSize.shortestSide * ExtraAvesEntryImages.scaleFactor; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
_tileSide = viewportSize.shortestSide * devicePixelRatio;
// scale for initial state `contained` // scale for initial state `contained`
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height); 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 rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped; final isFlipped = entry.isFlipped;
@ -229,25 +239,31 @@ class _RasterImageViewState extends State<RasterImageView> {
); );
} }
List<Widget> _getTiles() { List<Widget> _buildTiles() {
if (!_isTilingInitialized) return []; if (!_isTilingInitialized) return [];
final displayWidth = _displaySize.width.round(); final displayWidth = _displaySize.width.round();
final displayHeight = _displaySize.height.round(); final displayHeight = _displaySize.height.round();
final viewRect = _getViewRect(displayWidth, displayHeight); 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 // for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image without tiling // so we subsample the whole image without tiling
final fullImageRegionTile = _RegionTile( final fullImageRegionTile = _RegionTile(
entry: entry, entry: entry,
tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale), tileRect: Rect.fromLTWH(0, 0, displayWidth * magnifierScale, displayHeight * magnifierScale),
regionRect: fullImageRegion, regionRect: fullImageRegion,
sampleSize: _maxSampleSize, sampleSize: _maxSampleSize,
quality: _qualityForScale(
magnifierScale: magnifierScale,
sampleSize: _maxSampleSize,
devicePixelRatio: devicePixelRatio,
),
); );
final tiles = [fullImageRegionTile]; 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(); int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) { for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
final regionSide = (_tileSide * sampleSize).round(); final regionSide = (_tileSide * sampleSize).round();
@ -259,7 +275,7 @@ class _RasterImageViewState extends State<RasterImageView> {
regionSide: regionSide, regionSide: regionSide,
displayWidth: displayWidth, displayWidth: displayWidth,
displayHeight: displayHeight, displayHeight: displayHeight,
scale: scale, scale: magnifierScale,
viewRect: viewRect, viewRect: viewRect,
); );
if (rects != null) { if (rects != null) {
@ -269,6 +285,11 @@ class _RasterImageViewState extends State<RasterImageView> {
tileRect: tileRect, tileRect: tileRect,
regionRect: regionRect, regionRect: regionRect,
sampleSize: sampleSize, sampleSize: sampleSize,
quality: _qualityForScale(
magnifierScale: magnifierScale,
sampleSize: sampleSize,
devicePixelRatio: devicePixelRatio,
),
)); ));
} }
} }
@ -321,6 +342,21 @@ class _RasterImageViewState extends State<RasterImageView> {
} }
return (tileRect, regionRect); 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 { class _RegionTile extends StatefulWidget {
@ -331,12 +367,14 @@ class _RegionTile extends StatefulWidget {
final Rect tileRect; final Rect tileRect;
final Rectangle<int> regionRect; final Rectangle<int> regionRect;
final int sampleSize; final int sampleSize;
final FilterQuality quality;
const _RegionTile({ const _RegionTile({
required this.entry, required this.entry,
required this.tileRect, required this.tileRect,
required this.regionRect, required this.regionRect,
required this.sampleSize, required this.sampleSize,
required this.quality,
}); });
@override @override
@ -405,6 +443,7 @@ class _RegionTileState extends State<_RegionTile> {
width: tileRect.width, width: tileRect.width,
height: tileRect.height, height: tileRect.height,
fit: BoxFit.fill, fit: BoxFit.fill,
filterQuality: widget.quality,
); );
// apply EXIF orientation // apply EXIF orientation
@ -437,7 +476,7 @@ class _RegionTileState extends State<_RegionTile> {
Text( Text(
'\ntile=(${tileRect.left.round()}, ${tileRect.top.round()}) ${tileRect.width.round()} x ${tileRect.height.round()}' '\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()}' '\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), style: const TextStyle(backgroundColor: Colors.black87),
), ),
Positioned.fill( Positioned.fill(

View file

@ -106,7 +106,7 @@ class _VectorImageViewState extends State<VectorImageView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null); if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null);
final devicePixelRatio = View.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
return ValueListenableBuilder<ViewState>( return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier, valueListenable: viewStateNotifier,
builder: (context, viewState, child) { builder: (context, viewState, child) {