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

cgifsave per frame cmap #2445

Merged
merged 25 commits into from
Oct 14, 2021
Merged

cgifsave per frame cmap #2445

merged 25 commits into from
Oct 14, 2021

Conversation

jcupitt
Copy link
Member

@jcupitt jcupitt commented Sep 24, 2021

This patch makes the GIF a frame at a time, and only redoes the palette optimisation if the new frame is significantly different from the frame it last ran the optimisation on.

It detects differences by simply summing pixels and checking of the sum has changed by more than 20%. I've no idea if this is good enough, we'll need some testing.

Pros:

  • Lower memory use, esp. with huge GIFs, since it doesn't need to keep the whole image in memory
  • The write part (palette, dither, compress) runs in a background thread, so it can overlap load and save
  • It only reoptimises the palette occasionally, which helps performance too

Cons:

  • Is the change detector good enough?
  • GIFs are larger, since we don't use a global colourmap, but a local one for each frame

Benchmark with master:

$ /usr/bin/time -f %M:%e vips copy 3198.gif[n=-1] x.gif
94332:1.45

And with this branch:

$ /usr/bin/time -f %M:%e vips copy 3198.gif[n=-1] x.gif
49376:1.11

@lovell any thoughts?

And the same test with 8.11, just to see how far we've come:

$ /usr/bin/time -f %M:%e vips copy 3198.gif[n=-1] x.gif
263656:6.39

@jcupitt
Copy link
Member Author

jcupitt commented Sep 24, 2021

We could maybe set the first frame palette as the global colourmap, and only start using local cmaps if we find we need a new one.

The most common case is probably all frames pretty similar, so we'd often get a single cmap per file, as before.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 24, 2021

Regarding local vs global cmaps, I see:

-rw-r--r-- 1 john john 4044290 Sep 24 11:28 master.gif
-rw-r--r-- 1 john john 4248068 Sep 24 11:36 x-branch.gif
-rw-r--r-- 1 john john 4271972 Sep 24 11:23 8.11.gif

So it's ~5% worse than master because of this cmap difference, and 8.11 is 1% worse again, perhaps because of LZW effort differences.

Start using local cmaps only if we need to make a new palette
@jcupitt
Copy link
Member Author

jcupitt commented Sep 24, 2021

... I pushed something to use a global cmap for the start of processing, and only flip to local if we have to.

The change detection isn't working very well, and it seems to leave transparent holes in some GIFs. I think this needs some work still :(

This GIF ends up with holes:

vanity

@jcupitt jcupitt changed the title Cgifsave per frame cmap WIP -- cgifsave per frame cmap Sep 24, 2021
@kleisauke
Copy link
Member

Here's another test GIF that seems to produce incorrect output after processing:
waves

This is probably because the transparency index of the image is set incorrectly.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 24, 2021

Yes, cgif likes index 0 to be transparent, but I don't think libimagequant knows that. I'll have a look.

and drop the change thresh to 1%
@jcupitt
Copy link
Member Author

jcupitt commented Sep 24, 2021

I fixed the issue with transparency and dropped the change threshold to 1%, it seems to work much better now.

@jcupitt jcupitt changed the title WIP -- cgifsave per frame cmap cgifsave per frame cmap Sep 24, 2021
@kleisauke
Copy link
Member

I noticed a case where this PR could introduce noticeable artifacts.

$ curl https://user-images.githubusercontent.com/12746591/134692488-e830184c-d801-4c29-9582-23786b1612f3.gif -o waves.gif
$ vips gaussblur waves.gif[n=-1] x.gif 10
Details

master:
x

cgifsave-per-frame-cmap:
x2

Presumably this is due to the use of local palettes or the threshold of the change detector.

@lovell
Copy link
Member

lovell commented Sep 25, 2021

Here's an example, the infamous 301 frame sample.gif, where the change detector isn't sensitive enough:

Before:

sample

After:

out

(This page is now a good test for CPU fans.)

@kleisauke
Copy link
Member

