🎃

作画資料管理ツールを作りたい #05 フォルダ内ファイルのサムネイル一覧【高速化の検討②】ListViewの仮想モード

に公開
4

はじめに

作りたいもの

作画資料管理ツール

  • 画像ファイルをタグで分類できる(pixivやpinterestみたいに)
  • サムネイル一覧できる
  • 選択画像のプレビューができる

↑ここまでで一旦完成

  • 動画も扱う
  • 簡単な画像処理もできる(動画のフレームやトリミングもできたらいいな)
  • LLM?とかで類似画像の検索ができる

↑これは発展

シリーズ記事一覧

https://zenn.dev/indonegiyan/articles/77062311f8755e

前回

サムネイル一覧表示を高速化する。

  • Parallel.For

https://zenn.dev/indonegiyan/articles/ec027d890c1a14

今回

サムネイル一覧表示を高速化する。

  • ListViewの仮想モード

能力

  • 初心者
  • C#とフォームアプリの作り方を勉強する

1. 環境

  • Windows 11 pro
  • Visual Studio Community 2022 (Version 17.14.12)
  • プロジェクトの種類:Windowsフォームアプリ

2. フォームデザイン

主なコントロールと内容

コントロール プロパティ 内容
Form (Name) Form1
TreeView (Name) treeViewDirectory
ListView (Name) listViewThumbnail
View LargeIcon
ImageList (Name) imageListThumbnail
PictureBox (Name) pictureBoxPreview
BackColor Silver

選択画像のプレビュー

3. ソースコード

イベントハンドラ

  1. Form1_Load
    TreeViewの最上位にドライブ一覧を構成する。

  2. treeViewDirectory_BeforeExpand
    展開ドライブまたはフォルダの配下のフォルダ一覧を構成する。

  3. treeViewDirectory_NodeMouseClick
    選択フォルダ内の画像ファイル一覧を作り、サムネイルで表示する。

  4. listViewThumbnail_SelectedIndexChanged
    選択画像ファイルをプレビューする。

  5. listViewThumbnail_RetrieveVirtualItem \textcolor{red}{←追加}
    仮想モードで、Form上のListViewの表示範囲に対して反応するらしい?
    lvVirtualItemsは配列ListViewItem[]で作成したもの。
    サムネ一覧作成のmakeListThumbs()メソッドでファイル名と画像インデックスを設定している。

listViewThumbnail_RetrieveVirtualItem
private void listViewThumbnail_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
{      
    e.Item = lvVirtualItems[e.ItemIndex];
}

メソッド

  1. addTree_Drive
    フォルダツリーの最上位(ドライブ一覧)を構成する。

  2. addTree_Child
    フォルダツリーの選択フォルダ内のサブフォルダを構成する。

  3. resizeImg
    画像をリサイズする。

  4. makeListThumbs \textcolor{red}{←変更}
    サムネイル一覧を作成する。
    ListViewItem配列はローカル変数で扱っていたが、
    仮想モードにするためForm1内で使えるようにフィールドに宣言した。
    仮想モードに伴い、imageListThumbnail.Images.AddRange(images); が使えなくなったので、
    サムネ作成用forループ内でImageListのAdd()メソッドで追加している。

