🙄

作画資料管理ツールを作りたい #02 フォルダ内の画像ファイルをサムネイルで一覧表示する

に公開

はじめに

作りたいもの

作画資料管理ツール

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

↑ここまでで一旦完成

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

↑これは発展

シリーズ記事一覧

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

前回

  • フォルダのツリービューの作成

https://zenn.dev/indonegiyan/articles/344b3909130da9

今回

  • フォルダ内の画像ファイルをサムネイルで一覧表示する

能力

  • 初心者
  • 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



起動してフォルダツリーを展開していく。
フォルダ内に画像ファイルがあればサムネイルで一覧表示する。
フォーム

3. ソースコード

全体のコードは最後にあります。

イベントハンドラ

  1. Form1_Load …前回から変更無し
    TreeViewの最上位にドライブ一覧を構成する。

  2. treeViewDirectory_BeforeExpand …前回から変更無し
    展開ドライブまたはフォルダの配下のフォルダ一覧を構成する。

  3. treeViewDirectory_NodeMouseClick …追加
    フォルダをクリック選択したとき(BeforeExpandの後にも呼び出されるみたい)。
    makeListThumbs()を呼び出して、サムネイル一覧を作る。
    selectedDir(現在選択しているフォルダのフルパス)は今後、色んなところで使いそうなのでフィールドにした。
    あと、ドライブ名の後に\を付加しているせいか、ツリービュー展開後のノードのフルパスを取得するとドライブ名の後ろが\\と2つ連なってしまう。

    • これの扱いがよくわからない。ドライブ名の取得のときに取り除いても、その後配下のフォルダ情報を取得できるドライブと、できない・存在しない?どこかのフォルダを取得してしまう。
      Cドライブや外付けなど物理的に別のドライブはたぶん問題なさそう。
      パーティションで区切ったドライブ(元環境では1つだけしかないのでなんとも言えない)で発生している。
      とりあえず、ここで\1文字除去をやってみたらぱっと見正しく動いている。
treeViewDirectory_NodeMouseClick
        //フォルダをクリック選択したとき(BeforeExpandの後にも呼び出される)
        private void treeViewDirectory_NodeMouseClick(object sender, TreeNodeMouseClickEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine("■■■treeViewDirectory_NodeMouseClick");

            selectedDir = e.Node.FullPath.Replace("\\\\", "\\"); //ドライブ名の後の「\\」になるので「\」を1つ除去
            makeListThumbs(selectedDir);
        }

