Boost the performance when advancing to the next page using .tif images

It turns out that the slow part is the Image.SelectActiveFrame call.

As usual, the solution is caching. However, in order to not increase the initial load time, it should be performed lazily on background.

The idea is simple. Start a worker thread and load all the image frames as separate Bitmaps in an array. Then use the cached image from the array instead of the SelectActiveFrame.

Since all that require some thread synchronization, I've encapsulated it in a helper class:

class PageBuffer : IDisposable
{
    public static PageBuffer Open(string path)
    {
        return new PageBuffer(File.OpenRead(path));
    }

    private PageBuffer(Stream stream)
    {
        this.stream = stream;
        Source = Image.FromStream(stream);
        PageCount = Source.GetFrameCount(FrameDimension.Page);
        if (PageCount < 2) return;
        pages = new Image[PageCount];
        var worker = new Thread(LoadPages) { IsBackground = true };
        worker.Start();
    }

    private void LoadPages()
    {
        for (int index = 0; ; index++)
        {
            lock (syncLock)
            {
                if (disposed) return;
                if (index >= pages.Length)
                {
                    // If you don't need the source image, 
                    // uncomment the following line to free some resources
                    //DisposeSource();
                    return;
                }
                if (pages[index] == null)
                    pages[index] = LoadPage(index);
            }
        }
    }

    private Image LoadPage(int index)
    {
        Source.SelectActiveFrame(FrameDimension.Page, index);
        return new Bitmap(Source);
    }

    private Stream stream;
    private Image[] pages;
    private object syncLock = new object();
    private bool disposed;

    public Image Source { get; private set; }
    public int PageCount { get; private set; }
    public Image GetPage(int index)
    {
        if (disposed) throw new ObjectDisposedException(GetType().Name);
        if (PageCount < 2) return Source;
        var image = pages[index];
        if (image == null)
        {
            lock (syncLock)
            {
                image = pages[index];
                if (image == null)
                    image = pages[index] = LoadPage(index);
            }
        }
        return image;
    }

    public void Dispose()
    {
        if (disposed) return;
        lock (syncLock)
        {
            disposed = true;
            if (pages != null)
            {
                foreach (var item in pages)
                    if (item != null) item.Dispose();
                pages = null;
            }
            DisposeSource();
        }
    }

    private void DisposeSource()
    {
        if (Source != null)
        {
            Source.Dispose();
            Source = null;
        }
        if (stream != null)
        {
            stream.Dispose();
            stream = null;
        }
    }
}

A full working demo:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Threading;
using System.Windows.Forms;

namespace Demo
{
    class TestForm : Form
    {
        public TestForm()
        {
            var panel = new Panel { Dock = DockStyle.Top, BorderStyle = BorderStyle.FixedSingle };
            openButton = new Button { Text = "Open", Top = 8, Left = 16 };
            prevButton = new Button { Text = "Prev", Top = 8, Left = 16 + openButton.Right };
            nextButton = new Button { Text = "Next", Top = 8, Left = 16 + prevButton.Right };
            panel.Height = 16 + openButton.Height;
            panel.Controls.AddRange(new Control[] { openButton, prevButton, nextButton });
            pageViewer = new PictureBox { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom };
            ClientSize = new Size(850, 1100 + panel.Height);
            Controls.AddRange(new Control[] { panel, pageViewer });
            openButton.Click += OnOpenButtonClick;
            prevButton.Click += OnPrevButtonClick;
            nextButton.Click += OnNextButtonClick;
            Disposed += OnFormDisposed;
            UpdatePageInfo();
        }

        private Button openButton;
        private Button prevButton;
        private Button nextButton;
        private PictureBox pageViewer;
        private PageBuffer pageData;
        private int currentPage;

        private void OnOpenButtonClick(object sender, EventArgs e)
        {
            using (var dialog = new OpenFileDialog())
            {
                if (dialog.ShowDialog(this) == DialogResult.OK)
                    Open(dialog.FileName);
            }
        }

        private void OnPrevButtonClick(object sender, EventArgs e)
        {
            SelectPage(currentPage - 1);
        }

        private void OnNextButtonClick(object sender, EventArgs e)
        {
            SelectPage(currentPage + 1);
        }

        private void OnFormDisposed(object sender, EventArgs e)
        {
            if (pageData != null)
                pageData.Dispose();
        }

        private void Open(string path)
        {
            var data = PageBuffer.Open(path);
            pageViewer.Image = null;
            if (pageData != null)
                pageData.Dispose();
            pageData = data;
            SelectPage(0);
        }

        private void SelectPage(int index)
        {
            pageViewer.Image = pageData.GetPage(index);
            currentPage = index;
            UpdatePageInfo();
        }

        private void UpdatePageInfo()
        {
            prevButton.Enabled = pageData != null && currentPage > 0;
            nextButton.Enabled = pageData != null && currentPage < pageData.PageCount - 1;
        }
    }

    static class Program
    {
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new TestForm());
        }
    }

    class PageBuffer : IDisposable
    {
        public static PageBuffer Open(string path)
        {
            return new PageBuffer(File.OpenRead(path));
        }

        private PageBuffer(Stream stream)
        {
            this.stream = stream;
            Source = Image.FromStream(stream);
            PageCount = Source.GetFrameCount(FrameDimension.Page);
            if (PageCount < 2) return;
            pages = new Image[PageCount];
            var worker = new Thread(LoadPages) { IsBackground = true };
            worker.Start();
        }

        private void LoadPages()
        {
            for (int index = 0; ; index++)
            {
                lock (syncLock)
                {
                    if (disposed) return;
                    if (index >= pages.Length)
                    {
                        // If you don't need the source image, 
                        // uncomment the following line to free some resources
                        //DisposeSource();
                        return;
                    }
                    if (pages[index] == null)
                        pages[index] = LoadPage(index);
                }
            }
        }

        private Image LoadPage(int index)
        {
            Source.SelectActiveFrame(FrameDimension.Page, index);
            return new Bitmap(Source);
        }

        private Stream stream;
        private Image[] pages;
        private object syncLock = new object();
        private bool disposed;

        public Image Source { get; private set; }
        public int PageCount { get; private set; }
        public Image GetPage(int index)
        {
            if (disposed) throw new ObjectDisposedException(GetType().Name);
            if (PageCount < 2) return Source;
            var image = pages[index];
            if (image == null)
            {
                lock (syncLock)
                {
                    image = pages[index];
                    if (image == null)
                        image = pages[index] = LoadPage(index);
                }
            }
            return image;
        }

        public void Dispose()
        {
            if (disposed) return;
            lock (syncLock)
            {
                disposed = true;
                if (pages != null)
                {
                    foreach (var item in pages)
                        if (item != null) item.Dispose();
                    pages = null;
                }
                DisposeSource();
            }
        }

        private void DisposeSource()
        {
            if (Source != null)
            {
                Source.Dispose();
                Source = null;
            }
            if (stream != null)
            {
                stream.Dispose();
                stream = null;
            }
        }
    }
}

UPDATE: As mentioned in the comments, the above implementation is using quite simple greedy caching strategy, which uses a lot of memory and does not work for big files.

The good thing though is that once the logic is encapsulated inside the class, we can change the strategy without touching our app code. For instance, we can remove the caching at all (return to the initial state), or optimize for "prev/next" navigation by maintaining a small set of cached image "window" like this

class PageBuffer : IDisposable
{
    public const int DefaultCacheSize = 5;

    public static PageBuffer Open(string path, int cacheSize = DefaultCacheSize)
    {
        return new PageBuffer(File.OpenRead(path), cacheSize);
    }