フィールド
// RetrieveVirtualItemy用
int lvItemsCount; // ListViewItem数
ListViewItem[] lvVirtualItems; //ファイル名と画像インデックス
- Image[] images; //サムネ
makeListThumbs
//サムネ一覧構成
private void makeListThumbs(string dir)
{
    System.Diagnostics.Debug.WriteLine($"-- makeListThumbs --");

    //表示対象のファイル形式
    string[] extensions = { ".jpg", ".jpeg", ".png", ".tif", ".jfif", ".bmp" };

    var itemsFullPath = new List<string>(); //ファイルのフルパス格納用
                try
    {
        itemsFullPath = System.IO.Directory
                        .GetFiles(dir, "*.*")
                        .Where(file => extensions.Any(pattern => file.ToLower()
                                                            .EndsWith(pattern)
                                                      )
                              ).ToList();
    }
    catch
    {
        System.Diagnostics.Debug.WriteLine($"例外 -- makeListThumbs ");
        return;
    }
    System.Diagnostics.Debug.WriteLine($"itemsFullPath: {itemsFullPath}");


+   //RetrieveVirtualItem用
+   lvItemsCount = itemsFullPath.Count;

+   //RetrieveVirtualItem用
+   lvVirtualItems = new ListViewItem[lvItemsCount];


    //サムネイル一覧の初期化
    listViewThumbnail.Items.Clear();
    imageListThumbnail.Images.Clear();


    //ListViewに追加するItem
    var items = new ListViewItem[itemsFullPath.Count];

    // サムネイル用配列
-   images = new Image[lvItemsCount];

    //ImageListに表示するサムネのサイズ
    int width = 50, height = 50;
    imageListThumbnail.ImageSize = new Size(width, height);


    // *** 時間計測用 ************************************
    // Stopwatchクラス生成
    System.Diagnostics.Stopwatch sw = new();
    // 計測開始
    sw.Start();
    // *** 時間計測用 ************************************

    //画像ファイルを1つずつ処理
    Parallel.For(0, lvItemsCount, i =>
    {
        //フルパスからファイル名のみ抽出
        string fileName = System.IO.Path.GetFileName(itemsFullPath[i]);

        //サムネイル作成→サムネ用配列に格納
-       images[i] = resizeImg(itemsFullPath[i], width, height);
+       // 仮想モード
+       imageListThumbnail.Images.Add( resizeImg(itemsFullPath[i], width, height) );

        //サムネのファイル名追加
-       items[i] = new ListViewItem(fileName, i); //ファイル名, イメージインデックスを指定
+       // 仮想モード
+       lvVirtualItems[i] = new ListViewItem(fileName, i); //ファイル名, イメージインデックスを指定
    });

    // *** 時間計測用 ************************************
    // 計測停止
    sw.Stop();
    // 結果表示
    System.Diagnostics.Debug.WriteLine("*** 計測時間 ***");
    TimeSpan ts = sw.Elapsed;
    System.Diagnostics.Debug.WriteLine($"{ts.Hours}時間 {ts.Minutes}{ts.Seconds}{ts.Milliseconds}ミリ秒");
    // *** 時間計測用 ************************************

    //サムネが入った配列をImageListに追加→ListViewのLargeImageListに設定
-   imageListThumbnail.Images.AddRange(images); 
    
    listViewThumbnail.LargeImageList = imageListThumbnail;

+   // 仮想モードON
+   listViewThumbnail.VirtualMode = true;

+   // Item数設定
+   listViewThumbnail.VirtualListSize = lvItemsCount;
}

実行結果

前回、Parallel.Forを使ったときと比較して、ほぼ高速化できていない。
サムネの作成がなければ多分高速になるのだろう(参考サイトのコードを試すと高速化できていた)。
また、マウスカーソルがListView上にある間は頻繁に仮想モードのイベントが発生しているのか、コントロール内の描画がチラつくし、動作も重たい。
今回は、なんなら仮想モードを使わない方が使いやすい。
きっと書き方、使い方が間違っているのかもしれないがよくわからない。

ソースコード全体

(省略)

参考にさせていただきました

https://codepanic.itigo.jp/cs/listview_virtual.html

おわり

今回は仮想モードを試してみましたが、ImageListとの相性が良くないだろうかと疑っています。
使い方を間違っているだけなら希望が生まれるのですがよくわりません。
task/async/awaitで非同期処理?も試してみたいと思います。
あと、読み込みながらListViewを表示していけるようなものがいいのかなと思います。
それも一度読み込めば再読み込みしなくてもいいような作りにしたいです。

Discussion

いぬいぬいぬいぬ

高速化のヒケツは「まずは計測」です。

現在はStopwatchで計っているだけなので、ざっくりこの辺が重い、は分かりますが重い原因までは分かりません。ですが「重くなる原因」にはCPU負荷、GPU負荷、ファイルやネットワークのI/O、メモリ消費、コードのアルゴリズムなどいろんなものがあります!

VisualStudioをお使いのようですので、プロファイリングツールを使って計測することをおすすめします!
https://learn.microsoft.com/ja-jp/visualstudio/profiling/?view=visualstudio

またこちらの記事を一読していただくことをおすすめします!
https://zenn.dev/higty/articles/73b6b4a402b94e
https://zenn.dev/aakei/articles/b858aee98b602e

焼き肉焼き肉

まだまだIDEを使った開発に不慣れなので少しずつ試してみます!参考記事のご紹介ありがとうございます。

radian-jpradian-jp

