🍻

面倒なスクショ命名はローカルvlmにやらせよう on Mac

2025/01/05に公開


こんにちは。今回はMacにローカル Vision Language Model (VLM) 環境を整えて、スクリーンショットが撮られるたびに自動で “適切っぽい” ファイル名を付けてくれる仕組みを作ってみました。

いやー、Mac標準だと「スクリーンショット 2025-01-01…」みたいなファイルが無限に増えてどれがどれだかわかんなくなるじゃないですか。ぼくも未来のAI時代を先取りするなら、ちょっとカッコいい名前が自動でつくとウキウキするんじゃね?と思いまして。

ここではMacローカルで動かせる mlx-vlm パッケージ経由で Qwen2-VL モデルを用いる方法、そしておまけに ollamallama3.2-vision を使った方法も紹介します!


ざっくりやること

  • Macでスクショを撮る→デフォルトで「スクリーンショット…」となるファイルが保存される
  • これをAutomatorの「フォルダアクション」でフックして、保存直後にローカルVLM(Qwen2-vl)で画像解析→いい感じの名前にリネーム!
  • Pythonスクリプトや仮想環境構築のメモ
  • ついでに、ollama を使ったJavaScript版スクリプト例もオマケ紹介

https://x.com/wmoto_ai/status/1875772940056457431

検証環境

  • PC: M2 MacBook メモリ16GB
  • OS: macOS Sequoia 15.2
  • Python: 3.12.8

フォルダ構成の例

今回のプロジェクト用に、以下のようなフォルダ構成を想定します。
(もちろん、お好みの場所・名前で構成してもOKです!Automatorに指定するときは絶対パスが必要になるので注意してください。)

my_screenshot_rename_project/
├── .venv/                 # Pythonの仮想環境
├── rename_mlx.py          # mlx + Qwen2-VL版のリネーム用Pythonスクリプト
├── screenshot-renamer.js  # ollama llama3.2-vision版のリネーム用JavaScriptスクリプト (オマケ)
├── rename_log.txt         # (任意) Automator用のログファイル
└── ...                    # その他にREADMEなど置いてもOK

例として、my_screenshot_rename_project/ フォルダを作り、そこに本記事のスクリプトを配置する形です。


事前準備: Python仮想環境を作ろう

まず、Python環境と mlx-vlm をインストールするために仮想環境を用意します。

# フォルダに移動
$ cd my_screenshot_rename_project

# 仮想環境を作成
$ python3 -m venv .venv
# uvを使ってる方は uv venv .venv

# 仮想環境をアクティベート
$ source .venv/bin/activate

# mlx-vlmをインストール
$ pip install mlx-vlm
# uvを使ってる方は uv pip install mlx-vlm

これで準備完了です。mlx-vlm は、Mac上で Vision Language Models (VLM) の推論と微調整を行うためのパッケージです。

Pythonスクリプト(mlx + Qwen2-vl版)

やりたいこと
1. スクショが撮れたら画像ファイルのパスが自動で渡される
2. 画像の内容を解析して、適当なファイル名に変換する(例: Cap_BirdFlying とか)
3. 日時もファイル名に盛り込みたい(あとで何がいつの画像かわかるように)

具体的なスクリプト
# rename_mlx.py
import sys
import os
import warnings
import logging
import re
import datetime
import pytz

warnings.filterwarnings('ignore')

import mlx.core as mx
from mlx_vlm import load, generate
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import load_config

def suppress_output():
    class DummyFile:
        def write(self, x): pass
        def flush(self): pass
    return DummyFile()

def sanitize_filename(filename: str) -> str:
    """ファイル名に使えない文字を除外する"""
    sanitized = re.sub(r'[\\/:*?"<>|]+', '_', filename)
    sanitized = sanitized.strip().strip('.')
    if len(sanitized) > 100:
        sanitized = sanitized[:100]
    return sanitized

