見出し画像

Webカメラを使ったリアルタイム処理(Windows。Ollamaを使ったツールの参考コード)

先日試した、Webカメラを使ったリアルタイム処理(llamacpp)の
体験がすごく心躍り、またライセンスも好きに使っていいとの大変有り難いお言葉。

そこで、Geminiの力を借りて自分用にコード変更してみました。
①llamacpp→Ollamaにツール変更
②設定できそうな項目をGUI上で追加(※正常動作しているかは不明)
動作の保証はできませんが、参考としてコードも掲載しておきます。


使い方はこんな感じ

マルチモーダルモデルをダウンロードし、使える状態にしておきます。

現在のOllamaサーバーを完全停止。

  1. デスクトップ右下のタスクバーの
    ^(隠れているインジケーターを表示します)をクリック

  2. Ollamaアイコンをクリック

  3. Quit Ollamaをクリック

1.^(隠れているインジケーターを表示します)をクリック
2.Ollamaアイコンをクリック
3.Quit Ollamaをクリック

Ollamaサーバー起動

set OLLAMA_ORIGINS=*
ollama serve
ollama serve

補足
ollama serveだけだとうまく動作せず。
ブラウザからローカルサーバー(Ollama)へのリクエストは、
セキュリティ上の理由からCORSポリシーによって
制限されることがあるそう。
Ollamaサーバー側で、このウェブアプリケーションからのリクエストを
許可するのに、set OLLAMA_ORIGINS=*を行っています。

Ollamaサーバー起動確認

ブラウザに以下を入力してエンター
http://127.0.0.1:11434/
「Ollama is running」になっているのを確認。

「Ollama is running」になっているのを確認。

Webカメラを接続して認識できるようにしておく。

index_ollama_test.htmlをダブルクリック

  1. 最下段にある参考コードをテキストファイルに全コピぺし、
    ファイル名を「ndex_ollama_test.html」に変更。
    例:「新規 テキスト ドキュメント.txt」 → 「ndex_ollama_test.html」

  2. index_ollama_test.htmlをダブルクリック

index_ollama_test.htmlをダブルクリック

Webカメラの使用を許可する。

Webカメラの使用を許可する。

必要に応じて設定を変更する。

例えば、
Ollama Model Name :gemma3:4b (ダウンロード済みモデル指定)
Frame Interval : 5000 (CPU/GPU処理能力しだい)
Your Prompt : 何が書かれていますか? (モデルの対応言語で)

変更前
変更後

Startボタンをクリック


補足

ollama serve実施時に、以下が表示される場合は、上手くいっていません。
最初からやり直したら上手くいくかも。

Error: listen tcp 127.0.0.1:11434: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.

上手くいっていない例

実施例

実施例1
実施例2
実施例3

以下の実施例
NVIDIA GeForce RTX 4070 Ti SUPER
Frame Interval : 5000
動画ファイルがアップできないので、gif変換しています。
実際の速度と若干違うかもしれません。

Frame Interval : 5000

以下の実施例
NVIDIA GeForce RTX 4070 Ti SUPER
Frame Interval : 500
動画ファイルがアップできないので、gif変換しています。
実際の速度と若干違うかもしれません。

Frame Interval : 500