サムネイルをUIスレッドでループで一気に作成するとそこで描画が停止してしまうので、画像のロードもRetrieveVirtualItemで行う必要があるでしょうね。表示した分だけ逐次ロードが実行される筈です。
バックグラウンドでロードしたいなら、Task.Runで別Taskを起動してロードしたり一工夫必要になると思います。

下記はAIに出力させたやつですけど、雰囲気としては伝わるでしょうか。
1000枚でもすぐ表示されます。

// [プロンプト]
// C#のWinFormsの仮想化されたListViewで画像とファイル名一覧を表示するサンプルを出力してほしい。
// あくまでサンプルなので、実際のファイルを読み込むものでなくてよい。
// 概要としては、
// ・フォーム名はVirtualListViewForm、namespaceはWinFormsVirtualListView
// ・ImageListのIndex0に、透明な100x100のダミー画像を設定する。
// ・FilePath, FileName, ImageListIndex を持つImageFileDataのListをコンストラクタで作成する。
//  FilePathは "Z:\test\item1.png"~"Z:\test\item1000.png"
//  FileNameはPath.GetFileNameで取得。
//  ImageListIndex は0を設定。(ダミー画像)
// ・RetrieveVirtualItemイベントで、対応するImageFileDataをListから取得。
//  フォーム内のLoadImageメソッドで画像を取得し、ImageListに追加。
//  ImageListに追加された画像のインデックスをImageFileDataのListに反映し、
//  そこからListViewItemを作成し、e.Itemに設定する。
// ・LoadImageは、ランダムな単一色の100x100ダミー画像を作成する。
// ・LoadImageで取得した画像は、usingでImageList追加後に破棄されるようにする。

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Forms;

namespace WinFormsVirtualListView
{
    public class ImageFileData
    {
        public string FilePath { get; set; }
        public string FileName { get; set; }
        public int ImageListIndex { get; set; } = 0;
    }

    public partial class VirtualListViewForm : Form
    {
        private ListView listView;
        private ImageList imageList;
        private List<ImageFileData> imageFileDataList;

        public VirtualListViewForm()
        {
            this.Text = "Virtual ListView Sample";
            this.Width = 800;
            this.Height = 600;

            imageList = new ImageList();
            imageList.ImageSize = new Size(100, 100);
            imageList.ColorDepth = ColorDepth.Depth32Bit;

            // Index 0: 透明なダミー画像
            Bitmap dummy = new Bitmap(100, 100);
            using (Graphics g = Graphics.FromImage(dummy))
            {
                g.Clear(Color.Transparent);
            }
            imageList.Images.Add(dummy);

            listView = new ListView
            {
                Dock = DockStyle.Fill,
                View = View.LargeIcon,
                VirtualMode = true,
                VirtualListSize = 1000,
                LargeImageList = imageList
            };
            listView.RetrieveVirtualItem += ListView_RetrieveVirtualItem;
            this.Controls.Add(listView);

            // ImageFileDataのリスト作成
            imageFileDataList = new List<ImageFileData>();
            for (int i = 1; i <= 1000; i++)
            {
                string path = $@"Z:\test\item{i}.png";
                imageFileDataList.Add(new ImageFileData
                {
                    FilePath = path,
                    FileName = Path.GetFileName(path),
                    ImageListIndex = 0
                });
            }
        }

        private void ListView_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
        {
            var data = imageFileDataList[e.ItemIndex];

            // 画像がまだダミーなら読み込み
            if (data.ImageListIndex == 0)
            {
                using (var img = LoadImage())
                {
                    imageList.Images.Add(img);
                    data.ImageListIndex = imageList.Images.Count - 1;
                }
            }

            var item = new ListViewItem(data.FileName, data.ImageListIndex);
            e.Item = item;
        }

        private Image LoadImage()
        {
            Bitmap bmp = new Bitmap(100, 100);
            Random rand = new Random(Guid.NewGuid().GetHashCode());
            Color color = Color.FromArgb(rand.Next(256), rand.Next(256), rand.Next(256));
            using (Graphics g = Graphics.FromImage(bmp))
            {
                g.Clear(color);
            }
            return bmp;
        }
    }
}
焼き肉焼き肉

コメントありがとうございます。非同期処理の方も試していますがスレッドの扱い方とか理解できておらず迷子になっていました。
ひとまず、記載いただいたコードを参考にもう一度仮想モードで試してみます!