Webカメラを使ったリアルタイム処理(Windows。Ollamaを使ったツールの参考コード)
先日試した、Webカメラを使ったリアルタイム処理(llamacpp)の
体験がすごく心躍り、またライセンスも好きに使っていいとの大変有り難いお言葉。
そこで、Geminiの力を借りて自分用にコード変更してみました。
①llamacpp→Ollamaにツール変更
②設定できそうな項目をGUI上で追加(※正常動作しているかは不明)
動作の保証はできませんが、参考としてコードも掲載しておきます。
使い方はこんな感じ
マルチモーダルモデルをダウンロードし、使える状態にしておきます。
現在のOllamaサーバーを完全停止。
デスクトップ右下のタスクバーの
^(隠れているインジケーターを表示します)をクリックOllamaアイコンをクリック
Quit Ollamaをクリック



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

Webカメラを接続して認識できるようにしておく。
index_ollama_test.htmlをダブルクリック
最下段にある参考コードをテキストファイルに全コピぺし、
ファイル名を「ndex_ollama_test.html」に変更。
例:「新規 テキスト ドキュメント.txt」 → 「ndex_ollama_test.html」index_ollama_test.htmlをダブルクリック

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.

実施例



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

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

参考コード(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>