Interactively display a series of 2D data arrays.

    arrays
      An iterable of 2D arrays (a 3D array works).
    descs
      An iterable of text descriptions, one for each array
    cadence
      The time delay before the next array is shown, in seconds.
    tostatuses
      An iterable of functions that convert cursor positions to a textual
      status output corresponding to that position. FIXME details needed.
    toworlds
      An iterable of functions that convert cursor positions to a
      latitude/longitude pair that is displayed in the status output.
      The `tostatuses` keyword is a more generic version of this.
      FIXME details needed.
    drawoverlay
      An optional function that draws an overlay on the display after
      the underlying data image is presented. FIXME details needed.
    yflip
      If true, have the numerical *y* coordinates have 0 refer to the
      bottom of the image. Note that the data array is still drawn such
      that its first row appears at the top!
    run_main
      If true, run the Gtk mainloop explicitly so that the function does
      not return until the window is closed. If false, no mainloop is run.
      If the application happens to already be running a mainloop in the
      background, the window will appear and the user will be able to
      interact with it while this thread continues executing.
    save_after_viewing
      If set to a string containing an integer percent-formatting specifier,
      the data will be written to a series of PNG files after the window is
      closed.

def cycle(arrays, descs=None, cadence=0.6, toworlds=None,
          drawoverlay=None, yflip=False, tostatuses=None, run_main=True,
          save_after_viewing=None):
    """Interactively display a series of 2D data arrays.

    arrays
      An iterable of 2D arrays (a 3D array works).
    descs
      An iterable of text descriptions, one for each array
    cadence
      The time delay before the next array is shown, in seconds.
    tostatuses
      An iterable of functions that convert cursor positions to a textual
      status output corresponding to that position. FIXME details needed.
    toworlds
      An iterable of functions that convert cursor positions to a
      latitude/longitude pair that is displayed in the status output.
      The `tostatuses` keyword is a more generic version of this.
      FIXME details needed.
    drawoverlay
      An optional function that draws an overlay on the display after
      the underlying data image is presented. FIXME details needed.
    yflip
      If true, have the numerical *y* coordinates have 0 refer to the
      bottom of the image. Note that the data array is still drawn such
      that its first row appears at the top!
    run_main
      If true, run the Gtk mainloop explicitly so that the function does
      not return until the window is closed. If false, no mainloop is run.
      If the application happens to already be running a mainloop in the
      background, the window will appear and the user will be able to
      interact with it while this thread continues executing.
    save_after_viewing
      If set to a string containing an integer percent-formatting specifier,
      the data will be written to a series of PNG files after the window is
      closed.

    """
    n = len(arrays)
    amin = amax = h = w = None

    if toworlds is not None and tostatuses is not None:
        raise ValueError('only one of "toworlds" and "tostatuses" may be given')

    if descs is None:
        descs = [''] * n

    for array in arrays:
        thish, thisw = array.shape
        thismin, thismax = array.min(), array.max()

        if not np.isfinite(thismin):
            thismin = array[np.ma.where(np.isfinite(array))].min()
        if not np.isfinite(thismax):
            thismax = array[np.ma.where(np.isfinite(array))].max()

        if amin is None:
            w, h, amin, amax = thisw, thish, thismin, thismax
        else:
            if thisw != w:
                raise ValueError('array widths not all equal')
            if thish != h:
                raise ValueError('array heights not all equal')

            amin = min(amin, thismin)
            amax = max(amax, thismax)

    stride = cairo.ImageSurface.format_stride_for_width(cairo.FORMAT_ARGB32, w)
    assert stride % 4 == 0 # stride is in bytes
    imgdata = np.empty((n, h, stride // 4), dtype=np.uint32)
    fixed = np.empty((n, h, w), dtype=np.int32)
    antimask = np.empty((n, h, w), dtype=np.bool_)
    surfaces = [None] * n

    imgdata.fill(0xFF000000)

    for i, array in enumerate(arrays):
        surfaces[i] = cairo.ImageSurface.create_for_data(imgdata[i], cairo.FORMAT_ARGB32,
                                                         w, h, stride)

        if np.ma.is_masked(array):
            filled = array.filled(amin)
            antimask[i] = ~array.mask
        else:
            filled = array
            antimask[i].fill(True)

        fixed[i] = (filled - amin) * (0x0FFFFFF0 / (amax - amin))

    def getn():
        return n

    def getshapei(i):
        return w, h

    def getdesci(i):
        return descs[i]

    clipped = np.zeros((h, w), dtype=np.int32) # scratch arrays -- two needed
    clipped2 = np.zeros((h, w), dtype=np.uint32) # to make numpy ufunc casting happy

    def settuningi(i, tunerx, tunery):
        np.bitwise_and(imgdata[i], 0xFF000000, imgdata[i])

        fmin = int(0x0FFFFFF0 * tunerx)
        fmax = int(0x0FFFFFF0 * tunery)

        if fmin == fmax:
            np.add(imgdata[i], 255 * (fixed[i] > fmin).astype(np.uint32), imgdata[i])
        else:
            np.clip(fixed[i], fmin, fmax, clipped)
            np.subtract(clipped, fmin, clipped)
            np.multiply(clipped, 255. / (fmax - fmin), clipped2, casting='unsafe')
            np.add(imgdata[i], clipped2, imgdata[i])

        np.multiply(imgdata[i], antimask[i], imgdata[i])

    def getsurfacei(i, xoffset, yoffset, width, height):
        return surfaces[i], xoffset, yoffset

    # see comment in view()
    nomasks = [not np.ma.is_masked(a) or a.mask is np.ma.nomask
               for a in arrays]

    if tostatuses is None:
        if toworlds is None:
            tostatuses = [None] * n
        else:
            from .astutil import fmthours, fmtdeglat

            def make_status_func(toworld):
                def status(y_and_x):
                    lat, lon = toworld(y_and_x)
                    return 'lat=%s lon=%s' % (fmtdeglat(lat),
                                              fmthours(lon))

            tostatuses = [make_status_func(toworlds[i]) for i in range(n)]

    def fmtstatusi(i, x, y):
        s = ''
        row = int(np.floor(y + 0.5))
        col = int(np.floor(x + 0.5))
        if row >= 0 and col >= 0 and row < h and col < w:
            if nomasks[i] or not arrays[i].mask[row,col]:
                s += '%g ' % arrays[i][row,col]
        if yflip:
            y = h - 1 - y
            row = h - 1 - row
        s += '[%d,%d] x=%.1f y=%.1f' % (row, col, x, y)
        if tostatuses[i] is not None:
            s += ' ' + tostatuses[i](np.array([y, x]))
        return s

    cycler = Cycler()
    cycler.set_n_getter(getn)
    cycler.set_shape_getter(getshapei)
    cycler.set_desc_getter(getdesci)
    cycler.set_tuning_setter(settuningi)
    cycler.set_surface_getter(getsurfacei)
    cycler.set_status_formatter(fmtstatusi)
    cycler.set_overlay_drawer(drawoverlay)
    cycler.win.show_all()

    if run_main:
        cycler.win.connect('destroy', Gtk.main_quit)
        Gtk.main()
    else:
        cycler.win.connect('destroy', lambda e: cycler.win.destroy())

    if save_after_viewing is not None:
        for i in range(n):
            filename = save_after_viewing % (i,)
            settuningi(i, cycler.last_tunerx,  cycler.last_tunery)
            surface, xoffset, yoffset = getsurfacei(i, 0, 0, w, h)
            surface.write_to_png(filename)