def main():
    if len(sys.argv) != 2:
        print("Usage: python rename_mlx.py <image_path>")
        sys.exit(1)

    image_path = sys.argv[1]

    try:
        # 出力を一時的に抑制
        original_stderr = sys.stderr
        original_stdout = sys.stdout
        sys.stderr = suppress_output()
        sys.stdout = suppress_output()

        # モデルとプロセッサのロード
        model_path = 'mlx-community/Qwen2-VL-2B-Instruct-4bit'  # 実際のパスは適宜
        model, processor = load(model_path)
        config = load_config(model_path)

        # 出力を元に戻す
        sys.stderr = original_stderr
        sys.stdout = original_stdout

        # 会話メッセージ このへんは適宜ご自身の用途でアレンジください
        messages = [
            {
                "role": "system",
                "content": """You are an expert at generating descriptive filenames for images. 
                Always output one CamelCase label starting with 'Cap_' that best describes the image content.
                Follow these rules:
                - Start with 'Cap_'
                - Use CamelCase format
                - Keep it concise but descriptive
                - No explanation, just the label
                - Examples: Cap_PyEnvTerminal, Cap_CookingWebPage, Cap_MarioGame, Cap_Xpost, Cap_JpnAnimeYouTube"""
            },
            {
                "role": "user",
                "content": "Generate a descriptive filename for this image."
            }
        ]

        # チャットテンプレートの適用
        formatted_prompt = apply_chat_template(processor, config, messages, num_images=1)

        # 画像の説明を生成
        output = generate(
            model, 
            processor, 
            formatted_prompt, 
            [image_path], 
            verbose=False,
            temperature=0.7,
            max_tokens=100
        )

        description = output.strip()

        # 拡張子
        extension = os.path.splitext(image_path)[1]
        safe_description = sanitize_filename(description)

        # 日本時間
        tz = pytz.timezone('Asia/Tokyo')
        now_japan = datetime.datetime.now(tz)
        date_str = now_japan.strftime('%y-%m-%dT%H-%M')

        # 新しいファイル名
        new_file_name = f"{date_str}_{safe_description}{extension}"
        new_file_path = os.path.join(os.path.dirname(image_path), new_file_name)

        os.rename(image_path, new_file_path)

        print(f"ファイルをリネームしました: {new_file_path}")

    except Exception as e:
        if sys.stderr != original_stderr:
            sys.stderr = original_stderr
        if sys.stdout != original_stdout:
            sys.stdout = original_stdout
        print(f"エラーが発生しました: {str(e)}")
        sys.exit(1)

if __name__ == "__main__":
    main()

このスクリプトを実行すると、スクショ画像が「日時_説明」形式(例: 25-01-04T23-14_Cap_Something.png)でリネームされます。


AutomatorでFinderアクションを設定

Automatorを使うと、特定フォルダにファイルが保存された瞬間をトリガーにして、任意のスクリプトを走らせることができます。ここでは「スクリーンショットが保存されるフォルダ」を指定し、リネームスクリプトを実行するようにします。
1. Automatorを開く → 「新規作成」ダイアログで「フォルダアクション」を選択

2. 「フォルダアクションが受け取るフォルダ」を「デスクトップ」に設定(またはスクリーンショットの保存先にしているフォルダ)
3. 右側の検索バーから「シェルスクリプトを実行」を追加する
4. スクリプト言語(ZshやBashなど)を選び、以下を追記して保存

#!/bin/zsh
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

# ここでは上記pythonスクリプト があるフォルダに移動
cd /PATH/TO/your_project_dir

# 仮想環境をアクティベート
source .venv/bin/activate

# ログファイル(デバッグ用、パスは適宜)
LOG_FILE="/PATH/TO/rename_log.txt"
echo "$(date): Starting script" >> "$LOG_FILE"

for f in "$@"; do
    echo "Processing: $f" >> "$LOG_FILE"
    python ./rename_mlx.py "$f" 2>> "$LOG_FILE"
done

# 仮想環境終了
deactivate

echo "$(date): Script finished" >> "$LOG_FILE"

上記のように書くと、指定フォルダにファイルが保存されるたびにこのシェルスクリプトが実行されて、Pythonスクリプトが呼び出されます。
($@ には、保存された全てのファイルパスが配列として入っています。)

もしAutomatorが「/usr/local/bin/node」や「/usr/local/bin/python」が見つからないと文句を言う場合は、export PATH="..." の設定をしっかり指定しましょう。


おまけ:ollama + llama3.2-vision版(JavaScript)

「やっぱりPythonよりNode.jsがいいぜ!」とか「ollamaが好き!」という方へのおまけ。
ollama はローカルにLlama系モデルを動かすコマンドラインツールで、Visionモデルも扱えます。
(ただし、僕の環境では推論に3分かかりました… 環境によるかも)

JavaScript版スクリプト

screenshot-renamer.js (プロジェクトルート直下に置く想定)

// screenshot-renamer.js
#!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const { request } = require('undici');
const { promisify } = require('util');
const readFileAsync = promisify(fs.readFile);

// MIMEタイプを取得する関数(必要ならログやデバッグ用に使う)
function getMimeType(filename) {
    const ext = path.extname(filename).toLowerCase();
    const mimeTypes = {
        '.png': 'image/png',
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.gif': 'image/gif',
    };
    return mimeTypes[ext] || 'application/octet-stream';
}

async function analyzeImage(imagePath) {
    try {
        console.log('画像の読み込みを開始...');
        const imageBuffer = await readFileAsync(imagePath);

        console.log('画像をBase64エンコード中...');
        const base64Image = imageBuffer.toString('base64'); // 純粋なBase64に変換

        console.log('Ollamaへリクエスト送信中...');
        const response = await request('http://localhost:11434/api/generate', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                model: 'llama3.2-vision:11b',
                stream: false,
                // 必要に応じてプロンプトを変更
                prompt: 'Output one CamelCase label starting with "Cap" that best describes this screenshot (e.g. CapTerminal, CapBrowser, CapCode). No explanation.',
                images: [base64Image], // 純粋なBase64文字列を渡す
            }),
        });

        console.log('Ollamaからのレスポンスを処理中...');
        const data = await response.body.json();

        if (!data || data.error) {
            console.error('Ollamaエラー:', data?.error || 'レスポンスが空です');
            return null;
        }

        const description = data.response;
        if (!description) {
            console.error('応答から説明を取得できません');
            return null;
        }

        const cleanDescription = description
            .trim()
            .replace(/[\n\r]/g, ' ')
            .replace(/[<>:"/\\|?*]/g, '-')
            .substring(0, 100);

        console.log('クリーニング後の説明:', cleanDescription);
        return cleanDescription;
    } catch (error) {
        console.error('詳細なエラー情報:', error);
        console.error('エラースタック:', error.stack);
        return null;
    }
}

async function renameScreenshot(filePath) {
    try {
        console.log('処理開始:', filePath);

        const dir = path.dirname(filePath);
        const ext = path.extname(filePath);
        console.log('ファイル情報:', { dir, ext });

        console.log('画像分析開始...');
        const description = await analyzeImage(filePath);

        if (!description) {
            console.error('画像分析失敗');
            return;
        }

        // === 日本時間 (UTC+9) で日付・時刻を取得・整形 ===
        // 1. 現在のローカル時間を取得
        const now = new Date();

        // 2. ローカルタイムゾーンとの差を考慮し、日本時間へ変換
        //    getTimezoneOffset() は「UTCからの分オフセット(マイナス=UTCより進んでいる)」を返すので、
        //    日本は UTC+9。 例えば UTC+9 なら offset=-540 (分) などとなる。
        const jstOffset = 9 * 60;             // 日本時間は UTC+9 → 9時間=540分
        const localOffset = now.getTimezoneOffset(); // 例) UTC+9地域だと -540
        const diffMinutes = jstOffset + localOffset; // 例えば -540 + 540 = 0 (日本在住の場合は結果0で、同じ時刻に)
        const jstTime = now.getTime() + diffMinutes * 60_000;
        const jstDate = new Date(jstTime);

        // 3. 日付フォーマット [YY]-[MM]-[DD]T[HH]-[mm]
        const year = String(jstDate.getFullYear()).slice(-2); // 下2桁 ("2025" → "25")
        const month = String(jstDate.getMonth() + 1).padStart(2, '0');
        const day = String(jstDate.getDate()).padStart(2, '0');
        const hour = String(jstDate.getHours()).padStart(2, '0');
        const minute = String(jstDate.getMinutes()).padStart(2, '0');

        const jstTimestamp = `${year}-${month}-${day}T${hour}-${minute}`;
        // 例: 25-01-04T23-14

        // ファイル名: 25-01-04T23-14_CapMacOsCommandLineTerminal.png のように
        const newName = `${jstTimestamp}_${description}${ext}`;
        const newPath = path.join(dir, newName);

        console.log('新しいファイル名:', path.basename(newPath));

        fs.renameSync(filePath, newPath);
        console.log('リネーム成功:', path.basename(newPath));
    } catch (error) {
        console.error('詳細なリネームエラー:', error);
    }
}

const filePath = process.argv[2];
if (!filePath) {
    console.error('ファイルパスが指定されていません');
    process.exit(1);
}

console.log('スクリプト開始');
console.log('対象ファイル:', filePath);
renameScreenshot(filePath);
Automatorシェルスクリプト例(JS版呼び出し)
#!/bin/zsh
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

cd /PATH/TO/ollama_rename_script

for f in "$@"; do
  /usr/local/bin/node ./screenshot-renamer.js "$f"
done

上記のようにAutomatorの「フォルダアクション」内に設定しておくと、ollamaを使ったリネームも可能です。ただし推論時間が長い場合は動作が遅く感じるかもしれません。

感想とか

  • デフォルトの「スクリーンショット ###」がどんどん溜まると管理が地味につらいので、ローカルVLMで画像を解析して名前をつけるのは意外と便利でした。
  • 外部APIを使ってももちろんできるけど、プライベート画像とかはローカルで完結させたいですよね。
  • ollama のVisionモデルは推論に時間がかかったので、Mac の最適化が効いてそうな mlx の方が僕には合ってました。
  • こういうふうにローカル環境をAIフレンドリーにしておくと、のちのちもっと高度な自動整理も夢じゃない…かも。

そんな感じです!もし「自動リネーム時に失敗するよ」「ここはこうしたほうがいいよ」などあればぜひコメントください。
では皆さんのMacライフがちょっとだけAIっぽく彩られますように!
乾杯!🍻

おわりに

今回この記事はo1 pro mode&gpt-4oにコンテキスト渡してほぼポン出しで書いてもらったのでやりとりを添付します。
https://chatgpt.com/share/677a75c8-1f88-8010-a426-88bea98154dc
↓内容一部修正やりとり
https://chatgpt.com/share/677be1ca-68ac-8010-bdb5-c92144f3b40b

Discussion