WinFormsで画像ファイルを先読みしキャッシュファイルとして保存する。

コンピュータ

画像ファイルをキャッシュする方法としてSQLiteのBlobを使う方法を試しましたが、マルチスレッドでlock処理をはさむと、メリットが薄い感じがしました。今回はシンプルにキャッシュをファイルとして保存しています。キャッシュの有無は、オリジナルのファイルパスと更新日時、ファイルサイズからMD5でハッシュコードを生成し、それを重複しないキャッシュのファイル名とし、キャッシュファイルの有無で確認しています。

ファイル名:ImageLoadStore.cs

using System.Collections.Concurrent;
using System.Drawing.Drawing2D;
using System.Security.Cryptography;
using System.Text;

class ImageLoadStore : IDisposable
{
    private readonly BlockingCollection<string> _queue = new();
    private readonly SemaphoreSlim _semaphore = new(initialCount: 4); // 並列ワーカー4つまで
    private readonly CancellationTokenSource _cts = new();
    private readonly string _chacheDir;
    public void EnqueueFile(string path) => _queue.Add(path);

    private readonly Func<Bitmap, Bitmap> _processor;
    public ImageLoadStore(string chacheDir="./.cache", Func<Bitmap, Bitmap>? processor = null)
    {
        if (!Directory.Exists(chacheDir))
            Directory.CreateDirectory(chacheDir);
        
        _chacheDir = chacheDir;

        _processor = processor ?? (bmp => (Bitmap)bmp.Clone());
    }

    public void Start()
    {
        StartPrefetchWorker(_cts.Token);
    }
    public void Stop()
    {
        _cts.Cancel();
    }
    public void Dispose()
    {
        Stop();

        _cts.Dispose();
    }
    // 先読み
    public void StartPrefetchWorker(CancellationToken token)
    {
        Task.Run(async () =>
        {
            foreach (var path in _queue.GetConsumingEnumerable(token))
            {
                await _semaphore.WaitAsync(token);

                _ = Task.Run(async () =>
                {
                    MemoryStream ms = new();
                    string hash = ConvertFilePathToHash(path);
                    string cacheFile = Path.Join(_chacheDir, (hash + ".png"));
                    try
                    {

                        if (!File.Exists(cacheFile))
                        {
                            using( var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
                                await fs.CopyToAsync(ms);
                            ms.Seek(0, SeekOrigin.Begin);

                            using Bitmap bmp = new (ms);
                            // 変換
                            using Bitmap result = _processor(bmp);
                            result.Save(cacheFile, System.Drawing.Imaging.ImageFormat.Png);
                        }
                    }
                    finally
                    {
                        ms.Dispose();
                        _semaphore.Release();
                    }
                }, token);
            }
        }, token);
    }
    public async Task<Bitmap> LoadImageAsync(string path)
    {
        MemoryStream ms = new();
        string hash = ConvertFilePathToHash(path);
        string cacheFile = Path.Join(_chacheDir, (hash + ".png"));
        try
        {
            if (File.Exists(cacheFile))
            {
                path = cacheFile;
            }
            using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
                await fs.CopyToAsync(ms);
            ms.Seek(0, SeekOrigin.Begin);
            using var temp = new Bitmap(ms);
            Bitmap result = _processor(temp);

            return result;
        }
        finally
        {
            ms.Dispose();
        }
    }

    // MD5ハッシュ計算
    private static readonly MD5 _md5 = MD5.Create();
    private static readonly object _md5lock = new();

    public static string ComputeHash(string input)
    {
        byte[] inputBytes = Encoding.UTF8.GetBytes(input);

        byte[] hashBytes;
        lock (_md5lock) // MD5 はスレッドセーフではないためロックが必要
        {
            hashBytes = _md5.ComputeHash(inputBytes);
        }

        var sb = new StringBuilder();
        foreach (var b in hashBytes)
            sb.Append(b.ToString("x2"));

        return sb.ToString();
    }    
    // ファイルのパスからハッシュコードへ変換
    public static string ConvertFilePathToHash(string fileName)
    {
        var info = new FileInfo(fileName);
        string infoStr = $"{info.FullName}|{info.LastWriteTime}|{info.Length}";
        return ComputeHash(infoStr);
    }

    public static Bitmap ResizeToSquare(Bitmap source)
    {
        int canvasSize = 256;
        float scale = Math.Min((float)canvasSize / source.Width, (float)canvasSize / source.Height);
        int w = (int)(source.Width * scale);
        int h = (int)(source.Height * scale);

        Bitmap result = new(canvasSize, canvasSize);
        using var g = Graphics.FromImage(result);
        g.Clear(Color.Black);
        g.InterpolationMode = InterpolationMode.HighQualityBicubic;
        g.DrawImage(source, (canvasSize - w) / 2, (canvasSize - h) / 2, w, h);
        return result;
    }
    public static Bitmap ResizeToDesktopHeightSquare(Bitmap source)
    {
        // デスクトップの高さを取得(プライマリスクリーン)
        int desktopHeight = Screen.PrimaryScreen is null ? 600 : Screen.PrimaryScreen.Bounds.Height;

        // 画像のスケール計算(高さを基準)
        float scale = (float)desktopHeight / source.Height;
        int scaledWidth = (int)(source.Width * scale);
        int scaledHeight = desktopHeight;

        // 正方形キャンバス(幅と高さの大きい方をキャンバスサイズとする)
        int canvasSize = Math.Max(scaledWidth, scaledHeight);

        var result = new Bitmap(canvasSize, canvasSize);
        using var g = Graphics.FromImage(result);
        g.Clear(Color.Black); // 背景(必要に応じて透明にもできます)

        g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;

        // 中央に配置
        int offsetX = (canvasSize - scaledWidth) / 2;
        int offsetY = (canvasSize - scaledHeight) / 2;

        g.DrawImage(source, offsetX, offsetY, scaledWidth, scaledHeight);

        return result;
    }


}

ファイル名:Form1.cs

using System.Diagnostics;
using System.Runtime.InteropServices;

namespace ImageCahce01;

public partial class Form1 : Form
{
    ImageLoadStore _imageLoadStore = new("./.cache", bmp => ImageLoadStore.ResizeToSquare(bmp));

