Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix scaling of cover art on high pixel density screens #2247

Merged
merged 2 commits into from
Nov 24, 2019

Conversation

Be-ing
Copy link
Contributor

@Be-ing Be-ing commented Aug 20, 2019

Before, cover art images were getting scaled down to logical/device independent/1x pixel scale then automatically scaled up by the screen scale ratio which made them look blocky. By using QPixmap/QImage::setDevicePixelRatio and drawing QPixmaps/QImages at sizes scaled by the device pixel ratio, the cover art images only get scaled once to the size they are really shown at on screen.

Unfortunately there is a regression that somehow makes only one CoverArtDelegate show a cover at a time. I tried increasing the cache size by a factor of 100 and that made no difference. I would appreciate any hints where the problem might be with this.

Before:
Screenshot from 2019-08-20 00-10-55
After:
Screenshot from 2019-08-20 00-20-37

Before:
Screenshot from 2019-08-20 00-26-54

After:

Screenshot from 2019-08-20 00-29-11

@@ -48,6 +48,7 @@ CoverArtCache::~CoverArtCache() {
QPixmap CoverArtCache::requestCover(const CoverInfo& requestInfo,
const QObject* pRequestor,
const int desiredWidth,
const double devicePixelRatio,
Copy link
Contributor Author

@Be-ing Be-ing Aug 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the devicePixelRatio should be specified separately for each widget that accesses CoverArtCache. I thought about setting a global scale factor for CoverArtCache, but that would not work well in case widgets on different screens with different scale factors access the CoverArtCache singleton.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an important info why the design is as it is now. Please add the comment to the code as well.

@uklotzde
Copy link
Contributor

The generated image now depends on devicePixelRatio and so this new parameter must be considered in pixmapCacheKey(). Maybe by scaling it with a fixed constant like 100 and then rounding to integer?

@Be-ing
Copy link
Contributor Author

Be-ing commented Aug 21, 2019

scaling it with a fixed constant like 100 and then rounding to integer

Why not just used the scaled size in the cache key?

@uklotzde
Copy link
Contributor

uklotzde commented Aug 21, 2019

But then I don't get the idea behind this PR. If only the image size matters, why let the cache know anything about the device pixel ratio if it doesn't affect the resulting images that are reused for matching requests? If you request the same size but with a different device pixel ratio you will always get the same image.

If on the other hand it does affect the cached image it must be reflected in the cache key. Otherwise the caches doesn't work as expected.

My first idea was that the cache should never care about where images are actually displayed. It should simply pre-compute and store images of different (pixel) sizes and the client needs to decide which size is appropriate for display. Maybe I don't understand how QImage::setDevicePixelRation() is working?

@Be-ing
Copy link
Contributor Author

Be-ing commented Aug 21, 2019

Maybe I don't understand how QImage::setDevicePixelRation() is working?

It's confusing and not documented very well IMO. Without CoverArtCache calling this, everywhere cover art is used would have to do it. As I understand it, if the devicePixelRatio of the QImage/QPixmap are not equal to the screen, Qt will automatically scale the QImage/QPixmap up when a QPainter draws it on screen. But if the QImage/QPixmap's devicePixelRatio is equal to the screen's, then Qt skips this automatic scaling. This also requires the real dimensions of the QImage/QPixmap to be scaled by the devicePixelRatio.

If on the other hand it does affect the cached image it must be reflected in the cache key.

I think you are right. I'll do that. However, this doesn't solve the issue with only one CoverArtDelegate's cover art getting drawn at a time. Do you have any ideas about that?

@Be-ing
Copy link
Contributor Author

Be-ing commented Aug 21, 2019

the issue with only one CoverArtDelegate's cover art getting drawn at a time

It turns out this had nothing to do with caching. I was inappropriately scaling the target QRect for the drawing target, so the covers were being drawn, but they were being drawn outside the boundaries of the CoverArtDelegate.

Ready to merge?

.arg(QString::number(hash)).arg(width);
QString pixmapCacheKey(quint16 hash, int width, double devicePixelRatio) {
return QString("CoverArtCache_%1_%2_%3")
.arg(QString::number(hash)).arg(width).arg(devicePixelRatio);
Copy link
Contributor

@uklotzde uklotzde Aug 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to scale and round the device pixel ratio before formatting into an integer, e.g. 1.5 becomes "150":

...arg(static_cast<int>(std::round(devicePixelRatio * 100)))

Maybe add some assertions to detect unexpected values, e.g. devicePixelRatio >= 0.1 and devicePixelRatio <= 10.0 (for 10x up/down scaling)

@uklotzde
Copy link
Contributor

uklotzde commented Aug 22, 2019

I'm still not sure if the addition of device pixel ratio here is really necessary.

Assume that devicePixelRatio = 2.0 and you request the image with the original size by setting desiredWidth = 0. Then you get back a QImage, say 400x400. You also could have requested it with desiredWidth = 200 and devicePixelRatio = 2.0 and you still get the same 400x400 image. The parameter desiredWidth should actually be named desiredDeviceWidth:

desiredPixelWidth = desiredDeviceWidth * devicePixelRatio

For caching purposes only the pixel resolution of the image matters, but not the actual device resolution! Internally CoverArtCache could store all images with a devicePixelRatio of 1.0. If the image is requested with the separate parameters desiredDeviceWidth and devicePixelRatio it should invoke QImage::setDevicePixelRatio() just before returning the cached QImage by value. My hope is that the implicit sharing will not duplicate the actual image data when modifying this copied instance.

@uklotzde
Copy link
Contributor

We need to ensure that the public API uses either actual pixels or (virtual) device pixels. If it uses the latter then the devicePixelRatio parameter is a mandatory parameter for all API calls that deal with sizes.

@Be-ing
Copy link
Contributor Author

Be-ing commented Aug 22, 2019

Internally CoverArtCache could store all images with a devicePixelRatio of 1.0. If the image is requested with the separate parameters desiredDeviceWidth and devicePixelRatio it should invoke QImage::setDevicePixelRatio() just before returning the cached QImage by value. My hope is that the implicit sharing will not duplicate the actual image data when modifying this copied instance.

Done. However, I think it is very unlikely that an image on another screen will be scaled to the same real pixel size as one on another screen with a different scale factor. The ratio of the device-independent scaled areas on different screens would need to be equal to the ratio of the devicePixelRatios of each screen. Also remember CoverArtCache only caches resized images; full size images do not get cached so there is nothing to share.

const bool onlyCached,
const bool signalWhenDone) {
if (sDebug) {
kLogger.debug() << "requestCover"
<< requestInfo << pRequestor <<
desiredWidth << onlyCached << signalWhenDone;
deviceIndependentWidth << onlyCached << signalWhenDone;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New parameter devicePixelRatio is missing

const bool signalWhenDone) {
if (sDebug) {
kLogger.debug() << "loadCover"
<< info << desiredWidth << signalWhenDone;
<< info << deviceIndependentWidth << signalWhenDone;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New parameter devicePixelRatio is missing

@@ -173,9 +182,10 @@ void CoverArtCache::coverLoaded() {
// we have to be sure that res.cover.hash is unique
// because insert replaces the images with the same key
QString cacheKey = pixmapCacheKey(
res.cover.hash, res.cover.resizedToWidth);
res.cover.hash, res.cover.resizedToWidth * res.cover.devicePixelRatio);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The multiplication could be wrapped into a member function CoverArt::actualWidth()

@@ -26,11 +26,13 @@ class CoverArtCache : public QObject, public Singleton<CoverArtCache> {
QPixmap requestCover(const CoverInfo& info,
const QObject* pRequestor,
const int desiredWidth,
const double devicePixelRatio,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please finish the renaming in the header file: desiredWidth -> deviceIndependentWidth

QRect source(0, 0, target.width(), target.height());
int width = math_min(pixmap.width(), option.rect.width()) * scaleFactor;
int height = math_min(pixmap.height(), option.rect.height()) * scaleFactor;
QRect target(option.rect.x(), option.rect.y(), width, height);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have to scale manually to the device resolution instead of letting Qt do the work by consistently using setDevicePixelRatio() and doing all sizing in device independent coordinates? This is how I thought it is supposed to work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'll take another look at this logic. pixmap.width()/height() should be in scaled sizes already, but it looks like math_min picks the option.rect.width()/height(), which then needs to be scaled.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I was first writing this, I multiplied by scaleFactor everywhere until I got it to work without really understanding what was going on. I may be able to simplify the code now...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this manual scaling is confusing, I still don't get it. Why doesn't Qt provide a consistent measurement system? My assumption was that setting device pixel ratio does all the magic for us. These conversions back and forth between different coordinate systems are really error prone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, it is awfully confusing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the original code is now outdated since we have non ^2 scale favtors.
Originally this code prevents us from upscaling an actual 15×15 to 20x20 for instance. This will likely look blurry. Instead we print the 15x15 and pad it for 20x20

This approach still makes sense, but we need to considder the scaleFactor.
If it is 1 or 2 the code works. If it is 1.5 the code fails.
20x20 is in this case 30x30 and the pixmap will look perfectly scaled by 2.

I must admit that this is a constructed example, but it is relevant.

@uklotzde
Copy link
Contributor

No matter what you set in QImage::setDeviceImageRatio(), the size of the image is always in actual pixels according to the docs:

 QSize layoutSize = image.size() / image.devicePixelRatio()

Now we are doing the transformation between device independent pixels twice, both inside the cache and outside when calculating the layout before painting the image. This is bad.

We should clearly separate the responsibilities to avoid confusion:

  • The CoverArtCache doesn't need to care about painting and should use actual pixels, bot in the API and internally.
  • The Caller is responsible for calculating the required actual pixel size and invoke setDeviceImageRatio() before painting , because it needs to do it anyway when calculating and composing the layout

Would this help reduce the number of transformations and keep them within a single abstraction layer? Because now the multiplication (device independent size -> actual pixel size) is done within the cache and whereas the division (actual pixel size -> device independent size) is done by the widget. The widgets should solely be responsible for this transformation.

Or you wrap the QImage into some helper class DeviceIndependentImage that is responsible for these transformations and other than QImage presents its size in layout coordinates? Hacking this halfheartedly into QImage is a backward-compatible but rather ugly workaround that Qt has chosen here. In Rust the newtype pattern is a really powerful and ubiquitous idiom that enforces type safety at compile time 😄

Copy link
Member

@daschuer daschuer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a brief look.

@@ -48,6 +48,7 @@ CoverArtCache::~CoverArtCache() {
QPixmap CoverArtCache::requestCover(const CoverInfo& requestInfo,
const QObject* pRequestor,
const int desiredWidth,
const double devicePixelRatio,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an important info why the design is as it is now. Please add the comment to the code as well.

src/library/coverartcache.cpp Outdated Show resolved Hide resolved
QRect target(option.rect.x(), option.rect.y(),
width, height);
QRect source(0, 0, target.width(), target.height());
int width = math_min(pixmap.width(), option.rect.width()) * scaleFactor;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

math_min compares the actualWidth of the pimamp with the device independent width from opition. I think this is desired here, but needs a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pixmap dimensions are measured in device coords, but what about option.rect, source, target?

I agree with Daniel that this code seems to be incorrect, because it mixes up different measurements. The final multiplication scales the pixmap's coords up again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some experimentation I realized QPainter::drawPixmap(QRect, QPixmap, QRect) wasn't working how I thought at all and it was also overcomplicated. The simpler QPainter::drawPixmap(QPoint, QPixmap) works fine without any complicated size conversions.

QRect source(0, 0, target.width(), target.height());
int width = math_min(pixmap.width(), option.rect.width()) * scaleFactor;
int height = math_min(pixmap.height(), option.rect.height()) * scaleFactor;
QRect target(option.rect.x(), option.rect.y(), width, height);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the original code is now outdated since we have non ^2 scale favtors.
Originally this code prevents us from upscaling an actual 15×15 to 20x20 for instance. This will likely look blurry. Instead we print the 15x15 and pad it for 20x20

This approach still makes sense, but we need to considder the scaleFactor.
If it is 1 or 2 the code works. If it is 1.5 the code fails.
20x20 is in this case 30x30 and the pixmap will look perfectly scaled by 2.

I must admit that this is a constructed example, but it is relevant.

@Be-ing Be-ing force-pushed the coverart_scaling branch 4 times, most recently from 69b2d74 to 2998cae Compare August 27, 2019 21:05
@Be-ing Be-ing added this to the 2.2.3 milestone Oct 24, 2019
@Be-ing
Copy link
Contributor Author

Be-ing commented Oct 24, 2019

There ended up being commits that just reverted changes from earlier commits in this branch. Shall I rebase to condense this down to one commit?

@uklotzde
Copy link
Contributor

Yes, that's a good idea.

@Be-ing
Copy link
Contributor Author

Be-ing commented Oct 30, 2019

@uklotzde @daschuer ping. I'd like to merge this and release 2.2.3.

@uklotzde
Copy link
Contributor

Ok, I'll check this PR asap.

Unfortunately, I'm currently experiencing issues on Fedora 31: https://bugs.launchpad.net/mixxx/+bug/1850729

@daschuer
Copy link
Member

I guess the "resize to a integer scale" code is not working for HiDPI screens in coverartdelegate.cpp. See the outdated discussion above.
We need to check.

@uklotzde
Copy link
Contributor

Just some minor nitpicks, otherwise reasonable and concise. LGTM.

@Be-ing
Copy link
Contributor Author

Be-ing commented Nov 6, 2019

I guess the "resize to a integer scale" code is not working for HiDPI screens in coverartdelegate.cpp. See the outdated discussion above.
We need to check.

I don't know what you mean. It looks fine on my screen, otherwise I would not have pushed the code.

@Holzhaus
Copy link
Member

Holzhaus commented Nov 8, 2019

I tried to build this branch, but I'm getting errors:

$ rm -rf lin64_build/ .scon* cache/ config.log *.plist scons.txt
$ scons -j16
[...]
In file included from src/controllers/dlgcontrollerlearning.cpp:12:
src/controllers/dlgcontrollerlearning.h:16:10: fatal error: controllers/ui_dlgcontrollerlearning.h: No such file or directory
   16 | #include "controllers/ui_dlgcontrollerlearning.h"
      |          ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.
scons: *** [lin64_build/controllers/dlgcontrollerlearning.o] Error 1
In file included from src/controllers/dlgprefcontroller.h:18,
                 from src/controllers/dlgprefcontroller.cpp:16:
src/controllers/dlgcontrollerlearning.h:16:10: fatal error: controllers/ui_dlgcontrollerlearning.h: No such file or directory
   16 | #include "controllers/ui_dlgcontrollerlearning.h"
      |          ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.
scons: *** [lin64_build/controllers/dlgprefcontroller.o] Error 1
scons: building terminated because of errors.

@uklotzde
Copy link
Contributor

uklotzde commented Nov 8, 2019

@Holzhaus The 2.2.x build still requires Python 2. We didn't backport your Python 3 fixes.

@Holzhaus
Copy link
Member

Ok, I patched in support for Python 3. Builds fine, and the coverart looks much less pixelated on my 4K screen. Unfortunately, the coverart in the bottom left corner (below the sidebar) is not centered anymore.

Before:
2019-11-12-15-13-42_3840x2160

After:
2019-11-12-15-13-54_3840x2160

@daschuer
Copy link
Member

Can you also confirm the blurryness issue shown in #2247 (comment)
To see it you need to scale Mixxx for a cover art size slightly above it's maybe size.

@Holzhaus
Copy link
Member

Can you also confirm the blurryness issue shown in #2247 (comment)
To see it you need to scale Mixxx for a cover art size slightly above it's maybe size.

No, but I don't know if I did it correctly. I opened the coverart display, and then made the window slightly larger.

This branch is on the left, master is on the right:
2019-11-12-16-12-36_3840x2160

However, if I do not resize the cover art windows and keep them at their default size, the fonts on right one looks slightly better to read despite being more pixelated. Is that the issue what you're referring to?
2019-11-12-16-14-45_3840x2160

@daschuer
Copy link
Member

No, not exactly. It looks like you have discovered an other issue. My issue happens in the cover art column. I have just double checked the issue and all of my covers are big enough that the dicovered corner case is not that relevant.

The issue that you have discovered is that the window size is scaled from the original pixel size to the display pixel size. With a FullHD screen the cover is displayed unscaled pixel by pixel. In your case it is upscaled using anti aliasing which adds some noise. Do you think it makes sence to open the window by default pixel correct? This means the full screen cover will be smaller by default on HighDPI screens.

@Holzhaus
Copy link
Member

Is there no way to query the current scale factor from the application and then multiply it with the scale factor of the image, so that the actual size of the window on HighDPI screens stays the same?

If that is not possible, I'd probably keep the current behavior.

@daschuer
Copy link
Member

It is possible, but you cannot have both.

I think we need to be clear of the use case of the DlgCoverArtFullSize. I think we orignally have displayed it to see the cover at 100 % pixel correct. If you have a device pixel ratio of 2, the cover will appear smaller that it will appear on a ratio 1 screen.

If we just want to show the cover big, there is no reason to show a 50 x 50 cover smaller than a 500 x 500 cover.

Currently we are stuck in the middle. The HiDPI screes see a anti aliased blurry cover, with no easy way to see it pixel accurate. Covers with less resolution are still displayed small, but also blurry.

@@ -131,8 +132,9 @@ void DlgCoverArtFullSize::slotCoverFound(const QObject* pRequestor,
dialogSize.scale(availableScreenSpace.width(), dialogSize.height(),
Qt::KeepAspectRatio);
}
QPixmap resizedPixmap = m_pixmap.scaled(size(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is in line 125 above, where the logical dialogSize is taken form the native pixel size.
Can you try to use
QSize dialogSize = m_pixmap.size() / getDevicePixelRatioF(this);
there?

@daschuer
Copy link
Member

@Be-ing: The full size view of the cover is the last open thing. This should be pixel exact as discussed above. From this size the user can resize to any size.
Can you take care of it?

@ronso0
Copy link
Member

ronso0 commented Nov 17, 2019

@Be-ing Does this branch touch the right-click behaviour of the cover art widgets, or spinnies with cover? I'm trying to get the right-click ux sorted in #2359

@Be-ing
Copy link
Contributor Author

Be-ing commented Nov 24, 2019

In Mixxx 2.1, the cover art was displayed pixel exact without scaling on high DPI screens, so many covers were shown quite small. I prefer how it is here. I don't care that it isn't pixel exact and may have some minor aliasing artifacts. I did mind before when it was scaled twice and there were lots of artifacts.

@Be-ing
Copy link
Contributor Author

Be-ing commented Nov 24, 2019

The full size view of the cover is the last open thing.

I don't think there is any problem here, so ready for merge? @Holzhaus what do you think?

@uklotzde
Copy link
Contributor

LGTM. I rely on the opinion of those that are affected by scaling issues.

One option could be to scale the image up until at least one side of its dimensions is half the size of the corresponding screen dimension. Just an idea, does not need to be solved now.

@Be-ing
Copy link
Contributor Author

Be-ing commented Nov 24, 2019

I don't think there is a pressing need to make the scaling any more complicated. This is DJ software, not a raster graphics editor.

@uklotzde
Copy link
Contributor

uklotzde commented Nov 24, 2019

My remark was only intended to establish a sensible lower bound for the display to avoid diminishing small covers on HiDPI screens if you would decide to display the cover without scaling if possible. If we always apply a sensible scaling, then this distinction is not necessary.

@daschuer
Copy link
Member

OK so we can merge. Thank you for clarifications.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants