Skip to content

Switch to QImage scaling when downscaling as QPainter is awful#1214

Open
j-b-m wants to merge 2 commits intomltframework:masterfrom
j-b-m:work/qtblend-scaling
Open

Switch to QImage scaling when downscaling as QPainter is awful#1214
j-b-m wants to merge 2 commits intomltframework:masterfrom
j-b-m:work/qtblend-scaling

Conversation

@j-b-m
Copy link
Copy Markdown
Contributor

@j-b-m j-b-m commented Mar 27, 2026

Seems like a Qt6 regression, but QPainter scaling gives terrible results when downscaling. So in such cases, scale using QImage before painting. Kdenlive issue 2123

Comparison of results (updated QImage scaling on top)
scaling-comparison

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adjusts Qt blending downscaling to avoid poor-quality QPainter scaling (Qt6 regression), by pre-scaling via QImage when reducing image size.

Changes:

  • Detects rescale quality preference via consumer.rescale and toggles HQ painting accordingly.
  • Pre-scales the source QImage when downscaling (instead of applying a QPainter scale transform).
  • Makes QPainter render hints conditional on the chosen interpolation mode and draws either the scaled or original image.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 219 to +246
@@ -219,7 +240,17 @@ static int filter_get_image(mlt_frame frame,
}
// Center image in rect
transform.translate((rect.w - (b_width * scale)) / 2.0, (rect.h - (b_height * scale)) / 2.0);
transform.scale(scale, scale);
if (scale != 1.) {
if (downscale) {
scaled = true;
scaledSource = sourceImage.scaled(sourceImage.size() * scale,
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

downscale is computed once as rect.w < b_width || rect.h < b_height, but in the non-distort branch the actual scale factor is influenced by b_ar (scale = rect.h / b_height * b_ar). That can produce scale < 1 even when rect.w >= b_width and rect.h >= b_height, causing the code to fall back to QPainter downscaling (the case this PR is trying to avoid). Consider computing the downscale decision from the computed scale (or the final target QSize) inside each branch instead of using rect vs b_* upfront.

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +228
scaledSource = sourceImage.scaled(rect.w,
rect.h,
Qt::IgnoreAspectRatio,
hqPainting ? Qt::SmoothTransformation
: Qt::FastTransformation);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

QImage::scaled() takes integer dimensions; here rect.w/rect.h are doubles and will be implicitly truncated when passed as width/height. If rect is fractional or very small (< 1px), this can introduce 1px jitter or even produce a null (0x0) scaled image. Consider explicitly rounding (e.g., qRound) and clamping to a minimum of 1 for the target size before calling scaled().

Copilot uses AI. Check for mistakes.
Comment on lines 242 to +249
transform.translate((rect.w - (b_width * scale)) / 2.0, (rect.h - (b_height * scale)) / 2.0);
transform.scale(scale, scale);
if (scale != 1.) {
if (downscale) {
scaled = true;
scaledSource = sourceImage.scaled(sourceImage.size() * scale,
Qt::IgnoreAspectRatio,
hqPainting ? Qt::SmoothTransformation
: Qt::FastTransformation);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

In the non-distort downscale path, scaledSource is created with sourceImage.size() * scale (integer-rounded internally), but the centering translation uses (rect.w - (b_width * scale)) / 2.0 and (rect.h - (b_height * scale)) / 2.0 based on the unrounded float math. This can lead to off-by-one centering/cropping. Consider basing the translation on scaledSource.width()/height() (or compute a single rounded target size used for both scaling and translation).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@bmatherly bmatherly left a comment

Choose a reason for hiding this comment

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

The artifacts kind of look like the operation is not working on pixels that have some transparency (around the border of the logos). I did not recreate the problem myself. Does it only happen on images with transparency? Or does it still occur if the image background is fully opaque? I'm just wondering if there is some parameter we need to pass to the painter to make sure it handles transparency (alpha) correctly.


char *interps = mlt_properties_get(frame_properties, "consumer.rescale");
if (interps) {
interps = strdup(interps);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you explain why you copy the string? I think you can use it directly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Using strdup on this string property is a common thing in our source code that dates way back. The property getter returns a pointer, and after the call the string it points to can be changed including NULLed, leading to comparison over-reads or crashes. I think this is more of an issue for a string property on a service getting changed by an application, but these are checking a frame property set by mlt_consumer that is not going to be mutated by another thread.

This technique goes back to a bug fix in 9a19d7b. The bug report is not available, and there are other fixes mixed in with that commit. So, hard to say what was the actual fix. Maybe this was the ultimate fix, and something later actually did.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This technique goes back to a bug fix in 9a19d7b

Thanks for the reference. I suppose the author (or the AI they used) just followed other examples for interp.

The property getter returns a pointer, and after the call the string it points to can be changed including NULLed, leading to comparison over-reads or crashes.

While that is a true statement, this strdup() trick doesn't fix that because the pointer could still be changed after the get() call and before the strdup call. So this trick only reduces the probability. If data being changed is a concern, then the service should be locked.

I would prefer that we not propagate this unnecessary strdup and start setting a good example for future authors. But I can appreciate that it is following an established convention.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, I just copied code used in the qtblend transition that was initially based on the affine transition where this is still used. But sure, I will clean that up

@ddennedy
Copy link
Copy Markdown
Member

does it still occur if the image background is fully opaque?

Looking at the images attached here, even the lines inside the opaque areas have very bad aliasing suggesting no interpolation.

@ddennedy
Copy link
Copy Markdown
Member

JB, are the images you provided scaled-up for emphasis?

In the docs, both QPainter and QImage say the hints provided both do bilinear interpolation:
https://doc.qt.io/qt-6/qpainter.html#RenderHint-enum
https://doc.qt.io/qt-6/qt.html#TransformationMode-enum

However, Claude suggested this difference:

The Qt docs description is misleading/simplified. Despite the "bilinear filtering" wording, Qt's internal implementation of QImage::scaled() with SmoothTransformation uses an area-averaging algorithm (qSmoothScaleImageqImgScaleAARGBA in Qt's source), not just a bilinear 4-sample lookup.

The meaningful difference is:

Method Downscaling algorithm
QPainter with SmoothPixmapTransform Bilinear: maps each output pixel back to the source and samples only the 4 nearest source pixels
QImage::scaled(SmoothTransformation) Area-weighted: averages all source pixels that contribute to each output pixel, weighted by their coverage

For a 4× downscale, bilinear reads 4 out of ~16 contributing source pixels; the area-average reads all ~16. The Qt docs use "bilinear" as a loose synonym for "smooth/filtered" vs "nearest-neighbor" — not as a precise description of the algorithm.

So the fix is correct: pre-scaling via QImage::scaled(Qt::SmoothTransformation) produces proper area-averaged results, while relying on QPainter's transform for downscaling only ever bicubic/bilinearly-samples from the original high-res source, causing aliasing.

@bmatherly
Copy link
Copy Markdown
Member

does it still occur if the image background is fully opaque?

Looking at the images attached here, even the lines inside the opaque areas have very bad aliasing suggesting no interpolation.

Oh yeah. I see what you mean.

@j-b-m
Copy link
Copy Markdown
Contributor Author

j-b-m commented Apr 2, 2026

As Dan said, this is unrelated to alpha.. but yes the images attached above were scaled to better show the difference.
Here is an unscaled comparison, reducing an image (red S on a white background) with an original height of 4000 to a height of 270. Using QPainter on the left, QImage on the right.
qpainter-vs-qimage-4000px-to-270px

The quality loss is proportional to the scaling applied in the effect. We probably need to also apply the same trick to the qtblend transition.
I will try to cleanup a bit this MR in the next days.

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.

4 participants