    PictureBox _picbox1 = new()
    {
        Dock = DockStyle.Fill,
    };

    public Form1()
    {
        InitializeComponent();

        this.Controls.Add(_picbox1);

        // フォームロード
        Load += async (sendr, e) =>
        {
            _imageLoadStore.Start();

            string fileName = @"C:\Users\PC01114\Pictures\ClipImage\20240822151636.png";

            _imageLoadStore.EnqueueFile(fileName);

            _picbox1.Image = await _imageLoadStore.LoadImageAsync(fileName);

        };
        // フォームクロージング
        FormClosing += (sender, e) =>
        {
            _imageLoadStore.Dispose();
        };
    }
}

先読み(プリフェッチ)は基本順不同でよいので、パフォーマンスを稼ぐ為並列処理を行っています。
ただ、IOが絡むので同時に処理数は制限を掛けています。

コードの流れとしては、
1.imageLoadStoreのオブジェクト生成
2.Start()でワーカー(StartPrefetchWorker)がバックグラウンドで起動
3.EnqueueFile(path)でキューに登録する。
4.ワーカー内でキューからpathを取り出す。
5.取り出したpathを処理する。ループ内の処理は別スレッドで並列処理されるが同時に起動するスレッドは4つまで(semaphore)
(ループの処理終了を待たずにどんどん次の処理を行えるので、速そうな感じがします。)
6.終了処理は、Dispose()⇒Stop()=>cts.Cancel()でキャンセルすることでバックエンドのワーカーが終了される。

スレッド数は実行環境に合わせて可変にした方が良さそうです。(HDD、ネットワークドライブなら1でSSDは4など)

非同期処理や並列処理は比較的理解しやすいコードになっていますが、順次処理しか経験のない筆者としては、どのような動きになっているのか、うまく想像することが出来ない感じです。

コメント