    private PageBuffer(Stream stream, int cacheSize)
    {
        this.stream = stream;
        source = Image.FromStream(stream);
        pageCount = source.GetFrameCount(FrameDimension.Page);
        if (pageCount < 2) return;
        pageCache = new Image[Math.Min(pageCount, Math.Max(cacheSize, 3))];
        var worker = new Thread(LoadPages) { IsBackground = true };
        worker.Start();
    }

    private void LoadPages()
    {
        while (true)
        {
            lock (syncLock)
            {
                if (disposed) return;
                int index = Array.FindIndex(pageCache, 0, pageCacheSize, p => p == null);
                if (index < 0)
                    Monitor.Wait(syncLock);
                else
                    pageCache[index] = LoadPage(pageCacheStart + index);
            }
        }
    }

    private Image LoadPage(int index)
    {
        source.SelectActiveFrame(FrameDimension.Page, index);
        return new Bitmap(source);
    }

    private Stream stream;
    private Image source;
    private int pageCount;
    private Image[] pageCache;
    private int pageCacheStart, pageCacheSize;
    private object syncLock = new object();
    private bool disposed;

    public Image Source { get { return source; } }
    public int PageCount { get { return pageCount; } }
    public Image GetPage(int index)
    {
        if (disposed) throw new ObjectDisposedException(GetType().Name);
        if (PageCount < 2) return Source;
        lock (syncLock)
        {
            AdjustPageCache(index);
            int cacheIndex = index - pageCacheStart;
            var image = pageCache[cacheIndex];
            if (image == null)
                image = pageCache[cacheIndex] = LoadPage(index);
            return image;
        }
    }

    private void AdjustPageCache(int pageIndex)
    {
        int start, end;
        if ((start = pageIndex - pageCache.Length / 2) <= 0)
            end = (start = 0) + pageCache.Length;
        else if ((end = start + pageCache.Length) >= PageCount)
            start = (end = PageCount) - pageCache.Length;
        if (start < pageCacheStart)
        {
            int shift = pageCacheStart - start;
            if (shift >= pageCacheSize)
                ClearPageCache(0, pageCacheSize);
            else
            {
                ClearPageCache(pageCacheSize - shift, pageCacheSize);
                for (int j = pageCacheSize - 1, i = j - shift; i >= 0; j--, i--)
                    Exchange(ref pageCache[i], ref pageCache[j]);
            }
        }
        else if (start > pageCacheStart)
        {
            int shift = start - pageCacheStart;
            if (shift >= pageCacheSize)
                ClearPageCache(0, pageCacheSize);
            else
            {
                ClearPageCache(0, shift);
                for (int j = 0, i = shift; i < pageCacheSize; j++, i++)
                    Exchange(ref pageCache[i], ref pageCache[j]);
            }
        }
        if (pageCacheStart != start || pageCacheStart + pageCacheSize != end)
        {
            pageCacheStart = start;
            pageCacheSize = end - start;
            Monitor.Pulse(syncLock);
        }
    }

    void ClearPageCache(int start, int end)
    {
        for (int i = start; i < end; i++)
            Dispose(ref pageCache[i]);
    }

    static void Dispose<T>(ref T target) where T : class, IDisposable
    {
        var value = target;
        if (value != null) value.Dispose();
        target = null;
    }

    static void Exchange<T>(ref T a, ref T b) { var c = a; a = b; b = c; }

    public void Dispose()
    {
        if (disposed) return;
        lock (syncLock)
        {
            disposed = true;
            if (pageCache != null)
            {
                ClearPageCache(0, pageCacheSize);
                pageCache = null;
            }
            Dispose(ref source);
            Dispose(ref stream);
            if (pageCount > 2)
                Monitor.Pulse(syncLock);
        }
    }
}

or implement other "smart" caching strategy. We can even make the strategy selectable by implementing the Strategy pattern.

Bu that will be another story. The second PageBuffer implementation should be sufficient for the OP use case.