I tried to improve the initialization of attrFlags and genFlags with commit kleisauke@52501b3. While doing that I noticed 2 things in the cgif api:

  • It looks like the st_gifconfig.genFlags field is currently unused in cgif.
  • The global transparency flag (CGIF_ATTR_HAS_TRANSPARENCY) seems to be used only in cgif_addframe, so maybe that should be converted to a frame attribute (e.g. CGIF_FRAME_ATTR_HAS_TRANSPARENCY)? But perhaps this is by design to avoid complexity.

/cc @dloebl (sorry for the ping)

@jcupitt
Copy link
Member Author

jcupitt commented Sep 26, 2021

Wow, what a terrible GIF!

I added a DEBUG_PERCENT define and experimented with different values for the threshold. With 0% as the threshold (!!!) I see:

$ vips copy sample.gif[n=-1] x.gif
frame 0, 32.92% change, new 2 item colourmap
frame 20, 0.03862% change, new 44 item colourmap
frame 21, 0.2159% change, new 46 item colourmap
frame 22, 0.304% change, new 46 item colourmap
frame 23, 0.3596% change, new 47 item colourmap
frame 24, 0.3465% change, new 48 item colourmap
frame 25, 0.3237% change, new 46 item colourmap
frame 26, 0.324% change, new 49 item colourmap
frame 27, 0.297% change, new 47 item colourmap
frame 28, 0.3049% change, new 49 item colourmap
frame 29, 0.2666% change, new 50 item colourmap
frame 30, 0.2838% change, new 52 item colourmap
frame 31, 0.2687% change, new 53 item colourmap
frame 32, 0.2754% change, new 55 item colourmap
frame 33, 0.2587% change, new 52 item colourmap
frame 34, 0.2656% change, new 54 item colourmap
frame 35, 0.2312% change, new 56 item colourmap
frame 36, 0.1949% change, new 56 item colourmap
frame 37, 0.1292% change, new 57 item colourmap
frame 38, 0.0906% change, new 55 item colourmap
frame 39, 0.02581% change, new 54 item colourmap
frame 40, 0.006348% change, new 55 item colourmap
frame 41, 0.007392% change, new 54 item colourmap
frame 42, 0.00765% change, new 53 item colourmap
frame 43, 0.008321% change, new 55 item colourmap
frame 44, 0.00749% change, new 53 item colourmap
frame 45, 0.005529% change, new 55 item colourmap
frame 46, 0.008713% change, new 54 item colourmap
frame 47, 0.004312% change, new 55 item colourmap
frame 48, 0.0001019% change, new 55 item colourmap
frame 49, 0.001183% change, new 55 item colourmap
frame 50, 0.001708% change, new 55 item colourmap
frame 51, 0.0009327% change, new 54 item colourmap
frame 52, 0.0006004% change, new 55 item colourmap
frame 53, 0.0004685% change, new 54 item colourmap
frame 54, 0.0009247% change, new 55 item colourmap
frame 55, 0.0008867% change, new 54 item colourmap
frame 56, 0.0004751% change, new 55 item colourmap
frame 57, 0.0005171% change, new 54 item colourmap
frame 58, 0.0008545% change, new 55 item colourmap
frame 59, 0.0009071% change, new 54 item colourmap
frame 60, 0.0004941% change, new 55 item colourmap
frame 61, 0.0004751% change, new 54 item colourmap
frame 62, 0.0008629% change, new 55 item colourmap
frame 63, 0.0008356% change, new 54 item colourmap
frame 64, 0.0004685% change, new 55 item colourmap
frame 65, 0.0004996% change, new 54 item colourmap
frame 66, 0.0007954% change, new 55 item colourmap
frame 67, 0.0007899% change, new 54 item colourmap
frame 68, 0.0004616% change, new 55 item colourmap
frame 69, 0.0004389% change, new 54 item colourmap
frame 70, 0.0008359% change, new 55 item colourmap
frame 71, 0.0008286% change, new 54 item colourmap
frame 72, 0.0004441% change, new 55 item colourmap
frame 73, 0.0005036% change, new 54 item colourmap
frame 74, 0.0007859% change, new 55 item colourmap
frame 75, 0.0007764% change, new 54 item colourmap
frame 76, 0.0004616% change, new 55 item colourmap
frame 77, 0.0004214% change, new 54 item colourmap
frame 78, 0.0008279% change, new 55 item colourmap
frame 79, 0.000814% change, new 54 item colourmap
frame 80, 0.0004401% change, new 55 item colourmap
frame 81, 0.000486% change, new 54 item colourmap
frame 82, 0.0007779% change, new 55 item colourmap
frame 83, 0.0007549% change, new 54 item colourmap
frame 84, 0.0004576% change, new 55 item colourmap
frame 85, 0.0004539% change, new 54 item colourmap
frame 86, 0.006491% change, new 54 item colourmap
frame 87, 0.04426% change, new 53 item colourmap
frame 88, 0.0757% change, new 54 item colourmap
frame 89, 0.1202% change, new 53 item colourmap
frame 90, 0.1653% change, new 54 item colourmap
frame 91, 0.2094% change, new 53 item colourmap
frame 92, 0.2448% change, new 53 item colourmap
frame 93, 0.2837% change, new 52 item colourmap
frame 94, 0.3332% change, new 53 item colourmap
frame 95, 0.3187% change, new 52 item colourmap
frame 96, 0.3127% change, new 54 item colourmap
frame 97, 0.3055% change, new 51 item colourmap
frame 98, 0.2943% change, new 52 item colourmap
frame 99, 0.3011% change, new 52 item colourmap
frame 100, 0.3019% change, new 49 item colourmap
frame 101, 0.3086% change, new 47 item colourmap
frame 102, 0.3058% change, new 47 item colourmap
frame 103, 0.3094% change, new 47 item colourmap
frame 104, 0.2978% change, new 49 item colourmap
frame 105, 0.2135% change, new 47 item colourmap
frame 106, 0.05683% change, new 43 item colourmap
frame 244, 2.88e-05% change, new 18 item colourmap
301 frames
89 cmaps