参考コード(index_ollama_test.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SmolVLM Realtime Webcam (Ollama Backend - Conditional Params)</title>
    <style>
        body {
            font-family: sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            margin-top: 20px;
            background-color: #f0f0f0;
            color: #333;
        }
        #container {
            display: flex;
            flex-direction: column;
            align-items: center;
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
            max-width: 1400px;
            width: 95%;
            margin: auto;
        }
        #mainLayout {
            display: flex;
            flex-direction: row;
            width: 100%;
            gap: 20px;
            align-items: flex-start;
        }
        #leftColumn {
            flex: 1.5;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 15px;
        }
        #rightColumn {
            flex: 2;
            display: flex;
            flex-direction: column;
            gap: 15px;
            max-height: calc(100vh - 120px);
            overflow-y: auto;
            padding-right: 10px;
            padding-left: 10px;
        }

        video { display:none; }
        canvas {
            border: 1px solid #ccc;
            border-radius: 4px;
            max-width: 100%;
            height: auto;
            display: block;
        }
        textarea {
            width: calc(100% - 22px);
            min-height: 80px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: 1em;
            box-sizing: border-box;
        }
        button {
            padding: 10px 20px;
            font-size: 1em;
            color: white;
            background-color: #007bff;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover { background-color: #0056b3; }

        #answerContainer {
            width: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
        }
        #answerContainer h2 {
             margin-bottom: 5px;
        }
        #answer {
            padding:10px;
            border:1px solid #ddd;
            background-color:#f9f9f9;
            min-height: 100px;
            width: calc(100% - 22px);
            max-width: 640px;
            border-radius: 4px;
            white-space: pre-wrap;
            word-wrap: break-word;
            box-sizing: border-box;
            overflow-y: auto;
            max-height: 300px;
        }
        #status { font-size: 0.9em; color: #666; margin-bottom: 10px; text-align: center; width: 100%;}
        .config-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 15px;
            width: 100%;
        }
        .config-item { display: flex; flex-direction: column; }
        .config-item label { margin-bottom: 5px; font-weight: bold; font-size: 0.9em; }
        .config-item input[type="text"], .config-item input[type="number"] {
            padding: 8px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: 0.9em;
            width: 100%;
            box-sizing: border-box;
        }
        .config-item small { font-size: 0.8em; color: #555; margin-top: 3px;}
        h1, h2 { text-align: center; width: 100%; margin-bottom: 15px; }
        h2 { margin-top: 15px; font-size: 1.2em; }

        #controls {
            display: flex;
            gap: 10px;
            justify-content: center;
            width: 100%;
        }

        @media (max-width: 900px) {
            #mainLayout {
                flex-direction: column;
            }
            #rightColumn {
                max-height: none;
                overflow-y: visible;
                width: 100%;
            }
            #leftColumn {
                flex: none;
                width: 100%;
            }
            #answer {
                max-width: 100%;
            }
        }
    </style>