メソッド

  1. makeListThumbs …追加
    フォルダ内のファイル一覧取得。このとき取得する画像ファイルの形式を指定してる。
    ファイル一覧から、1つずつ画像を読み込みサムネイルを作成していく(makeThumbnailメソッドを呼び出す)。
    対象ファイルが数百枚を超えてくると、ここが最も処理速度に影響しそう。
    ツール開発の目的は作画資料管理なので、下手したら1万枚のFHDや4Kのデカい画像ファイルを扱うので必ず対策しなければならない。
    ぱっと調べたところListViewのプロパティには仮想モードがあり、これによりいくらか高速化できるそう。
    ただし、ListViewやImageListにAddメソッドで1つずつ追加していく方法が簡単だと思うがListViewのItemsが使用不可になる。
    また1つず追加するよりAddRangeメソッドで追加した方がいいらしい。
    その他諸々、今後の高速化を考えるときのために、今は一旦配列で処理してからコントロールに一括追加するようにしてみた。
    あと時間計測用のコードも追加している。
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
            {
                // フォルダ "C:\image" 内の jpeg 画像をすべて取得し、リストに格納
                //List<string> lstFile = System.IO.Directory.GetFiles(@"C:\image", "*.jpg").ToList();
                itemsFullPath = System.IO.Directory
                                .GetFiles(dir, "*.*")
                                .Where(file => extensions.Any(pattern => file.ToLower()
                                                                    .EndsWith(pattern)
                                                              )
                                      ).ToList();
            }
            catch
            {
                System.Diagnostics.Debug.WriteLine($"例外 -- makeListThumbs ");
                return;
            }


            //サムネイル一覧の初期化
            listViewThumbnail.Items.Clear();
            imageListThumbnail.Images.Clear();
            
            //ListViewに追加するItem
            var items = new ListViewItem[itemsFullPath.Count];
            // サムネイル用配列
            var images = new Image[itemsFullPath.Count];

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


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

            //画像ファイルを1つずつ処理
            for (int i = 0; i< itemsFullPath.Count; i++)
            {
                //フルパスからファイル名のみ抽出
                string fileName = System.IO.Path.GetFileName(itemsFullPath[i]);
      
                //サムネイル作成→サムネ用配列に格納
                images[i] = makeThumbnail(itemsFullPath[i], width, height);
                //pictureBox1.Image = images[i];

                //サムネのファイル名追加
                items[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;

            //アイテムをサムネ一覧に追加
            listViewThumbnail.Items.AddRange(items);
        }
  1. makeThumbnail …追加
    makeListThumbsメソッドから呼び出され、1ファイルずつサムネイル用にリサイズして返す。
makeThumbnail
        //サムネ作成
        private Image makeThumbnail(string fileFullPath, int width, int height)
        {
            System.Diagnostics.Debug.WriteLine($"-- makeThumbnail -- {fileFullPath}");

            //画像を読み込み
            Image img = Image.FromFile(fileFullPath);

            Bitmap canvas = new Bitmap(width, height);
   
            using (Graphics g = Graphics.FromImage(canvas))
            {
                g.FillRectangle(new SolidBrush(Color.White), 0, 0, width, height);

                //サムネイルのアスペクト調整
                float h = (float)height / (float)img.Height;
                float w = (float)width / (float)img.Width;
                float scale = Math.Min(w, h);
                w = img.Width * scale;
                h = img.Height * scale;

                g.DrawImage(img, (width - w) / 2, (height - h) / 2, w, h);
            }

            //画像を破棄
            img.Dispose();

            return  (Image)canvas;
        }

ソースコード全体

Form1.cs
    public partial class Form1 : Form
    {
        //選択中のディレクトリ
        string selectedDir;

        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            //ディレクトリのツリー構成
            addTree_Drive();
        }


        //フォルダを展開する直前
        private void treeViewDirectory_BeforeExpand(object sender, TreeViewCancelEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine("■■■treeViewDirectory_BeforeExpand");

            if (e.Node != null)
            {
                System.Diagnostics.Debug.WriteLine($"e.Node.Name: {e.Node.Name} ");
                addTree_Child(e.Node); //イベント元のノード(選択したディレクトリ)を渡す
            }
            else
            {
                System.Diagnostics.Debug.WriteLine("e.Node: null ");
            }
        }


        //フォルダをクリック選択したとき(BeforeExpandの後にも呼び出される)
        private void treeViewDirectory_NodeMouseClick(object sender, TreeNodeMouseClickEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine("■■■treeViewDirectory_NodeMouseClick");

            selectedDir = e.Node.FullPath.Replace("\\\\", "\\"); //ドライブ名の後の「\\」になるので「\」を1つ除去
            makeListThumbs(selectedDir);
        }


        //----------------------------------------------------------------------------------

        //サムネ一覧構成
        private void makeListThumbs(string dir)
        {
            System.Diagnostics.Debug.WriteLine($"-- makeListThumbs --");

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

            var itemsFullPath = new List<string>(); //ファイルのフルパス格納用
            try
            {
                // フォルダ "C:\image" 内の jpeg 画像をすべて取得し、リストに格納
                //List<string> lstFile = System.IO.Directory.GetFiles(@"C:\image", "*.jpg").ToList();
                itemsFullPath = System.IO.Directory
                                .GetFiles(dir, "*.*")
                                .Where(file => extensions.Any(pattern => file.ToLower()
                                                                    .EndsWith(pattern)
                                                              )
                                      ).ToList();
            }
            catch
            {
                System.Diagnostics.Debug.WriteLine($"例外 -- makeListThumbs ");
                return;
            }


            //サムネイル一覧の初期化
            listViewThumbnail.Items.Clear();
            imageListThumbnail.Images.Clear();
            
            //ListViewに追加するItem
            var items = new ListViewItem[itemsFullPath.Count];
            // サムネイル用配列
            var images = new Image[itemsFullPath.Count];

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

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

            //画像ファイルを1つずつ処理
            for (int i = 0; i< itemsFullPath.Count; i++)
            {
                //フルパスからファイル名のみ抽出
                string fileName = System.IO.Path.GetFileName(itemsFullPath[i]);
      
                //サムネイル作成→サムネ用配列に格納
                images[i] = makeThumbnail(itemsFullPath[i], width, height);
                //pictureBox1.Image = images[i];

                //サムネのファイル名追加
                items[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;

            //アイテムをサムネ一覧に追加
            listViewThumbnail.Items.AddRange(items);


        }


        //サムネ作成
        private Image makeThumbnail(string fileFullPath, int width, int height)
        {
            System.Diagnostics.Debug.WriteLine($"-- makeThumbnail -- {fileFullPath}");

            //画像を読み込み
            Image img = Image.FromFile(fileFullPath);

            Bitmap canvas = new Bitmap(width, height);
   
            using (Graphics g = Graphics.FromImage(canvas))
            {
                g.FillRectangle(new SolidBrush(Color.White), 0, 0, width, height);

                //サムネイルのアスペクト調整
                float h = (float)height / (float)img.Height;
                float w = (float)width / (float)img.Width;
                float scale = Math.Min(w, h);
                w = img.Width * scale;
                h = img.Height * scale;

                g.DrawImage(img, (width - w) / 2, (height - h) / 2, w, h);
            }

            //画像を破棄
            img.Dispose();

            return  (Image)canvas;
        }


        //ツリービューにノード(ドライブ名)を追加
        private void addTree_Drive()
        {
            string[] dirveList = Environment.GetLogicalDrives(); //ドライブ名一覧を取得

            foreach (string drive in dirveList)
            {
                // \除去(C:\ → C:)
                //    …をしたが、後のGetDirectories()でうまく取得できないドライブがある
                //string str = drive.Substring(0, drive.Length - 1);
                string str = drive;

                TreeNode tNode = new(str, 0, 0); //ノードにドライブアイコンを設定
                treeViewDirectory.Nodes.Add(tNode); //親ノードにドライブを設定

                //ダミーノード追加
                if (treeViewDirectory.Nodes.Count != 0)
                {
                    tNode.Nodes.Add("...");
                }
            }
        }


        //ツリービューにノード(ディレクトリ名)を追加
        private void addTree_Child(TreeNode treeParent)
        {
            System.Diagnostics.Debug.WriteLine($"-- addTree_Child -- treeParent.FullPath: {treeParent.FullPath}");

            TreeNode treeChild;

            DirectoryInfo dirInfo = new(treeParent.FullPath); //子ディレクトリ一覧を取得(展開する親ディレクトリのフルパスを指定)
            treeParent.Nodes.Clear(); //ダミーノードを消去

            //サブディレクトリのノード追加
            try
            {
                foreach (DirectoryInfo subDir in dirInfo.GetDirectories()) //※光学ドライブだと例外発生
                {
                    treeChild = new TreeNode(subDir.Name, 1, 2);
                    treeParent.Nodes.Add(treeChild);

                    //ダミーノード追加
                    if (treeParent.Nodes.Count != 0)
                    {
                        treeChild.Nodes.Add("...");
                    }
                }
            }
            catch (IOException ex)
            {
                string msg = "!!デバイスの準備ができていません";
                treeChild = new TreeNode(msg, 1, 2);
                treeParent.Nodes.Add(treeChild);
            }
        }


    }
}

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

https://effect.hatenablog.com/entry/2019/06/08/030840
https://atmarkit.itmedia.co.jp/ait/articles/0508/12/news091.html
https://tech.mychma.com/imagelist/274/

おわり

体系的に学んでいるわけではなく、作りたいもののために断片的に試行錯誤しながら作っているので正直自分でもよくわかってないコードや作り方をしていると思います。
この開発テーマでは、やはり高速化が一番重要な気がしています。
現時点では数十枚のサムネイルなので動作速度は普通です。
しかし約4000枚のサムネイルを表示するのに6分以上かかっているので先は長く険しそうな予感しかないです。
あと、コードが長くなってきたのでそろそろソースファイルを分けていこうと思います。

Discussion