So it's still able to reuse the previous map 2/3rds of the time. Here's the new image:

x

@jcupitt
Copy link
Member Author

jcupitt commented Sep 26, 2021

I tried to improve the initialization of attrFlags and genFlags with commit kleisauke@52501b3.

Cool! I'll just run some benchmarks, then merge this.

* It looks like the [`st_gifconfig.genFlags`](https://github.com/dloebl/cgif/blob/38535cb4d4356b999c9223b4228b6247467e9742/cgif.h#L40) field is currently unused in cgif.

Yes, I noticed that. I used the global config genflags field to hold frame gen options I didn't want to vary frame to frame.

* The global transparency flag (`CGIF_ATTR_HAS_TRANSPARENCY`) seems to be used only in [`cgif_addframe`](https://github.com/dloebl/cgif/blob/38535cb4d4356b999c9223b4228b6247467e9742/cgif.c#L491), so maybe that should be converted to a frame attribute (e.g. `CGIF_FRAME_ATTR_HAS_TRANSPARENCY`)? But perhaps this is by design to avoid complexity.

Ah I hadn't seen that.

I found the names a little confusing --- it's not clear from reading the header which define is part of which flag. Perhaps the genFlags should be called CGIF_GEN_ rather than CGIF_FRAME_GEN_, and maybe CGIF_FRAME_ATTR_USE_LOCAL_TABLE should be CGIF_ATTR_USE_LOCAL_TABLE?

@jcupitt
Copy link
Member Author

jcupitt commented Sep 26, 2021

Benchmarks, after Kleis's fixes:

$ /usr/bin/time -f %M:%e vips copy sample.gif[n=-1] x.per-frame-cmap.gif
146456:24.37
$ /usr/bin/time -f %M:%e vips copy sample.gif[n=-1] x.master.gif
1777212:12.97
$ /usr/bin/time -f %M:%e vips copy sample.gif[n=-1] x.8.11.gif
3645856:48.93
$ ls -l sample.gif x.*
-rw-rw-r-- 1 john john  3449741 Sep 26 11:28 sample.gif
-rw-rw-r-- 1 john john 10197811 Sep 26 12:29 x.8.11.gif
-rw-r--r-- 1 john john  3355980 Sep 26 12:22 x.master.gif
-rw-r--r-- 1 john john 10123367 Sep 26 12:13 x.per-frame-cmap.gif

So this branch is half the speed of master on this GIF, but uses less memory. As you suggested Lovell, perhaps we can use lower quality cmaps, since we are optimising per frame rather than for the whole file?

The file size has really shot up, I'm not sure why. That 3x increase can't just be the extra cmaps.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 26, 2021

Another benchmark:

$ /usr/bin/time -f %M:%e vips copy 3198.gif[n=-1] x.per-frame-cmap.gif
139 frames
139 cmaps
48532:1.12
$ /usr/bin/time -f %M:%e vips copy 3198.gif[n=-1] x.master.gif
94292:1.37
$ ls -l x.*.gif
-rw-r--r-- 1 john john  4044290 Sep 26 12:57 x.master.gif
-rw-r--r-- 1 john john  4271900 Sep 26 12:59 x.per-frame-cmap.gif

This is a short video clip, so there's a new cmap per frame, but it's still quicker, I expect because the GIF write is done in the background. File size seems somewhat higher, as expected,

@kleisauke
Copy link
Member

I could fix the artifacts mentioned in comment #2445 (comment) with commit kleisauke@d10a879.

Before After
before after

@kleisauke
Copy link
Member

I wasn't sure why that fixed it, I didn't debug it further. It was found by accident, when I was looking at how gifski uses the libimagequant API. See for example commit ImageOptim/gifski@a3fb2ff.

(It also uses the interesting liq_image_set_background and liq_image_set_importance_map functions, but the liq_histogram_* API doesn't seem to be used by any public repo on GitHub)

@dloebl
Copy link
Contributor

dloebl commented Sep 26, 2021

I tried to improve the initialization of attrFlags and genFlags with commit kleisauke@52501b3. While doing that I noticed 2 things in the cgif api:

* It looks like the [`st_gifconfig.genFlags`](https://github.com/dloebl/cgif/blob/38535cb4d4356b999c9223b4228b6247467e9742/cgif.h#L40) field is currently unused in cgif.

* The global transparency flag (`CGIF_ATTR_HAS_TRANSPARENCY`) seems to be used only in [`cgif_addframe`](https://github.com/dloebl/cgif/blob/38535cb4d4356b999c9223b4228b6247467e9742/cgif.c#L491), so maybe that should be converted to a frame attribute (e.g. `CGIF_FRAME_ATTR_HAS_TRANSPARENCY`)? But perhaps this is by design to avoid complexity.

/cc @dloebl (sorry for the ping)

Yes, st_gifconfig.genFlags is currently unused but we are planning to use it in future.
The second point is by design to keep the API consistent and simple. Furthermore, if the transparency could be set by a frame level, we would need to aggregate at least two frames before we could write them.
That's both something we could improve / point out in the documentation of cgif.

Quick note on the size difference (local vs global color table): If local color tables are used, the width / height + transparency optimization are not performed for the affected frame(s) at the moment (Flags CGIF_FRAME_GEN_USE_TRANSPARENCY and CGIF_FRAME_GEN_USE_DIFF_WINDOW are ignored in this case). That might be the reason for the size difference. Adding support for the optimizations in the case of local color tables is already on my ToDo for cgif. However, it will increase the complexity of the code a bit.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 26, 2021

Ah yes, that sounds like it. I had a look through the generated GIFs, and with per-frame tables most frames (but not all, curiously) are:

  redraw_x = 0
  redraw_y = 0
  redraw_width = 1080
  redraw_height = 1080

Which I suppose explains the size change and why experimenting with the transparency options did nothing.

Also: thank you for this nice library, @dloebl.

@lovell
Copy link
Member

lovell commented Sep 26, 2021

Are we thinking the change in this PR should be optional and, for now at least, not the default?

@jcupitt
Copy link
Member Author

jcupitt commented Sep 27, 2021

Sorry, I posted this in gitter, but perhaps it's better here for reference.

Here are a set of test GIFs plus a benchmark script and results for local-cmap, master and 8.11 (just for fun):

http://www.rollthepotato.net/~john/test-gifs

It's just 12 of the GIFs I had handy, but it's a mix of static images, video clips, and animated vector art with and without transparency. The test is making an animated thumbnail of each one.

Memory behaviour:

memory

master and local-cmaps are very similar, since we're downsizing to thumbnail size before doing the GIF save, so not keeping the whole GIF around makes little difference here. If the test was copying a large GIF, it'd look very different.

Time behaviour:

time

local-cmaps is generally a bit quicker, though slightly slower in one case, perhaps 10% saving overall. Again, we're only writing a small GIF, so time savings in cgifsave don't have a huge effect.

Perhaps copying a GIF should also be benchmarked.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 27, 2021

I added benchmarks for copy as well, but they are not very interesting.

  • per-frame has low memuse for all GIFs in the test set (as expected), and master and 8.11 have roughly similar memory use.
  • per-frame and master are sometimes faster and sometimes slower than each other depending on the GIF, but the differences are not usually very large
  • 8.11 is much, much slower

And remove the redundant liq_image_add_fixed_color call, which
should probably only be used in combination with
liq_image_set_background.
@kleisauke
Copy link
Member

I wasn't sure why that fixed it, I didn't debug it further. It was found by accident, when I was looking at how gifski uses the libimagequant API. See for example commit ImageOptim/gifski@a3fb2ff.

Looking into this further (after reading the docs), it seems that the liq_image_add_fixed_color() call isn't necessary (it should probably only be used in combination with liq_image_set_background()).

It looks like the issue with that test case is that the palette could be improved during remapping, so we must call liq_get_palette() after liq_write_remapped_image(), see for example commit kleisauke@3e3d162. This commit also fixes those artifacts for me.

Regarding CGIF_FRAME_GEN_USE_TRANSPARENCY, I think we could also do this optimization with liq_image_set_background() during the quantisation, but unfortunately this function is only available since version 2.11 of libimagequant.

@jcupitt
Copy link
Member Author

jcupitt commented Sep 28, 2021

Wow, I wouldn't have guessed that liq_write_remapped_image() updated the palette. Nice find! I've merged your patch.

@kleisauke
Copy link
Member

PR dloebl/cgif#23 would help here to reduce the file size, with that I see this (tested with the infamous sample.gif):

$ /usr/bin/time -f %M:%e vips copy sample.gif[n=-1] x.per-frame-cmap.gif
107196:17.82
$ /usr/bin/time -f %M:%e vips copy sample.gif[n=-1] x.master.gif
1757196:11.42
$ ls -l sample.gif x.*
-rw-r--r--. 1 kleisauke kleisauke 3449741 Sep 26 11:00 sample.gif
-rw-r--r--. 1 kleisauke kleisauke 3355980 Sep 29 10:06 x.master.gif
-rw-r--r--. 1 kleisauke kleisauke 3401174 Sep 29 10:04 x.per-frame-cmap.gif

@jcupitt
Copy link
Member Author

jcupitt commented Sep 29, 2021

Nice! Yes, that PR seems to mostly fix the size problem, and helps performance a little too.

@lovell
Copy link
Member

lovell commented Oct 2, 2021

dloebl/cgif#23 has now been merged so I've updated the (temporary) cgif Ubuntu PPA and Homebrew tap to use the latest commit - see https://github.com/lovell/cgif-packaging

@jcupitt
Copy link
Member Author

jcupitt commented Oct 2, 2021

Oh, that's great. I updated the benchmark above:

memory

time

Summary: this branch keeps memory use low and constant, but save is up to 2x slower for some (not all) very large GIFs.

@jcupitt
Copy link
Member Author

jcupitt commented Oct 2, 2021

I suppose my feeling is that the lower speed in some cases is unfortunate, but the much better memory behaviour is probably worth it. The lower speed is not that significant for thumbnailing since we only save small GIFs (the worst case is 2.36s vs 1.65s for wave.gif, and of course both are far, far better than 8.11).

Does anyone else have any thoughts?

Copy link
Member

@lovell lovell left a comment

Choose a reason for hiding this comment

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

LGTM

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

4 participants