</head>
<body>
    <div id="container">
        <h1>SmolVLM Realtime Webcam (Ollama Backend - Conditional Params)</h1>
        <p id="status">Initializing...</p>

        <div id="mainLayout">
            <div id="leftColumn">
                <canvas id="canvasElement" width="640" height="480"></canvas>
                <div id="controls">
                    <button id="startButton">Start</button>
                    <button id="stopButton" style="display:none;">Stop</button>
                </div>
                <div id="answerContainer">
                    <h2>Answer:</h2>
                    <div id="answer">Waiting for server response...</div>
                </div>
            </div>

            <div id="rightColumn">
                <section>
                    <h2>Base Configuration</h2>
                    <div class="config-grid">
                        <div class="config-item">
                            <label for="serverUrlInput">Ollama Server URL (Base):</label>
                            <input type="text" id="serverUrlInput" value="http://localhost:11434">
                            <small>(API path /api/generate will be appended)</small>
                        </div>
                        <div class="config-item"> {/* New field for Ollama Model Name */}
                            <label for="ollamaModelNameInput">Ollama Model Name:</label>
                            <input type="text" id="ollamaModelNameInput" value="llava:latest">
                            <small>(e.g., llava:latest, bakllava)</small>
                        </div>
                        <div class="config-item">
                            <label for="frameIntervalInput">Frame Interval (ms):</label>
                            <input type="number" id="frameIntervalInput" value="500" min="50">
                            <small>Time between sending frames.</small>
                        </div>
                        <div class="config-item">
                            <label for="imageQualityInput">Image Quality (JPEG 0.1-1.0):</label>
                            <input type="number" id="imageQualityInput" value="0.8" step="0.01" min="0.1" max="1.0">
                            <small>Quality of the image sent.</small>
                        </div>
                    </div>
                </section>

                <section>
                    <h2>Prompt</h2>
                    <div class="config-item" style="width:100%;">
                        <label for="promptInput">Your Prompt:</label>
                        <textarea id="promptInput">What do you see?</textarea>
                    </div>
                </section>

                <section>
                    <h2>Ollama Model Options</h2>
                    <div class="config-grid">
                        <div class="config-item">
                            <label for="numCtxInput">Context Size (num_ctx):</label>
                            <input type="number" id="numCtxInput" value="" placeholder="Server Default" min="0">
                        </div>
                        <div class="config-item">
                            <label for="numPredictInput">Max Tokens (num_predict):</label>
                            <input type="number" id="numPredictInput" value="100" min="-2">
                            <small>(-1: infinite, -2: fill context)</small>
                        </div>
                        <div class="config-item">
                            <label for="temperatureInput">Temperature:</label>
                            <input type="number" id="temperatureInput" value="" placeholder="Server Default" step="0.05" min="0">
                        </div>
                        <div class="config-item">
                            <label for="repeatPenaltyInput">Repeat Penalty (repeat_penalty):</label>
                            <input type="number" id="repeatPenaltyInput" value="" placeholder="Server Default" step="0.1" min="0">
                        </div>
                        <div class="config-item">
                            <label for="topKInput">Top K (top_k):</label>
                            <input type="number" id="topKInput" value="" placeholder="Server Default" min="0">
                        </div>
                        <div class="config-item">
                            <label for="topPInput">Top P (top_p):</label>
                            <input type="number" id="topPInput" value="" placeholder="Server Default" step="0.05" min="0" max="1">
                        </div>
                    </div>
                </section>
            </div>
        </div>
        <video id="videoElement" width="640" height="480" autoplay playsinline></video>
    </div>

    <script>
        const APP = {
            videoElement: document.getElementById('videoElement'),
            canvasElement: document.getElementById('canvasElement'),
            ctx: document.getElementById('canvasElement').getContext('2d'),
            promptInput: document.getElementById('promptInput'),
            answerElement: document.getElementById('answer'),
            statusElement: document.getElementById('status'),
            startButton: document.getElementById('startButton'),
            stopButton: document.getElementById('stopButton'),
            serverUrlInput: document.getElementById('serverUrlInput'),
            ollamaModelNameInput: document.getElementById('ollamaModelNameInput'), // New
            frameIntervalInput: document.getElementById('frameIntervalInput'),
            imageQualityInput: document.getElementById('imageQualityInput'),
            // Renamed input IDs to match Ollama option names for clarity
            numCtxInput: document.getElementById('numCtxInput'),
            numPredictInput: document.getElementById('numPredictInput'),
            temperatureInput: document.getElementById('temperatureInput'),
            repeatPenaltyInput: document.getElementById('repeatPenaltyInput'),
            topKInput: document.getElementById('topKInput'),
            topPInput: document.getElementById('topPInput'),

            isProcessing: false,
            cameraStream: null,
            intervalId: null,
            abortController: null,

            baseServerUrl: 'http://localhost:11434',
            ollamaModelName: 'llava:latest', // Default model
            frameInterval: 500,
            imageQuality: 0.8,
            ollamaOptions: {} // Will store Ollama specific options
        };

        APP.init = async function() {
            APP.statusElement.textContent = 'Requesting camera access...';
            APP.loadConfigFromUI();

            try {
                APP.cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
                APP.videoElement.srcObject = APP.cameraStream;
                APP.videoElement.onloadedmetadata = () => {
                    APP.canvasElement.width = APP.videoElement.videoWidth;
                    APP.canvasElement.height = APP.videoElement.videoHeight;
                    APP.statusElement.textContent = 'Camera access granted. Ready to start.';
                    APP.startButton.disabled = false;
                };
            } catch (err) {
                console.error("Error accessing camera: ", err);
                APP.statusElement.textContent = `Error accessing camera: ${err.name} - ${err.message}.`;
                alert(`Error accessing camera: ${err.name}. Please grant permission.`);
            }
        };

        APP.loadConfigFromUI = function() {
            APP.baseServerUrl = APP.serverUrlInput.value.trim() || 'http://localhost:11434';
            APP.ollamaModelName = APP.ollamaModelNameInput.value.trim() || 'llava:latest';
            APP.frameInterval = parseInt(APP.frameIntervalInput.value, 10) || 500;
            APP.imageQuality = parseFloat(APP.imageQualityInput.value) || 0.8;

            APP.ollamaOptions = {}; // Reset before populating

            // num_predict (Max Tokens for Ollama)
            const numPredictStr = APP.numPredictInput.value.trim();
            if (numPredictStr !== "") {
                const numPredict = parseInt(numPredictStr, 10);
                if (!isNaN(numPredict)) { // Ollama supports -1, -2
                    APP.ollamaOptions.num_predict = numPredict;
                }
            } else { // Default if empty
                APP.ollamaOptions.num_predict = 100;
            }

            // temperature
            const temperatureStr = APP.temperatureInput.value.trim();
            if (temperatureStr !== "") {
                const temperature = parseFloat(temperatureStr);
                if (!isNaN(temperature)) APP.ollamaOptions.temperature = temperature;
            }

            // top_p
            const topPStr = APP.topPInput.value.trim();
            if (topPStr !== "") {
                const topP = parseFloat(topPStr);
                if (!isNaN(topP)) APP.ollamaOptions.top_p = topP;
            }

            // top_k
            const topKStr = APP.topKInput.value.trim();
            if (topKStr !== "") {
                const topK = parseInt(topKStr, 10);
                if (!isNaN(topK) && topK >= 0) APP.ollamaOptions.top_k = topK;
            }

            // num_ctx (Context Size for Ollama)
            const numCtxStr = APP.numCtxInput.value.trim();
            if (numCtxStr !== "") {
                const numCtx = parseInt(numCtxStr, 10);
                if (!isNaN(numCtx) && numCtx > 0) APP.ollamaOptions.num_ctx = numCtx;
            }

            // repeat_penalty
            const repeatPenaltyStr = APP.repeatPenaltyInput.value.trim();
            if (repeatPenaltyStr !== "") {
                const repeatPenalty = parseFloat(repeatPenaltyStr);
                if (!isNaN(repeatPenalty)) APP.ollamaOptions.repeat_penalty = repeatPenalty;
            }
            console.log("Config loaded, Ollama options:", JSON.stringify(APP.ollamaOptions));
        };

        APP.setUIEnabledState = function(enabled) {
            const inputs = [
                APP.serverUrlInput, APP.ollamaModelNameInput, APP.frameIntervalInput,
                APP.imageQualityInput, APP.promptInput,
                APP.numCtxInput, APP.numPredictInput, APP.temperatureInput,
                APP.repeatPenaltyInput, APP.topKInput, APP.topPInput
            ];
            inputs.forEach(input => input.disabled = !enabled);
        };

        APP.captureImageAndGetDataURL = function() {
            if (!APP.cameraStream || !APP.videoElement.videoWidth || APP.videoElement.paused || APP.videoElement.ended) {
                console.warn("Video stream not ready or active for capture.");
                APP.statusElement.textContent = "Video stream not ready for capture.";
                return null;
            }
            if (APP.canvasElement.width !== APP.videoElement.videoWidth || APP.canvasElement.height !== APP.videoElement.videoHeight) {
                 APP.canvasElement.width = APP.videoElement.videoWidth;
                 APP.canvasElement.height = APP.videoElement.videoHeight;
            }
            APP.ctx.drawImage(APP.videoElement, 0, 0, APP.canvasElement.width, APP.canvasElement.height);
            const dataUrl = APP.canvasElement.toDataURL('image/jpeg', APP.imageQuality);
            return dataUrl.split(',')[1]; // Return only the Base64 part for Ollama
        }

        APP.sendDataToServer = async function() {
            if (!APP.isProcessing) return;

            const userPrompt = APP.promptInput.value; // Renamed for clarity
            const imageBase64 = APP.captureImageAndGetDataURL(); // Already just the base64 part

            if (!imageBase64) return;

            const requestUrl = `${APP.baseServerUrl}/api/generate`; // Ollama endpoint

            const requestBody = {
                model: APP.ollamaModelName,
                prompt: userPrompt,
                images: [imageBase64], // Array of base64 strings
                options: APP.ollamaOptions, // Spread collected parameters
                stream: false // Non-streaming, as per original's behavior
            };
            // Ensure options object is not empty if no options were set by user,
            // though an empty options object is usually fine for Ollama.
            // if (Object.keys(requestBody.options).length === 0) {
            //    delete requestBody.options; // Or ensure it's always present
            // }

            console.log("Sending request to Ollama:", requestUrl, "Body:", JSON.stringify(requestBody));

            try {
                const signal = APP.abortController ? APP.abortController.signal : undefined;
                if (signal && signal.aborted) return;

                const response = await fetch(requestUrl, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(requestBody),
                    signal: signal
                });

                if (!response.ok) {
                    const errorData = await response.text(); // Ollama might send plain text or JSON error
                    let detail = errorData;
                    try {
                        const errJson = JSON.parse(errorData);
                        if (errJson.error) detail = errJson.error;
                    } catch (e) { /* ignore if not json */ }
                    throw new Error(`Ollama API error: ${response.status} - ${detail}`);
                }

                const data = await response.json();
                if (data.response) { // Ollama's non-streaming response is in 'response' field
                    APP.answerElement.textContent = data.response;
                } else if (data.error) {
                    APP.answerElement.textContent = `Ollama error: ${data.error}`;
                }
                else {
                    APP.answerElement.textContent = "Unexpected response format from Ollama server.";
                    console.warn("Unexpected Ollama response format:", data);
                }
            } catch (error) {
                if (APP.isProcessing) {
                    if (error.name === 'AbortError') {
                        // Usually handled by stopProcessing status
                    } else {
                        console.error('Error sending data to Ollama:', error);
                        APP.answerElement.textContent = `Error: ${error.message}`;
                    }
                }
            }
        }

        APP.startProcessing = function() {
            if (!APP.cameraStream) {
                alert('Camera not available. Cannot start.');
                return;
            }
             if (!APP.ollamaModelNameInput.value.trim()) { // Check if model name is entered
                alert('Please enter an Ollama Model Name.');
                return;
            }

            APP.isProcessing = true;
            APP.abortController = new AbortController();

            APP.startButton.style.display = 'none';
            APP.stopButton.style.display = 'inline-block';
            APP.statusElement.textContent = 'Processing started...';
            APP.answerElement.textContent = 'Waiting for first response...';
            APP.setUIEnabledState(false);

            APP.loadConfigFromUI(); // Load latest config from UI

            APP.sendDataToServer();

            if (APP.intervalId) clearInterval(APP.intervalId);
            APP.intervalId = setInterval(APP.sendDataToServer, APP.frameInterval);
        };

        APP.stopProcessing = function() {
            APP.isProcessing = false;

            if (APP.intervalId) {
                clearInterval(APP.intervalId);
                APP.intervalId = null;
            }
            if (APP.abortController) {
                APP.abortController.abort();
                APP.abortController = null;
            }

            APP.startButton.style.display = 'inline-block';
            APP.stopButton.style.display = 'none';
            APP.statusElement.textContent = 'Processing stopped.';
            APP.setUIEnabledState(true);
        };

        APP.startButton.addEventListener('click', APP.startProcessing);
        APP.stopButton.addEventListener('click', APP.stopProcessing);

        window.addEventListener('DOMContentLoaded', APP.init);

        window.addEventListener('beforeunload', () => {
            if (APP.cameraStream) {
                APP.cameraStream.getTracks().forEach(track => track.stop());
            }
            if (APP.intervalId) {
                clearInterval(APP.intervalId);
            }
            if (APP.abortController) {
                APP.abortController.abort();
            }
        });
    </script>
</body>
</html>


いいなと思ったら応援しよう!