termux-vision 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/vlm.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Node.js VLM Engine & Supervised llama-cli Subprocess Bridge.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const { spawn } = require('child_process');
12
+
13
+ const {
14
+ VulkanNotAvailableError,
15
+ RuntimeNotFoundError,
16
+ TermuxVisionError
17
+ } = require('./errors');
18
+ const { ModelCacheManager } = require('./cache');
19
+
20
+ function isVulkanFailure(text) {
21
+ const lower = String(text).toLowerCase();
22
+ const markers = [
23
+ 'vulkan',
24
+ 'vk_error',
25
+ 'device lost',
26
+ 'failed to initialize gpu',
27
+ 'no vulkan device',
28
+ 'gpu backend',
29
+ 'vkcreateinstance',
30
+ 'vkcreatedevice',
31
+ 'ggml_vulkan'
32
+ ];
33
+ return markers.some(m => lower.includes(m));
34
+ }
35
+
36
+ function resolveLlamaCli(explicitPath = null) {
37
+ if (explicitPath) {
38
+ const p = path.resolve(explicitPath);
39
+ if (fs.existsSync(p)) return p;
40
+ throw new RuntimeNotFoundError([p]);
41
+ }
42
+
43
+ if (process.env.TERMUX_VISION_LLAMA_CLI && fs.existsSync(process.env.TERMUX_VISION_LLAMA_CLI)) {
44
+ return process.env.TERMUX_VISION_LLAMA_CLI;
45
+ }
46
+
47
+ const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr';
48
+ const candidates = [
49
+ 'llama-cli',
50
+ path.join(prefix, 'bin', 'llama-cli'),
51
+ path.join(prefix, 'bin', 'termux-llama-cli'),
52
+ path.join(os.homedir(), '.termux-llamacpp', 'current', 'bin', 'llama-cli'),
53
+ path.join(os.homedir(), '.local', 'bin', 'llama-cli'),
54
+ path.join(os.homedir(), 'bin', 'llama-cli')
55
+ ];
56
+
57
+ const pathDirs = (process.env.PATH || '').split(path.delimiter);
58
+ for (const dir of pathDirs) {
59
+ const full = path.join(dir, 'llama-cli' + (process.platform === 'win32' ? '.exe' : ''));
60
+ if (fs.existsSync(full)) return full;
61
+ }
62
+
63
+ for (const c of candidates) {
64
+ if (fs.existsSync(c)) return c;
65
+ }
66
+
67
+ throw new RuntimeNotFoundError(candidates);
68
+ }
69
+
70
+ class NodeVLMContext {
71
+ constructor(options) {
72
+ this.manifest = options.manifest;
73
+ this.textModelPath = options.textModelPath;
74
+ this.visionModelPath = options.visionModelPath;
75
+ this.executable = options.executable;
76
+ this.threads = options.threads || 4;
77
+ this.backend = options.backend || 'cpu';
78
+ this.fallback = options.fallback !== false;
79
+ this.contextLimit = options.contextLimit || options.manifest.contextLimit || 1024;
80
+ this.customNgl = options.ngl;
81
+ this.closed = false;
82
+ }
83
+
84
+ async _executeOnce(imagePath, prompt, options, targetBackend) {
85
+ const nglVal = this.customNgl !== undefined && this.customNgl !== null ? String(this.customNgl) : (targetBackend === 'vulkan' ? '99' : '0');
86
+ const tmpPrompt = path.join(os.tmpdir(), `tv_prompt_${Date.now()}_${Math.random().toString(36).substring(7)}.txt`);
87
+
88
+ const formattedPrompt = options.systemPrompt ?
89
+ `System: ${options.systemPrompt}\n<image>\nUser: ${prompt}\nAssistant:` :
90
+ `<image>\nUser: ${prompt}\nAssistant:`;
91
+ fs.writeFileSync(tmpPrompt, formattedPrompt, 'utf-8');
92
+
93
+ const cliArgs = [
94
+ '-m', this.textModelPath,
95
+ '--mmproj', this.visionModelPath,
96
+ '--image', imagePath,
97
+ '-f', tmpPrompt,
98
+ '-st',
99
+ '-t', String(this.threads),
100
+ '-c', String(this.contextLimit),
101
+ '-n', String(options.maxTokens || 150),
102
+ '--temp', String(options.temperature !== undefined ? options.temperature : 0.2),
103
+ '-ngl', nglVal
104
+ ];
105
+
106
+ if (options.repeatPenalty !== undefined) cliArgs.push('--repeat-penalty', String(options.repeatPenalty));
107
+ if (options.topP !== undefined) cliArgs.push('--top-p', String(options.topP));
108
+ if (options.topK !== undefined) cliArgs.push('--top-k', String(options.topK));
109
+ if (options.seed !== undefined) cliArgs.push('-s', String(options.seed));
110
+
111
+ return new Promise((resolve, reject) => {
112
+ const t0 = Date.now();
113
+ const proc = spawn(this.executable, cliArgs, {
114
+ stdio: ['ignore', 'pipe', 'pipe']
115
+ });
116
+
117
+ let stdoutData = '';
118
+ let stderrData = '';
119
+
120
+ proc.stdout.on('data', (d) => { stdoutData += d.toString('utf-8'); });
121
+ proc.stderr.on('data', (d) => { stderrData += d.toString('utf-8'); });
122
+
123
+ proc.on('close', (code, signal) => {
124
+ try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
125
+ const totalMs = Date.now() - t0;
126
+
127
+ if (code !== 0) {
128
+ const errText = (stderrData.trim() || stdoutData.trim());
129
+ if (code === 137 || signal === 'SIGKILL' || code === -9) {
130
+ return reject(new TermuxVisionError(
131
+ `VLM inference process was terminated by system (OOM / LowMemoryKiller / SIGKILL).\n` +
132
+ `[Action Recommendation] Use a smaller model (e.g. smolvlm-500m-q4), reduce threads (-t 2), or close background apps.`
133
+ ));
134
+ }
135
+ return reject(new TermuxVisionError(`llama-cli exited with code ${code}: ${errText}`));
136
+ }
137
+
138
+ const lines = stdoutData.split('\n');
139
+ const contentLines = [];
140
+ let startCapture = false;
141
+ let tps = null;
142
+
143
+ for (const line of lines) {
144
+ const trimmed = line.trim();
145
+ if (trimmed.startsWith('>')) {
146
+ startCapture = true;
147
+ continue;
148
+ }
149
+ if (startCapture) {
150
+ if (line.includes('Generation:') && line.includes('t/s')) {
151
+ try {
152
+ const match = line.match(/Generation:\s*([0-9.]+)\s*t\/s/);
153
+ if (match) tps = parseFloat(match[1]);
154
+ } catch (e) {}
155
+ continue;
156
+ }
157
+ if (line.includes('Exiting') || line.includes('main: image')) continue;
158
+ const cleaned = line.replace(/[|\-\/\\]/g, '').trim();
159
+ if (cleaned) contentLines.push(cleaned);
160
+ }
161
+ }
162
+
163
+ const textOutput = contentLines.length > 0 ? contentLines.join(' ') : stdoutData.trim();
164
+ resolve({
165
+ text: textOutput,
166
+ finishReason: 'stop',
167
+ wordCount: textOutput.split(/\s+/).filter(Boolean).length,
168
+ metrics: {
169
+ backend: targetBackend,
170
+ modelId: this.manifest.modelId,
171
+ decodeMs: totalMs,
172
+ tokensPerSecond: tps
173
+ },
174
+ warnings: []
175
+ });
176
+ });
177
+
178
+ proc.on('error', (err) => {
179
+ try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
180
+ reject(err);
181
+ });
182
+ });
183
+ }
184
+
185
+ async describe(imagePath, options = {}) {
186
+ if (this.closed) throw new Error('Cannot describe with closed VLMContext.');
187
+ if (!imagePath || typeof imagePath !== 'string' || !imagePath.trim()) {
188
+ throw new Error("Parameter 'imagePath' cannot be null or empty.");
189
+ }
190
+ const resolvedImg = path.resolve(imagePath.replace(/^~(?=$|\/|\\)/, os.homedir()));
191
+ if (!fs.existsSync(resolvedImg)) {
192
+ throw new Error(`Input image file not found: '${resolvedImg}'`);
193
+ }
194
+
195
+ const prompt = options.prompt || '이 사진 속 인물의 표정, 옷차림, 자세, 그리고 배경 환경을 한국어로 간결하게 요약 설명해줘.';
196
+ if (!prompt || !prompt.trim()) {
197
+ throw new Error("Parameter 'prompt' cannot be empty.");
198
+ }
199
+
200
+ // Strict validation
201
+ if (options.maxTokens !== undefined && options.maxTokens <= 0) {
202
+ throw new Error(`Parameter 'maxTokens' must be > 0. Received: ${options.maxTokens}`);
203
+ }
204
+ if (options.temperature !== undefined && options.temperature < 0) {
205
+ throw new Error(`Parameter 'temperature' must be >= 0. Received: ${options.temperature}`);
206
+ }
207
+ if (options.topP !== undefined && (options.topP <= 0 || options.topP > 1.0)) {
208
+ throw new Error(`Parameter 'topP' must be between (0.0, 1.0]. Received: ${options.topP}`);
209
+ }
210
+
211
+ try {
212
+ return await this._executeOnce(resolvedImg, prompt, options, this.backend);
213
+ } catch (err) {
214
+ if (this.backend === 'vulkan') {
215
+ if (this.fallback && isVulkanFailure(err.message)) {
216
+ const fallbackRes = await this._executeOnce(resolvedImg, prompt, options, 'cpu');
217
+ fallbackRes.warnings.push(`Vulkan execution failed; retried on CPU: ${err.message}`);
218
+ return fallbackRes;
219
+ } else {
220
+ throw new VulkanNotAvailableError(err.message);
221
+ }
222
+ }
223
+ throw err;
224
+ }
225
+ }
226
+
227
+ async ask(imagePath, question, options = {}) {
228
+ if (!question || !question.trim()) throw new Error("Parameter 'question' cannot be null or empty.");
229
+ const res = await this.describe(imagePath, { ...options, prompt: question });
230
+ return res.text;
231
+ }
232
+
233
+ close() {
234
+ this.closed = true;
235
+ }
236
+ }
237
+
238
+ async function load(options = {}) {
239
+ const cache = new ModelCacheManager(options.cacheRoot || null);
240
+ const modelId = options.modelId || 'smolvlm-500m-q4';
241
+
242
+ if (!modelId || !String(modelId).trim()) {
243
+ throw new Error("Parameter 'modelId' cannot be null or empty.");
244
+ }
245
+
246
+ if (options.allowDownload && !cache.isModelInstalled(modelId)) {
247
+ await cache.install(modelId);
248
+ }
249
+
250
+ const modelInfo = cache.requireInstalledModel(modelId, options.mmprojPath || null);
251
+ const executable = resolveLlamaCli(options.runtimePath || null);
252
+
253
+ const reqDevice = (options.device || 'auto').toLowerCase().trim();
254
+ let actualBackend = 'cpu';
255
+ let actualFallback = false;
256
+
257
+ if (reqDevice === 'auto') {
258
+ actualBackend = 'vulkan';
259
+ actualFallback = true;
260
+ } else if (['vulkan', 'gpu', 'vulkan-force'].includes(reqDevice)) {
261
+ actualBackend = 'vulkan';
262
+ actualFallback = false;
263
+ } else {
264
+ actualBackend = 'cpu';
265
+ actualFallback = false;
266
+ }
267
+
268
+ let threads = 4;
269
+ if (typeof options.threads === 'number') {
270
+ if (options.threads <= 0) throw new Error(`Parameter 'threads' must be > 0. Received: ${options.threads}`);
271
+ threads = Math.max(1, Math.min(128, options.threads));
272
+ }
273
+
274
+ return new NodeVLMContext({
275
+ manifest: modelInfo.manifest,
276
+ textModelPath: modelInfo.textModelPath,
277
+ visionModelPath: modelInfo.visionModelPath,
278
+ executable: executable,
279
+ threads: threads,
280
+ backend: actualBackend,
281
+ fallback: actualFallback,
282
+ contextLimit: options.contextLimit,
283
+ ngl: options.ngl
284
+ });
285
+ }
286
+
287
+ module.exports = {
288
+ load,
289
+ resolveLlamaCli,
290
+ NodeVLMContext
291
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "termux-vision",
3
+ "version": "1.0.0",
4
+ "description": "Native On-Device Computer Vision & VLM Multimodal Inference Framework for Android Termux & ARM64 (Dual-Engine Python & Node.js/TypeScript)",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "bin": {
8
+ "termux-vision": "bin/cli.js",
9
+ "tv": "bin/cli.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "index.d.ts",
14
+ "bin",
15
+ "lib",
16
+ "README.md",
17
+ "LICENSE",
18
+ "NOTICE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node tests/node_smoke.test.js"
22
+ },
23
+ "keywords": [
24
+ "termux",
25
+ "vision",
26
+ "vlm",
27
+ "multimodal",
28
+ "computer-vision",
29
+ "smolvlm",
30
+ "qwen2-vl",
31
+ "edge-ai",
32
+ "on-device-ai",
33
+ "vulkan",
34
+ "arm64",
35
+ "android",
36
+ "llama-cli",
37
+ "gguf",
38
+ "canny",
39
+ "haar-cascade",
40
+ "open-source"
41
+ ],
42
+ "author": "uno-km (AMEVA Foundation) <dev@amevafoundation.org>",
43
+ "license": "Apache-2.0",
44
+ "homepage": "https://uno-km.vercel.app/lib/vision/",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/uno-km/termux-vision.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/uno-km/termux-vision/issues"
51
+ },
52
+ "engines": {
53
+ "node": ">=16.0.0"
54
+ }
55
+ }