-
-
Notifications
You must be signed in to change notification settings - Fork 699
Fix the pixel shift within reduce (#703) #1592
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
Conversation
After a few hours of debugging I discovered when that small pixel shift occurs and I suspect what's causing that. Because this pixel shift only occurs when resizing to certain dimensions, I've started to refactor/simplify a number of things. I removed the round-to-nearest behavior with commit ff8d397 but for some reason the tests revealed that the resized image was the same with or without this change. When I continued to make some things simpler, I noticed something strange. I changed So, I did a sanity check and returned an error if the kernel radius is not a whole number It turned out that the target dimensions that caused small pixel shifts were now producing errors. 😅 For example: image = image.reducev(
1.5, VImage::option()->set("kernel", VIPS_KERNEL_LANCZOS3));
image = image.reduceh(
1.5, VImage::option()->set("kernel", VIPS_KERNEL_LANCZOS3)); Produces a small pixel shift since @jcupitt Do you have any ideas how to fix this? I don't think we can remove the |
This is a really good idea! Look at this thing (calculate coeffs for a cubic filter): https://github.com/libvips/libvips/blob/master/libvips/resample/templates.h#L346 We calculate the filter width (number of points to compute the filter over, eg. 17 for factor 4 shrink) like this: const int n_points = rint( 4 * shrink ) + 1; For shrink 4.3 (for example), it'll be Then the rint will throw away some information: a 4.3 shrink will have the same centre as a 4.0 shrink. Really, the fractional part of the shrink should be added to the x somehow (I think) so you get a displacement. Or perhaps this is included in the calculation of I'll try getting it to output some graphs. |
Here's a standalone test program. It's copy-pasted from master with minimal changes to make it compile and run. /* compile with
*
* gcc -g -Wall mask.c -lm
*/
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
typedef enum {
VIPS_KERNEL_NEAREST,
VIPS_KERNEL_LINEAR,
VIPS_KERNEL_CUBIC,
VIPS_KERNEL_MITCHELL,
VIPS_KERNEL_LANCZOS2,
VIPS_KERNEL_LANCZOS3,
VIPS_KERNEL_LAST
} VipsKernel;
#define VIPS_FABS( V ) __builtin_fabs( V )
#define g_assert_not_reached() abort()
/* Get n points. @shrink is the shrink factor, so 2 for a 50% reduction.
*/
static int
vips_reduce_get_points( VipsKernel kernel, double shrink )
{
switch( kernel ) {
case VIPS_KERNEL_NEAREST:
return( 1 );
case VIPS_KERNEL_LINEAR:
return( rint( 2 * shrink ) + 1 );
case VIPS_KERNEL_CUBIC:
case VIPS_KERNEL_MITCHELL:
return( rint( 4 * shrink ) + 1 );
case VIPS_KERNEL_LANCZOS2:
/* Needs to be in sync with calculate_coefficients_lanczos().
*/
return( rint( 2 * 2 * shrink ) + 1 );
case VIPS_KERNEL_LANCZOS3:
return( rint( 2 * 3 * shrink ) + 1 );
default:
g_assert_not_reached();
return( 0 );
}
}
/* Generate a cubic filter. See:
*
* Mitchell and Netravali, Reconstruction Filters in Computer Graphics
* Computer Graphics, Volume 22, Number 4, August 1988.
*
* B = 1, C = 0 - cubic B-spline
* B = 1/3, C = 1/3 - Mitchell
* B = 0, C = 1/2 - Catmull-Rom spline
*/
static void
calculate_coefficients_cubic( double *c,
const double shrink, const double x, double B, double C )
{
/* Needs to be in sync with vips_reduce_get_points().
*/
const int n_points = rint( 4 * shrink ) + 1;
int i;
double sum;
sum = 0;
for( i = 0; i < n_points; i++ ) {
const double xp = (i - (2 * shrink - 1) - x) / shrink;
const double axp = VIPS_FABS( xp );
const double axp2 = axp * axp;
const double axp3 = axp2 * axp;
double l;
if( axp <= 1 )
l = ((12 - 9 * B - 6 * C) * axp3 +
(-18 + 12 * B + 6 * C) * axp2 +
(6 - 2 * B)) / 6;
else if( axp <= 2 )
l = ((-B - 6 * C) * axp3 +
(6 * B + 30 * C) * axp2 +
(-12 * B - 48 * C) * axp +
(8 * B + 24 * C)) / 6;
else
l = 0.0;
c[i] = l;
sum += l;
}
for( i = 0; i < n_points; i++ )
c[i] /= sum;
}
/* Calculate a mask element.
*/
static void
vips_reduce_make_mask( double *c, VipsKernel kernel, double shrink, double x )
{
switch( kernel ) {
case VIPS_KERNEL_CUBIC:
/* Catmull-Rom.
*/
calculate_coefficients_cubic( c, shrink, x, 0.0, 0.5 );
break;
case VIPS_KERNEL_MITCHELL:
calculate_coefficients_cubic( c, shrink, x,
1.0 / 3.0, 1.0 / 3.0 );
break;
/* Removed non-cubic filters.
*/
default:
g_assert_not_reached();
break;
}
}
int
main( int argc, char **argv )
{
double shrink;
for( shrink = 1.0; shrink < 10; shrink += 0.1 ) {
int n_points;
double mask[100];
int i;
n_points = vips_reduce_get_points( VIPS_KERNEL_MITCHELL,
shrink );
if( n_points > 100 ) {
printf( "too large!\n" );
abort();
}
vips_reduce_make_mask( mask,
VIPS_KERNEL_MITCHELL, shrink, 0.5 );
printf( "%g", shrink );
for( i = 0; i < n_points; i++ )
printf( ", %g", mask[i] );
printf( "\n" );
}
return( 0 );
} It generates the mask for x == 0.5 for Mitchell for every shrink factor from 1 to 10 in 0.1 steps. Here's the mask for shrink == 10 to show the shape we expect: Features:
Now have a look at shrink == 1.4 The peak is not central -- we've missed by ~0.5 pixels or so. I think this could be at least part of your shift. It's not symmetrical either, but that's because of the extra shift we've incorporated. That's actually one of the better ones -- the error in mask size calculation makes the shift even worse in some cases. I'll make a PR that fixes up mask generation, since this is a simple and clear error, then let's look at shifts on rotate again. |
Actually, it's really easy, here's a fixed up version of that mask.c: /* compile with
*
* gcc -g -Wall mask.c -lm
*
* run with
*
* gcc -g -Wall mask.c -lm && ./a.out > masks.csv && gnumeric masks.csv
*/
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#define VIPS_FABS( V ) __builtin_fabs( V )
#define VIPS_MAX( A, B ) ((A) > (B) ? (A) : (B))
#define VIPS_PI (3.14159265358979323846)
#define g_assert_not_reached() abort()
typedef enum {
VIPS_KERNEL_NEAREST,
VIPS_KERNEL_LINEAR,
VIPS_KERNEL_CUBIC,
VIPS_KERNEL_MITCHELL,
VIPS_KERNEL_LANCZOS2,
VIPS_KERNEL_LANCZOS3,
VIPS_KERNEL_LAST
} VipsKernel;
/* Get n points. @shrink is the shrink factor, so 2 for a 50% reduction.
*/
static int
vips_reduce_get_points( VipsKernel kernel, double shrink )
{
switch( kernel ) {
case VIPS_KERNEL_NEAREST:
return( 1 );
case VIPS_KERNEL_LINEAR:
return( 2 * rint( shrink ) + 1 );
case VIPS_KERNEL_CUBIC:
case VIPS_KERNEL_MITCHELL:
return( 2 * rint( 2 * shrink ) + 1 );
case VIPS_KERNEL_LANCZOS2:
return( 2 * rint( 2 * shrink ) + 1 );
case VIPS_KERNEL_LANCZOS3:
return( 2 * rint( 3 * shrink ) + 1 );
default:
g_assert_not_reached();
return( 0 );
}
}
static void
calculate_coefficients_triangle( double *c,
const double shrink, const double x )
{
/* Needs to be in sync with vips_reduce_get_points().
*/
const int n_points = 2 * rint( shrink ) + 1;
const double half = x + n_points / 2.0 - 1;
int i;
double sum;
sum = 0;
for( i = 0; i < n_points; i++ ) {
double xp = (i - half) / shrink;
double l;
l = 1.0 - VIPS_FABS( xp );
l = VIPS_MAX( 0.0, l );
c[i] = l;
sum += l;
}
for( i = 0; i < n_points; i++ )
c[i] /= sum;
}
static void
calculate_coefficients_cubic( double *c,
const double shrink, const double x, double B, double C )
{
/* Needs to be in sync with vips_reduce_get_points().
*/
const int n_points = 2 * rint( 2 * shrink ) + 1;
const double half = x + n_points / 2.0 - 1;
int i;
double sum;
sum = 0;
for( i = 0; i < n_points; i++ ) {
const double xp = (i - half) / shrink;
const double axp = VIPS_FABS( xp );
const double axp2 = axp * axp;
const double axp3 = axp2 * axp;
double l;
if( axp <= 1 )
l = ((12 - 9 * B - 6 * C) * axp3 +
(-18 + 12 * B + 6 * C) * axp2 +
(6 - 2 * B)) / 6;
else if( axp <= 2 )
l = ((-B - 6 * C) * axp3 +
(6 * B + 30 * C) * axp2 +
(-12 * B - 48 * C) * axp +
(8 * B + 24 * C)) / 6;
else
l = 0.0;
c[i] = l;
sum += l;
}
for( i = 0; i < n_points; i++ )
c[i] /= sum;
}
static void
calculate_coefficients_lanczos( double *c,
const int a, const double shrink, const double x )
{
/* Needs to be in sync with vips_reduce_get_points().
*/
const int n_points = 2 * rint( a * shrink ) + 1;
const double half = x + n_points / 2.0 - 1;
int i;
double sum;
sum = 0;
for( i = 0; i < n_points; i++ ) {
double xp = (i - half) / shrink;
double l;
if( xp == 0.0 )
l = 1.0;
else if( xp < -a )
l = 0.0;
else if( xp > a )
l = 0.0;
else
l = (double) a * sin( VIPS_PI * xp ) *
sin( VIPS_PI * xp / (double) a ) /
(VIPS_PI * VIPS_PI * xp * xp);
//printf( "%d, %g, %g\n", i, xp, l );
c[i] = l;
sum += l;
}
for( i = 0; i < n_points; i++ )
c[i] /= sum;
}
static void
vips_reduce_make_mask( double *c, VipsKernel kernel, double shrink, double x )
{
switch( kernel ) {
case VIPS_KERNEL_NEAREST:
c[0] = 1.0;
break;
case VIPS_KERNEL_LINEAR:
calculate_coefficients_triangle( c, shrink, x );
break;
case VIPS_KERNEL_CUBIC:
/* Catmull-Rom.
*/
calculate_coefficients_cubic( c, shrink, x, 0.0, 0.5 );
break;
case VIPS_KERNEL_MITCHELL:
calculate_coefficients_cubic( c, shrink, x,
1.0 / 3.0, 1.0 / 3.0 );
break;
case VIPS_KERNEL_LANCZOS2:
calculate_coefficients_lanczos( c, 2, shrink, x );
break;
case VIPS_KERNEL_LANCZOS3:
calculate_coefficients_lanczos( c, 3, shrink, x );
break;
default:
g_assert_not_reached();
break;
}
}
int
main( int argc, char **argv )
{
//const VipsKernel kernel = VIPS_KERNEL_LINEAR;
//const VipsKernel kernel = VIPS_KERNEL_MITCHELL;
//const VipsKernel kernel = VIPS_KERNEL_LANCZOS2;
const VipsKernel kernel = VIPS_KERNEL_LANCZOS3;
double shrink;
double mask[100];
int i;
for( shrink = 1.0; shrink < 10; shrink += 0.1 ) {
const int n_points = vips_reduce_get_points( kernel, shrink );
if( n_points > 100 ) {
printf( "too large!\n" );
abort();
}
vips_reduce_make_mask( mask, kernel, shrink, 0.5 );
printf( "%g", shrink );
for( i = 0; i < n_points; i++ )
printf( ", %g", mask[i] );
printf( "\n" );
}
return( 0 );
} So this has improved mask size calculation, and improved positioning of the peak. It seems to generate the correct mask for all cases. The two changes are:
Mask size is always calculated with this pattern: And something to get the peak in the correct place:
ie. we offset the mask by a amount based on the I'll make a PR with this change. |
We were seeing small displacements at some shrink factors because of rounding in the mask size calcualation. This PR takes the rounding into account when positioning the mask. See #1592 (comment)
OK, PR with fixed up kernel calculation. If you rebase on top of that, it might fix some of the rotation shifts you are seeing. |
Thanks! I've rebased this PR on top of the fwiw, the pixel shifts now seems to apply to both portrait and landscape images, so we might be on the right track. Here are the results before the rebase: Could issue #1512 also affect these small pixel shifts? |
I had a quick go at testing if #1512 could also affect this by commenting out the shrink part from Detailsdiff --git a/libvips/resample/resize.c b/libvips/resample/resize.c
index 1111111..2222222 100644
--- a/libvips/resample/resize.c
+++ b/libvips/resample/resize.c
@@ -194,8 +194,8 @@ vips_resize_build( VipsObject *object )
/* The int part of our scale.
*/
- int_hshrink = vips_resize_int_shrink( resize, hscale );
- int_vshrink = vips_resize_int_shrink( resize, vscale );
+ /*int_hshrink = vips_resize_int_shrink( resize, hscale );
+ int_vshrink = vips_resize_int_shrink( resize, vscale );*/
/* Unpack for processing.
*/
@@ -203,7 +203,7 @@ vips_resize_build( VipsObject *object )
return( -1 );
in = t[5];
- if( int_vshrink > 1 ) {
+ /*if( int_vshrink > 1 ) {
g_info( "shrinkv by %d", int_vshrink );
if( vips_shrinkv( in, &t[0], int_vshrink, NULL ) )
return( -1 );
@@ -219,7 +219,7 @@ vips_resize_build( VipsObject *object )
in = t[1];
hscale *= int_hshrink;
- }
+ }*/
/* Don't let either axis drop below 1 px.
*/ It seems that this only affects the landscape images, see: |
Yes, I think you're right. Block shrink will round to nearest, so it can add or remove up to 0.5 pixels from the output image. These added or removed pixels will always be along the right or bottom edge, so you'll get a small displacement. Eg. the /* We need new pixels at the right so that we don't have small chunks
* to average down the right edge.
*/
if( vips_embed( in, &t[1],
0, 0,
in->Xsize + shrink->hshrink, in->Ysize,
"extend", VIPS_EXTEND_COPY,
NULL ) )
return( -1 ); Instead of Shall I make a PR? |
Feel free to create a new PR. BTW, do you know how to properly test this commit?: I get exactly the same image with or without that change. It looks like this behavior was introduced in 7fd672f, but the removed TODO-items suggests that it only applies to the upscale path (i.e.
|
Actually, thinking about it again, this will make the pixels at the top-left of an image shift about as you adjust the parameters to |
I started adding a centre option to shrink, then realized there would need to be something similar on JPEG shrink-on-load as well. Rather than adding a centre option to all these operators, it's probably better if the top-level driver program ( The code I wrote for /* Output size. We need to always round to nearest, so round(), not
* rint().
*/
width = VIPS_ROUND_UINT(
(double) resample->in->Xsize / shrink->hshrink );
/* We need new pixels around the edges so that we don't have to
* average half-pixels anywhere.
*
* In centre mode, we add pixels around all the edges, otherwise, only
* to the right.
*/
if( shrinkh->centre ) {
/* How many pixels we are inventing in the input, -ve for
* discarding.
*/
int extra_pixels =
width * shrink->hshrink - resample->in->Xsize;
/* If we are rounding down, we are not using some input
* pixels. We need to move the origin *inside* the input image
* by half that distance so that we discard pixels equally
* from left and right.
*/
left = (1 + extra_pixels) / 2;
}
else
left = 0; |
Great! Isn't it possible to centre by default? This matches the centre option in resize (which is deprecated now). For reference, here's the output with the test image from libvips/pyvips#148 before and after this PR (at the time of writing). DetailsVIPS shrink()
No changes. We still need to fix #1512. Here's the Pillow image for reference. VIPS reduce()
The 0.5 pixel shift seems to be gone and the vector patch introduces some additional moire (issue #1518). Here's the Pillow image for reference (if compared to libvips, there still seems to be a tiny pixel shift). VIPS resize()
If compared to Pillow, the bottom red border is more intensive (this could be due to different edges handling and is probably OK). However, the right border is gone (which could happen as a result of the above issues). |
We were seeing small displacements at some shrink factors because of rounding in the mask size calcualation. This PR takes the rounding into account when positioning the mask. See libvips#1592 (comment) (cherry picked from commit d64385a)
I had a look at your reference implementation and wondered if the I did some Details$ seq 2 20 | xargs -I{} vips shrinkv input/Landscape_1.jpg output-patch/lanczos3/Landscape_1.jpg[strip,Q=85] {}
vshrink: 2, top: 0.500000
vshrink: 3, top: 0.500000
vshrink: 4, top: 0.500000
vshrink: 5, top: 0.500000
vshrink: 6, top: 0.500000
vshrink: 7, top: -1.000000
vshrink: 8, top: 0.500000
vshrink: 9, top: -1.000000
vshrink: 10, top: 0.500000
vshrink: 11, top: 0.000000
vshrink: 12, top: 0.500000
vshrink: 13, top: -1.500000
vshrink: 14, top: 2.500000
vshrink: 15, top: 0.500000
vshrink: 16, top: 0.500000
vshrink: 17, top: 4.000000
vshrink: 18, top: 3.500000
vshrink: 19, top: -1.000000
vshrink: 20, top: 0.500000
$ seq 2 20 | xargs -I{} vips shrinkh input/Landscape_1.jpg output-patch/lanczos3/Landscape_1.jpg[strip,Q=85] {}
hshrink: 2, left: 0.500000
hshrink: 3, left: 0.500000
hshrink: 4, left: 0.500000
hshrink: 5, left: 0.500000
hshrink: 6, left: 0.500000
hshrink: 7, left: 0.000000
hshrink: 8, left: 0.500000
hshrink: 9, left: 0.500000
hshrink: 10, left: 0.500000
hshrink: 11, left: 2.500000
hshrink: 12, left: 0.500000
hshrink: 13, left: -2.500000
hshrink: 14, left: 3.500000
hshrink: 15, left: 0.500000
hshrink: 16, left: 4.500000
hshrink: 17, left: 1.500000
hshrink: 18, left: 0.500000
hshrink: 19, left: 3.000000
hshrink: 20, left: 0.500000 So there's usually a fractional part present. |
Yes, exactly, reduce gets around this by displacing the mask by the opposite fractional amount, but shrink can't do this sub-pixel movement, of course. JPEG shrink-on-load will have the same problem, but even worse. I think the best solution is probably to accumulate the errors from the three stages (shrink on load always rounds down, block shrink rounds to nearest, reduce rounds to nearest) and do a final fractional displacement to correct all the errors as part of reduce. What do you think? You're right that #1512 can be fixed at the same time. |
We were seeing small displacements at some shrink factors because of rounding in the mask size calcualation. This PR takes the rounding into account when positioning the mask. See libvips#1592 (comment) (cherry picked from commit d64385a)
Portrait images seems to be fixed.
`nearest_Portrait.webp` seems to be fixed as well.
Sounds good! I had a go with implementing a fractional displacement within reduce, see: This seems to fix all the portrait images on the Does not produce any pixel shifts. 🎉 I've a look to the rounding errors, feel free to commit on this branch as well. |
All pixel shifts are gone!
/me falls off his chair |
Yeah, I had to check it twice because I couldn't believe it. 😅 All pixel shifts are gone with commit 793f90b (which is squashed within f51336c). The output can be viewed here: There's still a Travis error within the test suite that needs to be investigated, but otherwise this is ready for reviewing. Should I remove the draft status? Note that this PR does not fully resolve libvips/pyvips#148, this will be addressed within the remaining issues (#1512 and #1518). |
fwiw, I also checked whether shrink-on-load for JPEG images could lead to pixel shifts, see: kleisauke/vips-issue-703@af4e70b It does not seem to cause any problems with these test images and target dimensions (before / after). I'll also have a look at WebP, PDF and SVG images, perhaps one of them needs an overall shift. |
By not calling vips_vector_to_fixed_point repeatedly.
In line with reduceh.
In line with reducev.
It seems that it generates the same image, with or without this change. Tested with https://github.com/kleisauke/vips-issue-703.
OK, test suite failure is now fixed. I also cleaned up the commits a bit. This is now ready for reviewing. |
Found by UBSan.
Wow, I've read the whole thing as carefully as I can. I couldn't see anything wrong and I now have a terrible headache. What a great job you've done here Kleis! Let's merge and do some wider testing. |
Amazing work, thanks Kleis! As soon as we can meet in-person then beer/coffee/tea is on me to thank you for this and everything else. (I was supposed to be in the Netherlands next week but as you can imagine that's no longer happening.) |
See: #703 and libvips/pyvips#148 for context. We might need to notify some people (for e.g. #659) after this is done.
Marked this PR as draft since it still produces a small pixel shift (withinreducev
and perhaps also inreduceh
) that I can't quite figure out (it might be a rounding error). See this testcase:reducev testcase
Output:https://github.com/kleisauke/vips-issue-703/raw/master/output-patch/Landscape-vertical.webp