Skip to content

Commit 64f4540

Browse files
authored
Add APNG backend for animations (#45)
Frames written to a .png or .apng file are stored as full-colour RGB instead of being quantised to a 256 colour GIF palette. The deflate and scanline filtering of png_encode is shared with the new apng_encode.
1 parent b9c03e7 commit 64f4540

7 files changed

Lines changed: 262 additions & 43 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ call suptitle("figure title")
123123
scale along an edge
124124
- `table` for a block of text below (or above) the axes
125125
- `streamplot` for the streamlines of a vector field
126-
- `add_frame` / `save_animation(file, fps=, loop=)` to write an animated GIF
126+
- `add_frame` / `save_animation(file, fps=, loop=)` to write an animated GIF, or a full-colour animated PNG with a `.png` or `.apng` file name
127127
- `spy` (sparsity pattern of a dense matrix or COO indices), `matshow`, `eventplot` and `broken_barh`
128128
- `hist2d` and `hexbin` for counting points into square or hexagonal bins
129129
- `imshow(interpolation="bilinear")` to smooth an image instead of

‎src/fplotlib.f90‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module fplotlib
1313
use fplotlib_backend_pdf
1414
use fplotlib_backend_eps
1515
use fplotlib_gif, only: gif_encode
16+
use fplotlib_png, only: apng_encode
1617
use fplotlib_proj3d
1718
use fplotlib_backend_png
1819
use fplotlib_mathtext
@@ -6233,7 +6234,8 @@ subroutine anim_append(frame)
62336234
anim_len = need
62346235
end subroutine anim_append
62356236

6236-
! Writes the frames collected so far and starts a new reel, so that a
6237+
! Writes the frames collected so far as a GIF, or as an animated PNG for
6238+
! .png and .apng (full colour), and starts a new reel, so that a
62376239
! program can make several animations without a reset call.
62386240
subroutine save_animation(filename, fps, loop)
62396241
character(len=*), intent(in) :: filename
@@ -6247,10 +6249,13 @@ subroutine save_animation(filename, fps, loop)
62476249
print *, "fplotlib: no frames; call add_frame before save_animation"
62486250
return
62496251
end if
6250-
if (file_ext(filename) /= "gif") then
6251-
print *, "fplotlib: save_animation writes .gif, not .", file_ext(filename)
6252+
select case (file_ext(filename))
6253+
case ("gif", "png", "apng")
6254+
case default
6255+
print *, "fplotlib: save_animation writes .gif, .png or .apng, not .", &
6256+
file_ext(filename)
62526257
return
6253-
end if
6258+
end select
62546259
rate = 5.0_dp
62556260
if (present(fps)) then
62566261
if (fps <= 0.0_dp) error stop "fplotlib: save_animation fps must be positive"
@@ -6262,8 +6267,16 @@ subroutine save_animation(filename, fps, loop)
62626267
! GIF counts delays in hundredths of a second and viewers treat 0 as
62636268
! "as fast as you like", so a frame never asks for less than one.
62646269
delay = max(1, nint(100.0_dp/rate))
6265-
call write_bytes(filename, gif_encode(anim_w, anim_h, anim_n, &
6266-
anim_pix(1:anim_len), delay, rep))
6270+
if (file_ext(filename) == "gif") then
6271+
call write_bytes(filename, gif_encode(anim_w, anim_h, anim_n, &
6272+
anim_pix(1:anim_len), delay, rep))
6273+
else
6274+
! APNG delays are a fraction, so the frame rate is not rounded
6275+
! to hundredths of a second.
6276+
call write_bytes(filename, apng_encode(anim_w, anim_h, anim_n, &
6277+
anim_pix(1:anim_len), &
6278+
min(65535, max(1, nint(1000.0_dp/rate))), 1000, rep))
6279+
end if
62676280
anim_n = 0
62686281
anim_w = 0
62696282
anim_h = 0

‎src/fplotlib_png.f90‎

Lines changed: 112 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ module fplotlib_png
2121
! zlib_compress is here rather than in the PDF backend because a PDF
2222
! FlateDecode stream is the same zlib stream a PNG carries, and one
2323
! compressor is enough.
24-
public :: png_encode, zlib_compress
24+
public :: png_encode, apng_encode, zlib_compress
2525

2626
! LZ77 parameters. WSIZE is fixed by the format; CHAIN bounds how hard the
2727
! matcher looks before settling, which is the usual size/speed dial.
@@ -314,12 +314,113 @@ function png_encode(w, h, rgba) result(bytes)
314314
integer, intent(in) :: w, h
315315
integer, intent(in) :: rgba(4, w, h)
316316
character(len=:), allocatable :: bytes
317+
type(bitbuf_t) :: out
318+
319+
call push_signature(out)
320+
call ihdr(out, w, h, 6)
321+
call chunk(out, "IDAT", filtered_zlib(w, h, 4, rgba))
322+
call chunk(out, "IEND", NO_DATA)
323+
bytes = to_string(out)
324+
end function png_encode
325+
326+
! ------------------------------------------------------------------
327+
! Encode nframes RGB frames as an animated PNG. pix holds the frames as
328+
! packed RGB bytes, one after another, as the animation reel keeps them.
329+
! A delay is delay_num/delay_den seconds; loop true repeats forever.
330+
! Every frame is a full, opaque replacement of the canvas, so the first
331+
! frame is also the default image that viewers without APNG support show.
332+
! ------------------------------------------------------------------
333+
334+
function apng_encode(w, h, nframes, pix, delay_num, delay_den, loop) result(bytes)
335+
integer, intent(in) :: w, h, nframes, delay_num, delay_den
336+
character(len=*), intent(in) :: pix
337+
logical, intent(in) :: loop
338+
character(len=:), allocatable :: bytes
339+
integer, allocatable :: rgb(:, :, :), z(:), d(:)
340+
integer :: f, x, y, c, k, seq, base, nz
341+
type(bitbuf_t) :: out
342+
343+
allocate (rgb(3, w, h))
344+
call push_signature(out)
345+
call ihdr(out, w, h, 2)
346+
347+
allocate (d(8))
348+
call be32(nframes, d(1:4))
349+
d(5:8) = 0 ! zero plays forever
350+
if (.not. loop) call be32(1, d(5:8))
351+
call chunk(out, "acTL", d)
352+
deallocate (d)
353+
354+
seq = 0
355+
do f = 1, nframes
356+
base = (f - 1)*3*w*h
357+
do concurrent (y = 1:h, x = 1:w, c = 1:3)
358+
k = base + (y - 1)*3*w + (x - 1)*3 + c
359+
rgb(c, x, y) = iachar(pix(k:k))
360+
end do
361+
362+
allocate (d(26))
363+
call be32(seq, d(1:4))
364+
call be32(w, d(5:8))
365+
call be32(h, d(9:12))
366+
d(13:20) = 0 ! x and y offset
367+
d(21) = iand(ishft(delay_num, -8), 255)
368+
d(22) = iand(delay_num, 255)
369+
d(23) = iand(ishft(delay_den, -8), 255)
370+
d(24) = iand(delay_den, 255)
371+
d(25) = 0 ! dispose: leave the canvas as it is
372+
d(26) = 0 ! blend: replace
373+
call chunk(out, "fcTL", d)
374+
deallocate (d)
375+
seq = seq + 1
376+
377+
z = filtered_zlib(w, h, 3, rgb)
378+
if (f == 1) then
379+
call chunk(out, "IDAT", z)
380+
else
381+
nz = size(z)
382+
allocate (d(4 + nz))
383+
call be32(seq, d(1:4))
384+
d(5:) = z
385+
call chunk(out, "fdAT", d)
386+
deallocate (d)
387+
seq = seq + 1
388+
end if
389+
end do
390+
391+
call chunk(out, "IEND", NO_DATA)
392+
bytes = to_string(out)
393+
end function apng_encode
394+
395+
subroutine push_signature(out)
396+
type(bitbuf_t), intent(inout) :: out
397+
398+
call push(out, 137); call push(out, 80); call push(out, 78)
399+
call push(out, 71); call push(out, 13); call push(out, 10)
400+
call push(out, 26); call push(out, 10)
401+
end subroutine push_signature
402+
403+
function to_string(out) result(bytes)
404+
type(bitbuf_t), intent(in) :: out
405+
character(len=:), allocatable :: bytes
406+
integer :: i
407+
408+
allocate (character(len=out%n) :: bytes)
409+
do i = 1, out%n
410+
bytes(i:i) = achar(out%b(i))
411+
end do
412+
end function to_string
413+
414+
! The zlib stream of an image's scanlines, each filtered with whichever of
415+
! the five PNG filters looks cheapest. px is bpp interleaved channels.
416+
function filtered_zlib(w, h, bpp, px) result(z)
417+
integer, intent(in) :: w, h, bpp
418+
integer, intent(in) :: px(bpp, w, h)
419+
integer, allocatable :: z(:)
317420
integer, allocatable :: raw(:), comp(:), cur(:), up(:), try(:), best(:)
318-
integer :: bpp, stride, nraw, x, y, c, i, p, f, score, bscore, bf
421+
integer :: stride, nraw, x, y, c, i, p, f, score, bscore, bf
319422
integer :: a_, b_, c_, v
320-
type(bitbuf_t) :: out
321423

322-
bpp = 4
323424
stride = w*bpp
324425
nraw = h*(stride + 1)
325426
allocate (raw(nraw), cur(stride), up(stride), try(stride), best(stride))
@@ -329,9 +430,9 @@ function png_encode(w, h, rgba) result(bytes)
329430
do y = 1, h
330431
i = 0
331432
do x = 1, w
332-
do c = 1, 4
433+
do c = 1, bpp
333434
i = i + 1
334-
cur(i) = iand(rgba(c, x, y), 255)
435+
cur(i) = iand(px(c, x, y), 255)
335436
end do
336437
end do
337438

@@ -372,21 +473,8 @@ function png_encode(w, h, rgba) result(bytes)
372473
end do
373474

374475
comp = deflate(raw, nraw)
375-
376-
! Signature
377-
call push(out, 137); call push(out, 80); call push(out, 78)
378-
call push(out, 71); call push(out, 13); call push(out, 10)
379-
call push(out, 26); call push(out, 10)
380-
381-
call ihdr(out, w, h)
382-
call chunk(out, "IDAT", zlib_wrap(comp, raw, nraw))
383-
call chunk(out, "IEND", NO_DATA)
384-
385-
allocate (character(len=out%n) :: bytes)
386-
do i = 1, out%n
387-
bytes(i:i) = achar(out%b(i))
388-
end do
389-
end function png_encode
476+
z = zlib_wrap(comp, raw, nraw)
477+
end function filtered_zlib
390478

391479
! Bytes in, a zlib stream out. Unlike a PNG the caller supplies its own
392480
! layout, so nothing is filtered here.
@@ -421,15 +509,15 @@ function zlib_wrap(comp, raw, nraw) result(z)
421509
z(n + 6) = iand(a, 255)
422510
end function zlib_wrap
423511

424-
subroutine ihdr(out, w, h)
512+
subroutine ihdr(out, w, h, ctype)
425513
type(bitbuf_t), intent(inout) :: out
426-
integer, intent(in) :: w, h
514+
integer, intent(in) :: w, h, ctype
427515
integer :: d(13)
428516

429517
call be32(w, d(1:4))
430518
call be32(h, d(5:8))
431519
d(9) = 8 ! bit depth
432-
d(10) = 6 ! truecolor with alpha
520+
d(10) = ctype ! 2 truecolor, 6 truecolor with alpha
433521
d(11) = 0 ! deflate
434522
d(12) = 0 ! adaptive filtering
435523
d(13) = 0 ! no interlace

0 commit comments

Comments
 (0)