Browse Source

Add CNN inference API support

Toby Chui 6 days ago
parent
commit
d2cfed67c2

+ 53 - 0
src/cnn_settings.go

@@ -0,0 +1,53 @@
+package main
+
+import (
+	"net/http"
+
+	prout "imuslab.com/arozos/mod/prouter"
+	"imuslab.com/arozos/mod/utils"
+)
+
+/*
+	CNN Inference Settings Manager
+
+	Registers the "CNN Inference" tab in System Settings > AI Integration and
+	exposes the admin-only endpoints used to configure the external CXNNAIO
+	vision-inference server (endpoint, token, request timeout) and to test
+	connectivity against it.
+
+	The AGI "cnn" library (mod/agi/agi.cnn.go) consumes the same configuration
+	and is what AGI scripts actually call via requirelib("cnn"); the wire
+	protocol itself lives in the standalone mod/aiservers/cnn client package.
+
+	  GET  /system/cnn/config  – masked connection config
+	  POST /system/cnn/config  – save connection config
+	  POST /system/cnn/test    – connectivity test (health + model listing)
+
+	All endpoints require administrator privileges.
+*/
+
+func CNNInferenceSettingInit() {
+	//Register the settings tab in the "AI Integration" group.
+	registerSetting(settingModule{
+		Name:         "CNN Inference",
+		Desc:         "Configure an external CXNNAIO vision-inference server for image/face recognition",
+		IconPath:     "SystemAO/system_setting/img/cnn.svg",
+		Group:        "AInteg",
+		StartDir:     "SystemAO/advance/cnninference.html",
+		RequireAdmin: true,
+	})
+
+	//Admin-only router. The connection config may contain a sensitive API
+	//token, so every endpoint here is restricted to administrators.
+	adminRouter := prout.NewModuleRouter(prout.RouterOption{
+		ModuleName:  "System Settings",
+		AdminOnly:   true,
+		UserHandler: userHandler,
+		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
+			utils.SendErrorResponse(w, "Permission Denied")
+		},
+	})
+
+	adminRouter.HandleFunc("/system/cnn/config", AGIGateway.HandleCNNConfig)
+	adminRouter.HandleFunc("/system/cnn/test", AGIGateway.HandleCNNTest)
+}

+ 136 - 0
src/mod/agi/README.md

@@ -272,6 +272,7 @@ Registered library IDs:
 - `ziplib` (includes 7z support via `ziplib.extract7zFile`, `ziplib.list7zFileContents`, etc.)
 - `sqlite` (SQLite database access — not available on linux/mipsle or windows/arm/386)
 - `aimodel` (OpenAI / Anthropic LLM chat: text & file based, with pricing & quota)
+- `cnn` (CXNNAIO vision inference: classification, detection, segmentation, pose, oriented detection, face analysis)
 - `ffmpeg` (only when ffmpeg exists on host)
 
 Special case:
@@ -1000,6 +1001,141 @@ All functions that accept `options` support the following fields (all optional):
 | `temperature` | number | Sampling temperature |
 | `max_tokens` | number | Maximum tokens to generate |
 
+## cnn API
+
+Load:
+
+```javascript
+requirelib("cnn");
+```
+
+The `cnn` library connects to an external **CXNNAIO** vision-inference server
+configured by an admin in **System Settings > AI Integration > CNN
+Inference** (endpoint, optional bearer token, request timeout). Every
+function reads its input image from a virtual file path (the calling user
+must have read access); the returned object is the server's own response
+envelope (`object`, `model`, `created`, `image`, `timing_ms`, `data`, ...),
+so it matches the CXNNAIO API documentation field-for-field.
+
+### `cnn.classify(file, options)` → object
+Image classification (default model `mobilenet-v2`).
+
+```javascript
+requirelib("cnn");
+var r = cnn.classify("user:/Photos/cat.jpg", { top_k: 3 });
+sendJSONResp(r.data); // [{ label, index, score }, ...]
+```
+
+### `cnn.detect(file, options)` → object
+Object detection (default model `yolo11n`).
+
+```javascript
+requirelib("cnn");
+var r = cnn.detect("user:/Photos/street.jpg", { score_threshold: 0.3, render: true });
+sendJSONResp(r.data);              // [{ label, class_id, score, box:{x1,y1,x2,y2} }, ...]
+// r.rendered_image is a data URI PNG when render:true was set
+```
+
+### `cnn.segment(file, options)` → object
+Instance segmentation (`yolo11n-seg`). Each item carries a per-instance,
+box-cropped mask (`mask.data` is a base64 PNG).
+
+### `cnn.pose(file, options)` → object
+Pose estimation (`yolo11n-pose`), 17 COCO keypoints per detected person.
+
+### `cnn.oriented(file, options)` → object
+Oriented/rotated-box detection (`yolo11n-obb`), intended for aerial/top-down imagery.
+
+### `cnn.faceDetect(file, options)` → object
+Face detection (default model `ultraface-rfb-320`).
+
+### `cnn.faceLandmarks(file, options)` → object
+98-point facial landmarks (`pfld`). Set `options.cropped = true` to treat the
+whole input image as one face crop instead of detecting faces first.
+
+### `cnn.faceEmbedding(file, options)` → object
+L2-normalized 128-d face embedding vector(s) (`mbv2facenet`).
+
+### `cnn.faceAttributes(file, options)` → object
+Gender attributes per face (`gender-mbv2-0.35`). Calls the server's
+`/v1/faces/gender` route (the upstream API doc names this endpoint
+`/v1/faces/attributes`, but the deployed server registers it as `gender`;
+the response `object` field reads `"face.gender"`).
+
+### `cnn.faceCompare(fileA, fileB, options)` → object
+Compares two face photos/crops and returns their cosine similarity. Does not
+support `options.async` (the server has no async variant for this endpoint).
+
+```javascript
+requirelib("cnn");
+var r = cnn.faceCompare("user:/a.jpg", "user:/b.jpg", { threshold: 0.5 });
+sendResp(r.similarity + " - " + (r.same ? "same person" : "different"));
+```
+
+### `cnn.analyze(file, tasks, options)` → object
+Runs several tasks over one image in a single round trip. `tasks` is an
+array (`"classify"`, `"detect"`, `"segment"`, `"pose"`, `"oriented"`,
+`"faces"`, `"landmarks"`, `"attributes"`); `options` carries an optional
+top-level `render`/`async` plus per-task parameter blocks keyed by task name.
+
+```javascript
+requirelib("cnn");
+var r = cnn.analyze("user:/group.jpg", ["detect", "faces"], {
+    render: true,
+    detect: { score_threshold: 0.3 }
+});
+sendJSONResp(r.results.detect.data);
+document_rendered = r.rendered_image; // data URI PNG
+```
+
+### `cnn.job(id)` → object
+Polls an async job (see Async below). Returns
+`{ id, object, status, created, result, error }` where `status` is one of
+`"queued"`, `"running"`, `"succeeded"` or `"failed"`.
+
+### `cnn.models()` → object
+Live model registry from the configured server: `{ object, data: [{ id, object, task, classes, input }, ...] }`.
+
+### `cnn.health()` → object
+Live server health: `{ status, version, models_loaded, sessions, uptime_s }`.
+
+### Async jobs
+
+Every single-image function (`classify`, `detect`, `segment`, `pose`,
+`oriented`, `faceDetect`, `faceLandmarks`, `faceEmbedding`,
+`faceAttributes`, `analyze`) accepts `options.async = true`. When set, the
+function returns immediately with a job object instead of blocking:
+
+```javascript
+requirelib("cnn");
+var job = cnn.detect("user:/big.jpg", { async: true });
+while (job.status === "queued" || job.status === "running") {
+    delay(500);
+    job = cnn.job(job.id);
+}
+sendJSONResp(job.status === "succeeded" ? job.result : job.error);
+```
+
+### Options object
+
+All single-image functions accept the same `options` fields, using the
+server's own field names so they match the CXNNAIO API documentation
+directly (all optional):
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `model` | string | Override the server's default model for this task |
+| `score_threshold` | number | Minimum confidence to keep (detect/seg/pose/oriented/faces) |
+| `nms_threshold` | number | IoU suppression threshold (detect/seg/pose/oriented/faces) |
+| `top_k` | number | Number of ranked results (classification) |
+| `max_results` | number | Cap on returned items |
+| `render` | bool | Also return an annotated PNG in `rendered_image` |
+| `cropped` | bool | Treat the whole input image as one face crop (face endpoints) |
+| `async` | bool | Submit as an async job instead of blocking (see Async jobs) |
+
+`cnn.faceCompare` uses its own options shape instead: `model`, `threshold`,
+`a_cropped`, `b_cropped`.
+
 ## ffmpeg API
 
 Load:

+ 550 - 0
src/mod/agi/agi.cnn.go

@@ -0,0 +1,550 @@
+package agi
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"mime"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/robertkrimen/otto"
+
+	"imuslab.com/arozos/mod/agi/static"
+	cnn "imuslab.com/arozos/mod/aiservers/cnn"
+	"imuslab.com/arozos/mod/filesystem"
+	user "imuslab.com/arozos/mod/user"
+	"imuslab.com/arozos/mod/utils"
+)
+
+/*
+	AJGI CNN Inference Library
+
+	This library lets AGI scripts run image classification, object detection,
+	segmentation, pose, oriented (OBB) detection and face analysis (detection,
+	landmarks, embedding, comparison, attributes) against an external CXNNAIO
+	vision-inference server. The transport/wire-format logic lives in the
+	standalone mod/aiservers/cnn client package; this file only owns the
+	ArozOS-specific bits: admin-configured connection settings (System
+	Settings > AI Integration > CNN Inference) and the Otto VM bindings.
+
+	Author: tobychui (AGI), CNN Inference lib addition
+*/
+
+const (
+	//cnnDBTable is the system database table used to persist the CNN server
+	//connection settings.
+	cnnDBTable = "cnnserver"
+
+	//cnnTokenMask is the sentinel value the frontend submits when the token
+	//field was left untouched. When received, the stored token is kept.
+	cnnTokenMask = "********"
+
+	//cnnDefaultTimeoutSeconds is used when no timeout has been configured.
+	cnnDefaultTimeoutSeconds = 60
+)
+
+// CNNServerConfig holds the admin-configured connection settings for the
+// external CXNNAIO vision-inference server.
+type CNNServerConfig struct {
+	Endpoint       string `json:"endpoint"`       //Base URL, e.g. http://localhost:8080
+	Token          string `json:"token"`          //Bearer token; empty for a server running in no_auth mode
+	TimeoutSeconds int    `json:"timeoutSeconds"` //Per-request client timeout
+}
+
+// ── Library registration ─────────────────────────────────────────────────────
+
+func (g *Gateway) CNNLibRegister() {
+	//Make sure the storage table exists before any read / write happens.
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if !sysdb.TableExists(cnnDBTable) {
+		sysdb.NewTable(cnnDBTable)
+	}
+
+	err := g.RegisterLib("cnn", g.injectCNNFunctions)
+	if err != nil {
+		agiLogger.PrintAndLog("Agi", fmt.Sprint(err), nil)
+		os.Exit(1)
+	}
+}
+
+func (g *Gateway) injectCNNFunctions(payload *static.AgiLibInjectionPayload) {
+	vm := payload.VM
+	u := payload.User
+	scriptFsh := payload.ScriptFsh
+
+	//cnn.classify(file, options) => image.classification envelope
+	vm.Set("_cnn_classify", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Classify(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.detect(file, options) => image.detection envelope
+	vm.Set("_cnn_detect", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Detect(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.segment(file, options) => image.segmentation envelope
+	vm.Set("_cnn_segment", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Segment(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.pose(file, options) => image.pose envelope
+	vm.Set("_cnn_pose", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Pose(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.oriented(file, options) => image.oriented envelope
+	vm.Set("_cnn_oriented", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Oriented(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.faceDetect(file, options) => face.detection envelope
+	vm.Set("_cnn_faceDetect", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.FaceDetect(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.faceLandmarks(file, options) => face.landmarks envelope
+	vm.Set("_cnn_faceLandmarks", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.FaceLandmarks(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.faceEmbedding(file, options) => face.embedding envelope
+	vm.Set("_cnn_faceEmbedding", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.FaceEmbedding(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.faceAttributes(file, options) => face.gender envelope (see FaceAttributes doc)
+	vm.Set("_cnn_faceAttributes", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNOptions(getOttoStringArg(call, 1))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.FaceAttributes(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.faceCompare(fileA, fileB, options) => face.comparison object (no async support)
+	vm.Set("_cnn_faceCompare", func(call otto.FunctionCall) otto.Value {
+		dataA, mimeA, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		dataB, mimeB, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 1))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		opt := parseCNNComparisonOptions(getOttoStringArg(call, 2))
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, err := client.FaceCompare(dataA, dataB, mimeA, mimeB, opt)
+		return cnnRespond(vm, result, nil, err)
+	})
+
+	//cnn.analyze(file, tasks, options) => vision.analysis envelope
+	//options may carry top-level "render"/"async" flags plus a per-task
+	//options block keyed by task name (e.g. { detect: {...}, render: true }).
+	vm.Set("_cnn_analyze", func(call otto.FunctionCall) otto.Value {
+		data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+
+		var tasks []string
+		if err := json.Unmarshal([]byte(getOttoStringArg(call, 1)), &tasks); err != nil || len(tasks) == 0 {
+			panic(vm.MakeCustomError("CNNError", "no tasks specified"))
+		}
+
+		raw := map[string]json.RawMessage{}
+		json.Unmarshal([]byte(getOttoStringArg(call, 2)), &raw)
+		opt := cnn.AnalyzeOptions{Tasks: tasks, Options: map[string]json.RawMessage{}}
+		for k, v := range raw {
+			switch k {
+			case "render":
+				json.Unmarshal(v, &opt.Render)
+			case "async":
+				json.Unmarshal(v, &opt.Async)
+			default:
+				opt.Options[k] = v
+			}
+		}
+
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		result, job, err := client.Analyze(data, mimeType, opt)
+		return cnnRespond(vm, result, job, err)
+	})
+
+	//cnn.job(id) => poll an async job submitted with options.async = true
+	vm.Set("_cnn_job", func(call otto.FunctionCall) otto.Value {
+		id, _ := call.Argument(0).ToString()
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		job, err := client.GetJob(id)
+		return cnnRespond(vm, job, nil, err)
+	})
+
+	//cnn.models() => live model registry from the configured server
+	vm.Set("_cnn_models", func(call otto.FunctionCall) otto.Value {
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		models, err := client.ListModels()
+		return cnnRespond(vm, models, nil, err)
+	})
+
+	//cnn.health() => live health/status from the configured server
+	vm.Set("_cnn_health", func(call otto.FunctionCall) otto.Value {
+		client, err := g.cnnClient()
+		if err != nil {
+			panic(vm.MakeCustomError("CNNError", err.Error()))
+		}
+		health, err := client.Health()
+		return cnnRespond(vm, health, nil, err)
+	})
+
+	//Wrap the native functions into a clean cnn class
+	vm.Run(`
+		var cnn = {};
+		cnn.classify = function(file, options){
+			return JSON.parse(_cnn_classify(file, JSON.stringify(options || {})));
+		};
+		cnn.detect = function(file, options){
+			return JSON.parse(_cnn_detect(file, JSON.stringify(options || {})));
+		};
+		cnn.segment = function(file, options){
+			return JSON.parse(_cnn_segment(file, JSON.stringify(options || {})));
+		};
+		cnn.pose = function(file, options){
+			return JSON.parse(_cnn_pose(file, JSON.stringify(options || {})));
+		};
+		cnn.oriented = function(file, options){
+			return JSON.parse(_cnn_oriented(file, JSON.stringify(options || {})));
+		};
+		cnn.faceDetect = function(file, options){
+			return JSON.parse(_cnn_faceDetect(file, JSON.stringify(options || {})));
+		};
+		cnn.faceLandmarks = function(file, options){
+			return JSON.parse(_cnn_faceLandmarks(file, JSON.stringify(options || {})));
+		};
+		cnn.faceEmbedding = function(file, options){
+			return JSON.parse(_cnn_faceEmbedding(file, JSON.stringify(options || {})));
+		};
+		cnn.faceAttributes = function(file, options){
+			return JSON.parse(_cnn_faceAttributes(file, JSON.stringify(options || {})));
+		};
+		cnn.faceCompare = function(fileA, fileB, options){
+			return JSON.parse(_cnn_faceCompare(fileA, fileB, JSON.stringify(options || {})));
+		};
+		cnn.analyze = function(file, tasks, options){
+			return JSON.parse(_cnn_analyze(file, JSON.stringify(tasks || []), JSON.stringify(options || {})));
+		};
+		cnn.job = function(id){
+			return JSON.parse(_cnn_job(id));
+		};
+		cnn.models = function(){
+			return JSON.parse(_cnn_models());
+		};
+		cnn.health = function(){
+			return JSON.parse(_cnn_health());
+		};
+	`)
+}
+
+// ── Core helpers ──────────────────────────────────────────────────────────────
+
+// cnnClient builds a cnn.Client from the persisted configuration.
+func (g *Gateway) cnnClient() (*cnn.Client, error) {
+	cfg := g.getCNNConfig()
+	if strings.TrimSpace(cfg.Endpoint) == "" {
+		return nil, errors.New("CNN inference server is not configured (System Settings > AI Integration > CNN Inference)")
+	}
+	return cnn.NewClient(cfg.Endpoint, cfg.Token, time.Duration(cfg.TimeoutSeconds)*time.Second), nil
+}
+
+// cnnReadImage resolves a script vpath to its raw bytes and a best-effort
+// mime type, enforcing the calling user's read permission.
+func (g *Gateway) cnnReadImage(scriptFsh *filesystem.FileSystemHandler, vm *otto.Otto, u *user.User, vpath string) ([]byte, string, error) {
+	//Resolve relative paths against the script's directory
+	vpath = static.RelativeVpathRewrite(scriptFsh, vpath, vm, u)
+
+	if !u.CanRead(vpath) {
+		return nil, "", errors.New("permission denied: " + vpath)
+	}
+
+	fsh, rpath, err := static.VirtualPathToRealPath(vpath, u)
+	if err != nil {
+		return nil, "", err
+	}
+	if !fsh.FileSystemAbstraction.FileExists(rpath) {
+		return nil, "", errors.New("file not found: " + vpath)
+	}
+
+	content, err := fsh.FileSystemAbstraction.ReadFile(rpath)
+	if err != nil {
+		return nil, "", err
+	}
+
+	ext := strings.ToLower(filepath.Ext(rpath))
+	if !cnnIsImageExt(ext) {
+		return nil, "", errors.New("unsupported file type for CNN inference: " + filepath.Base(rpath) + " (expected an image)")
+	}
+	mimeType := mime.TypeByExtension(ext)
+	if mimeType == "" {
+		mimeType = "image/" + strings.TrimPrefix(ext, ".")
+	}
+	return content, mimeType, nil
+}
+
+// cnnRespond converts a client call's (result, job, err) trio into the otto
+// value returned to the script: an error panics, an async submission returns
+// the job object, otherwise the typed result is marshalled back as-is so the
+// script receives the exact server envelope shape.
+func cnnRespond(vm *otto.Otto, result interface{}, job *cnn.Job, err error) otto.Value {
+	if err != nil {
+		panic(vm.MakeCustomError("CNNError", err.Error()))
+	}
+	var out []byte
+	if job != nil {
+		out, _ = json.Marshal(job)
+	} else {
+		out, _ = json.Marshal(result)
+	}
+	reply, _ := vm.ToValue(string(out))
+	return reply
+}
+
+func cnnIsImageExt(ext string) bool {
+	switch ext {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
+		return true
+	}
+	return false
+}
+
+func parseCNNOptions(s string) cnn.RequestOptions {
+	opt := cnn.RequestOptions{}
+	s = strings.TrimSpace(s)
+	if s == "" || s == "undefined" || s == "null" {
+		return opt
+	}
+	json.Unmarshal([]byte(s), &opt)
+	return opt
+}
+
+func parseCNNComparisonOptions(s string) cnn.ComparisonOptions {
+	opt := cnn.ComparisonOptions{}
+	s = strings.TrimSpace(s)
+	if s == "" || s == "undefined" || s == "null" {
+		return opt
+	}
+	json.Unmarshal([]byte(s), &opt)
+	return opt
+}
+
+// ── Persistence helpers ───────────────────────────────────────────────────────
+
+func (g *Gateway) getCNNConfig() CNNServerConfig {
+	cfg := CNNServerConfig{TimeoutSeconds: cnnDefaultTimeoutSeconds}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.KeyExists(cnnDBTable, "config") {
+		sysdb.Read(cnnDBTable, "config", &cfg)
+		if cfg.TimeoutSeconds <= 0 {
+			cfg.TimeoutSeconds = cnnDefaultTimeoutSeconds
+		}
+	}
+	return cfg
+}
+
+func cnnMaskToken(token string) string {
+	if token == "" {
+		return ""
+	}
+	if len(token) <= 4 {
+		return strings.Repeat("•", len(token))
+	}
+	return "••••" + token[len(token)-4:]
+}
+
+// ── HTTP handlers (System Settings) ──────────────────────────────────────────
+
+// HandleCNNConfig serves GET (masked config) and POST (save config).
+// GET  /system/cnn/config
+// POST /system/cnn/config  (endpoint, timeoutSeconds, token, cleartoken)
+func (g *Gateway) HandleCNNConfig(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodGet {
+		cfg := g.getCNNConfig()
+		js, _ := json.Marshal(map[string]interface{}{
+			"endpoint":       cfg.Endpoint,
+			"timeoutSeconds": cfg.TimeoutSeconds,
+			"hasToken":       cfg.Token != "",
+			"tokenHint":      cnnMaskToken(cfg.Token),
+		})
+		utils.SendJSONResponse(w, string(js))
+		return
+	}
+
+	//POST - save. Read raw form values so an empty endpoint can intentionally
+	//clear the configuration.
+	r.ParseForm()
+	cfg := g.getCNNConfig()
+	cfg.Endpoint = strings.TrimSpace(r.Form.Get("endpoint"))
+	if t, err := strconv.Atoi(strings.TrimSpace(r.Form.Get("timeoutSeconds"))); err == nil && t > 0 {
+		cfg.TimeoutSeconds = t
+	}
+
+	//Token: only overwrite when a new, non-sentinel value is supplied.
+	if clear, _ := utils.PostBool(r, "cleartoken"); clear {
+		cfg.Token = ""
+	} else if token := r.Form.Get("token"); token != "" && token != cnnTokenMask {
+		cfg.Token = token
+	}
+
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if err := sysdb.Write(cnnDBTable, "config", cfg); err != nil {
+		utils.SendErrorResponse(w, "failed to save config: "+err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleCNNTest performs a connectivity check against the CXNNAIO server:
+// health status plus the live model registry. Accepts optional unsaved
+// endpoint/token overrides so the admin can test before saving.
+// POST /system/cnn/test
+func (g *Gateway) HandleCNNTest(w http.ResponseWriter, r *http.Request) {
+	cfg := g.getCNNConfig()
+	endpoint := cfg.Endpoint
+	token := cfg.Token
+	timeoutSeconds := cfg.TimeoutSeconds
+	if ep := strings.TrimSpace(r.FormValue("endpoint")); ep != "" {
+		endpoint = ep
+	}
+	if tk := r.FormValue("token"); tk != "" && tk != cnnTokenMask {
+		token = tk
+	}
+
+	if strings.TrimSpace(endpoint) == "" {
+		utils.SendErrorResponse(w, "endpoint not configured")
+		return
+	}
+
+	client := cnn.NewClient(endpoint, token, time.Duration(timeoutSeconds)*time.Second)
+	health, err := client.Health()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	models, err := client.ListModels()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	out, _ := json.Marshal(map[string]interface{}{
+		"ok":           true,
+		"status":       health.Status,
+		"version":      health.Version,
+		"modelsLoaded": health.ModelsLoaded,
+		"sessions":     health.Sessions,
+		"uptimeS":      health.UptimeS,
+		"modelCount":   len(models.Data),
+		"models":       models.Data,
+	})
+	utils.SendJSONResponse(w, string(out))
+}

+ 218 - 0
src/mod/agi/agi.cnn_test.go

@@ -0,0 +1,218 @@
+package agi
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+	user "imuslab.com/arozos/mod/user"
+)
+
+// ─── pure helpers ─────────────────────────────────────────────────────────────
+
+func TestCNNMaskToken(t *testing.T) {
+	cases := map[string]string{
+		"":               "",
+		"abc":            "•••",
+		"cxn-1234567890": "••••7890",
+	}
+	for in, want := range cases {
+		if got := cnnMaskToken(in); got != want {
+			t.Errorf("cnnMaskToken(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestCNNIsImageExt(t *testing.T) {
+	if !cnnIsImageExt(".png") || !cnnIsImageExt(".jpeg") || !cnnIsImageExt(".webp") {
+		t.Error("expected image extensions to be detected")
+	}
+	if cnnIsImageExt(".txt") {
+		t.Error(".txt should not be an image")
+	}
+}
+
+func TestParseCNNOptions(t *testing.T) {
+	if opt := parseCNNOptions(""); opt.Model != "" {
+		t.Errorf("empty string should yield zero options")
+	}
+	if opt := parseCNNOptions("undefined"); opt.Model != "" {
+		t.Errorf("'undefined' should yield zero options")
+	}
+	if opt := parseCNNOptions("null"); opt.Model != "" {
+		t.Errorf("'null' should yield zero options")
+	}
+	opt := parseCNNOptions(`{"model":"yolo11n","score_threshold":0.3,"render":true,"top_k":5}`)
+	if opt.Model != "yolo11n" || !opt.Render || opt.TopK != 5 {
+		t.Errorf("unexpected parse: %+v", opt)
+	}
+	if opt.ScoreThreshold == nil || *opt.ScoreThreshold != 0.3 {
+		t.Errorf("score_threshold not parsed: %+v", opt)
+	}
+}
+
+func TestParseCNNComparisonOptions(t *testing.T) {
+	opt := parseCNNComparisonOptions(`{"threshold":0.5,"a_cropped":true}`)
+	if opt.Threshold == nil || *opt.Threshold != 0.5 || !opt.ACropped {
+		t.Errorf("unexpected parse: %+v", opt)
+	}
+	if opt := parseCNNComparisonOptions(""); opt.Threshold != nil {
+		t.Errorf("empty string should yield zero options")
+	}
+}
+
+// ─── persistence ──────────────────────────────────────────────────────────────
+
+func TestCNNConfigDefaultsAndPersistence(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+
+	cfg := g.getCNNConfig()
+	if cfg.TimeoutSeconds != cnnDefaultTimeoutSeconds {
+		t.Errorf("expected default timeout %d, got %d", cnnDefaultTimeoutSeconds, cfg.TimeoutSeconds)
+	}
+
+	sysdb.Write(cnnDBTable, "config", CNNServerConfig{Endpoint: "http://localhost:8080", Token: "tok", TimeoutSeconds: 30})
+	cfg = g.getCNNConfig()
+	if cfg.Endpoint != "http://localhost:8080" || cfg.Token != "tok" || cfg.TimeoutSeconds != 30 {
+		t.Errorf("unexpected config after write: %+v", cfg)
+	}
+}
+
+func TestCNNClientRequiresEndpoint(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+
+	if _, err := g.cnnClient(); err == nil {
+		t.Fatal("expected an error when endpoint is not configured")
+	}
+}
+
+// ─── VM injection ─────────────────────────────────────────────────────────────
+
+// TestCNNFunctionsInjected verifies every cnn.* binding is exposed as a
+// function after injection. The file-reading bindings (classify, detect, ...)
+// are checked for existence only here, mirroring how aimodel.chatWithFile is
+// checked for the aimodel lib (agi.aimodel_test.go) - actually invoking them
+// needs a fully wired virtual filesystem + user permission set that isn't
+// modelled anywhere in this test suite.
+func TestCNNFunctionsInjected(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+
+	vm := otto.New()
+	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}}
+	g.injectCNNFunctions(payload)
+
+	methods := []string{
+		"classify", "detect", "segment", "pose", "oriented",
+		"faceDetect", "faceLandmarks", "faceEmbedding", "faceAttributes",
+		"faceCompare", "analyze", "job", "models", "health",
+	}
+	for _, method := range methods {
+		val, err := vm.Run(`typeof cnn.` + method)
+		if err != nil {
+			t.Fatalf("evaluating cnn.%s: %v", method, err)
+		}
+		if s, _ := val.ToString(); s != "function" {
+			t.Errorf("cnn.%s should be a function, got %q", method, s)
+		}
+	}
+}
+
+// TestCNNHealthAndModelsRoundTrip exercises the full native-func -> JSON ->
+// JS-shim round trip for the file-free bindings against a mock CXNNAIO server.
+func TestCNNHealthAndModelsRoundTrip(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v1/health":
+			json.NewEncoder(w).Encode(map[string]any{"status": "ok", "version": "0.1.0", "models_loaded": 12, "uptime_s": 100})
+		case "/v1/models":
+			json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []map[string]any{{"id": "yolo11n", "object": "model", "task": "detection"}}})
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+	sysdb.Write(cnnDBTable, "config", CNNServerConfig{Endpoint: srv.URL, TimeoutSeconds: 5})
+
+	vm := otto.New()
+	g.injectCNNFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
+
+	val, err := vm.Run(`cnn.health().status`)
+	if err != nil {
+		t.Fatalf("cnn.health() errored: %v", err)
+	}
+	if s, _ := val.ToString(); s != "ok" {
+		t.Errorf("expected status ok, got %q", s)
+	}
+
+	val, err = vm.Run(`cnn.models().data[0].id`)
+	if err != nil {
+		t.Fatalf("cnn.models() errored: %v", err)
+	}
+	if s, _ := val.ToString(); s != "yolo11n" {
+		t.Errorf("expected yolo11n, got %q", s)
+	}
+}
+
+// TestCNNJobPoll exercises the async job-poll binding end-to-end.
+func TestCNNJobPoll(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/jobs/job-1" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+			return
+		}
+		json.NewEncoder(w).Encode(map[string]any{
+			"id": "job-1", "object": "job", "status": "succeeded",
+			"result": map[string]any{"object": "image.detection", "data": []any{}},
+		})
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+	sysdb.Write(cnnDBTable, "config", CNNServerConfig{Endpoint: srv.URL, TimeoutSeconds: 5})
+
+	vm := otto.New()
+	g.injectCNNFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
+
+	val, err := vm.Run(`cnn.job("job-1").status`)
+	if err != nil {
+		t.Fatalf("cnn.job() errored: %v", err)
+	}
+	if s, _ := val.ToString(); s != "succeeded" {
+		t.Errorf("expected succeeded, got %q", s)
+	}
+}
+
+// TestCNNHealthErrorsWhenUnconfigured checks the CNNError surfaces cleanly
+// when no endpoint has been saved yet.
+func TestCNNHealthErrorsWhenUnconfigured(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.NewTable(cnnDBTable)
+
+	vm := otto.New()
+	g.injectCNNFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
+
+	_, err := vm.Run(`cnn.health()`)
+	if err == nil {
+		t.Fatal("expected an error when CNN server is not configured")
+	}
+	if !strings.Contains(err.Error(), "not configured") {
+		t.Errorf("expected a 'not configured' error, got: %v", err)
+	}
+}

+ 1 - 0
src/mod/agi/moduleManager.go

@@ -56,6 +56,7 @@ func (g *Gateway) LoadAllFunctionalModules() {
 	//g.AudioLibRegister() //work in progress
 	g.ZipLibRegister()
 	g.AIModelLibRegister()
+	g.CNNLibRegister()
 	g.SQLiteLibRegister()
 
 	//Only register ffmpeg lib if host OS have ffmpeg installed

+ 56 - 0
src/mod/aiservers/cnn/analyze.go

@@ -0,0 +1,56 @@
+package cnn
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+// AnalyzeOptions are the parameters for Analyze.
+type AnalyzeOptions struct {
+	//Tasks selects which recognition tasks to run in this single request, e.g.
+	//"classify", "detect", "segment", "pose", "oriented", "faces", "landmarks",
+	//"attributes".
+	Tasks []string `json:"tasks"`
+	//Options carries per-task parameters, keyed by task name, using the same
+	//fields as RequestOptions (raw passthrough - shapes differ per task).
+	Options map[string]json.RawMessage `json:"options,omitempty"`
+	Render  bool                       `json:"render,omitempty"`
+	Async   bool                       `json:"async,omitempty"`
+}
+
+type analyzeRequest struct {
+	Image   string                     `json:"image"`
+	Tasks   []string                   `json:"tasks"`
+	Options map[string]json.RawMessage `json:"options,omitempty"`
+	Render  bool                       `json:"render,omitempty"`
+	Async   bool                       `json:"async,omitempty"`
+}
+
+// AnalyzeResult is the response of POST /v1/vision/analyze. Results is keyed
+// by task name; each value is that task's own envelope shape (raw passthrough
+// since the shape differs per task and callers re-serialize it anyway).
+type AnalyzeResult struct {
+	Object        string                     `json:"object"`
+	Created       int64                      `json:"created"`
+	Image         *Dims                      `json:"image,omitempty"`
+	Results       map[string]json.RawMessage `json:"results"`
+	RenderedImage string                     `json:"rendered_image,omitempty"`
+}
+
+// Analyze runs several recognition tasks over one image in a single request
+// (the server decodes the image and warms models once).
+func (c *Client) Analyze(image []byte, mimeType string, opt AnalyzeOptions) (*AnalyzeResult, *Job, error) {
+	req := analyzeRequest{
+		Image:   dataURI(image, mimeType),
+		Tasks:   opt.Tasks,
+		Options: opt.Options,
+		Render:  opt.Render,
+		Async:   opt.Async,
+	}
+	result := &AnalyzeResult{}
+	job, err := c.do(http.MethodPost, "/v1/vision/analyze", req, result)
+	if err != nil || job != nil {
+		return nil, job, err
+	}
+	return result, nil, nil
+}

+ 61 - 0
src/mod/aiservers/cnn/analyze_test.go

@@ -0,0 +1,61 @@
+package cnn
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestAnalyze(t *testing.T) {
+	var gotBody map[string]any
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/vision/analyze" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		body, _ := io.ReadAll(r.Body)
+		json.Unmarshal(body, &gotBody)
+		json.NewEncoder(w).Encode(map[string]any{
+			"object":  "vision.analysis",
+			"created": 1,
+			"results": map[string]any{
+				"detect": map[string]any{"object": "image.detection", "data": []any{}},
+			},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, job, err := c.Analyze([]byte("x"), "image/jpeg", AnalyzeOptions{Tasks: []string{"detect", "faces"}})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if job != nil {
+		t.Fatalf("did not expect an async job")
+	}
+	tasks, _ := gotBody["tasks"].([]any)
+	if len(tasks) != 2 {
+		t.Errorf("tasks not forwarded: %+v", gotBody["tasks"])
+	}
+	if _, ok := result.Results["detect"]; !ok {
+		t.Errorf("expected a detect entry in results: %+v", result.Results)
+	}
+}
+
+func TestAnalyzeAsync(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusAccepted)
+		json.NewEncoder(w).Encode(Job{ID: "job-2", Status: "queued"})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, job, err := c.Analyze([]byte("x"), "image/jpeg", AnalyzeOptions{Tasks: []string{"detect"}, Async: true})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if result != nil || job == nil || job.ID != "job-2" {
+		t.Fatalf("expected an async job, got result=%+v job=%+v", result, job)
+	}
+}

+ 149 - 0
src/mod/aiservers/cnn/client.go

@@ -0,0 +1,149 @@
+package cnn
+
+/*
+	CXNNAIO Client
+
+	A minimal Go client for the CXNNAIO vision-inference REST API (see
+	E:\golang\ncnn\docs\API.md for the upstream design doc). This package only
+	speaks the wire protocol - it carries no ArozOS-specific knowledge (no AGI,
+	no virtual paths, no system database), so it can be reused from anywhere in
+	the codebase. The AGI binding that exposes it to WebApp backend scripts
+	lives in mod/agi/agi.cnn.go.
+
+	Author: tobychui (AGI/ArozOS integration)
+*/
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// DefaultTimeout is used when NewClient is called with timeout <= 0.
+const DefaultTimeout = 60 * time.Second
+
+// Client talks to one CXNNAIO server instance.
+type Client struct {
+	Endpoint string //Base URL, e.g. http://localhost:8080
+	Token    string //Bearer token; leave empty for a server running in no_auth mode
+	HTTP     *http.Client
+}
+
+// NewClient creates a client for the given endpoint. timeout <= 0 uses DefaultTimeout.
+func NewClient(endpoint string, token string, timeout time.Duration) *Client {
+	if timeout <= 0 {
+		timeout = DefaultTimeout
+	}
+	return &Client{
+		Endpoint: strings.TrimRight(strings.TrimSpace(endpoint), "/"),
+		Token:    strings.TrimSpace(token),
+		HTTP:     &http.Client{Timeout: timeout},
+	}
+}
+
+// do sends a request to path and decodes the response.
+//   - On a 2xx success, the body is decoded into out (which may be nil to
+//     discard it) and the returned *Job is nil.
+//   - On a 202 Accepted (async submission), the body is decoded into a *Job
+//     and returned; out is left untouched.
+//   - On any other status, the body is decoded as the API's error envelope
+//     and returned as a *APIError.
+func (c *Client) do(method, path string, body interface{}, out interface{}) (*Job, error) {
+	if strings.TrimSpace(c.Endpoint) == "" {
+		return nil, fmt.Errorf("cnn: server endpoint is not configured")
+	}
+
+	var reader io.Reader
+	if body != nil {
+		raw, err := json.Marshal(body)
+		if err != nil {
+			return nil, fmt.Errorf("cnn: failed to encode request: %w", err)
+		}
+		reader = bytes.NewReader(raw)
+	}
+
+	req, err := http.NewRequest(method, c.Endpoint+path, reader)
+	if err != nil {
+		return nil, fmt.Errorf("cnn: failed to build request: %w", err)
+	}
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	req.Header.Set("User-Agent", "arozos-cnn-client/1.0")
+	if c.Token != "" {
+		req.Header.Set("Authorization", "Bearer "+c.Token)
+	}
+
+	httpClient := c.HTTP
+	if httpClient == nil {
+		httpClient = &http.Client{Timeout: DefaultTimeout}
+	}
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("cnn: request to %s failed: %w", path, err)
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("cnn: failed to read response: %w", err)
+	}
+
+	switch {
+	case resp.StatusCode == http.StatusAccepted:
+		job := &Job{}
+		if err := json.Unmarshal(respBody, job); err != nil {
+			return nil, fmt.Errorf("cnn: unexpected 202 response: %s", truncate(string(respBody), 300))
+		}
+		return job, nil
+	case resp.StatusCode >= 200 && resp.StatusCode < 300:
+		if out != nil && len(respBody) > 0 {
+			if err := json.Unmarshal(respBody, out); err != nil {
+				return nil, fmt.Errorf("cnn: failed to decode response (HTTP %d): %s", resp.StatusCode, truncate(string(respBody), 300))
+			}
+		}
+		return nil, nil
+	default:
+		var envelope struct {
+			Error APIError `json:"error"`
+		}
+		if err := json.Unmarshal(respBody, &envelope); err != nil || envelope.Error.Message == "" {
+			return nil, &APIError{Status: resp.StatusCode, Message: truncate(string(respBody), 300), Type: "server_error"}
+		}
+		envelope.Error.Status = resp.StatusCode
+		return nil, &envelope.Error
+	}
+}
+
+// doImageCall posts a single-image request and decodes the response into an
+// envelope[T]. A non-nil *Job means the server accepted the call
+// asynchronously instead of returning the result immediately.
+func doImageCall[T any](c *Client, path string, req imageRequest) (*envelope[T], *Job, error) {
+	result := &envelope[T]{}
+	job, err := c.do(http.MethodPost, path, req, result)
+	if err != nil || job != nil {
+		return nil, job, err
+	}
+	return result, nil, nil
+}
+
+// dataURI builds a base64 data URI for the given raw image bytes.
+func dataURI(image []byte, mimeType string) string {
+	if strings.TrimSpace(mimeType) == "" {
+		mimeType = "application/octet-stream"
+	}
+	return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(image)
+}
+
+func truncate(s string, max int) string {
+	s = strings.TrimSpace(s)
+	if len(s) <= max {
+		return s
+	}
+	return s[:max] + "…"
+}

+ 161 - 0
src/mod/aiservers/cnn/client_test.go

@@ -0,0 +1,161 @@
+package cnn
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestNewClientDefaults(t *testing.T) {
+	c := NewClient(" http://localhost:8080/ ", "", 0)
+	if c.Endpoint != "http://localhost:8080" {
+		t.Errorf("endpoint not trimmed: %q", c.Endpoint)
+	}
+	if c.HTTP.Timeout != DefaultTimeout {
+		t.Errorf("expected default timeout, got %v", c.HTTP.Timeout)
+	}
+}
+
+func TestAuthHeaderSentWhenTokenSet(t *testing.T) {
+	var gotAuth string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotAuth = r.Header.Get("Authorization")
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"status":"ok","version":"0.1.0"}`))
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "tok123", 0)
+	if _, err := c.Health(); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if gotAuth != "Bearer tok123" {
+		t.Errorf("expected Bearer header, got %q", gotAuth)
+	}
+}
+
+func TestAuthHeaderOmittedWhenNoToken(t *testing.T) {
+	var gotAuth string
+	called := false
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		called = true
+		gotAuth = r.Header.Get("Authorization")
+		w.Write([]byte(`{"status":"ok"}`))
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	if _, err := c.Health(); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !called {
+		t.Fatal("server was not called")
+	}
+	if gotAuth != "" {
+		t.Errorf("expected no Authorization header, got %q", gotAuth)
+	}
+}
+
+func TestErrorEnvelopeDecoded(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+		json.NewEncoder(w).Encode(map[string]any{
+			"error": map[string]any{
+				"message": `model "x" is not available`,
+				"type":    "not_found_error",
+				"code":    "model_not_found",
+			},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	_, _, err := c.Detect([]byte{1, 2, 3}, "image/png", RequestOptions{Model: "x"})
+	if err == nil {
+		t.Fatal("expected an error")
+	}
+	apiErr, ok := err.(*APIError)
+	if !ok {
+		t.Fatalf("expected *APIError, got %T: %v", err, err)
+	}
+	if apiErr.Status != http.StatusNotFound || apiErr.Code != "model_not_found" {
+		t.Errorf("unexpected error fields: %+v", apiErr)
+	}
+}
+
+func TestAsyncSubmissionReturnsJob(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusAccepted)
+		json.NewEncoder(w).Encode(Job{ID: "job-1", Object: "job", Status: "queued", Created: 1})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, job, err := c.Detect([]byte{1, 2, 3}, "image/png", RequestOptions{Async: true})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if result != nil {
+		t.Errorf("expected nil result on async submission, got %+v", result)
+	}
+	if job == nil || job.ID != "job-1" || job.Status != "queued" {
+		t.Fatalf("unexpected job: %+v", job)
+	}
+}
+
+func TestEndpointNotConfigured(t *testing.T) {
+	c := NewClient("", "", 0)
+	if _, err := c.Health(); err == nil {
+		t.Fatal("expected an error when endpoint is empty")
+	}
+}
+
+func TestGetJob(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/jobs/job-1" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		json.NewEncoder(w).Encode(Job{ID: "job-1", Status: "succeeded", Result: json.RawMessage(`{"object":"image.detection"}`)})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	job, err := c.GetJob("job-1")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if job.Status != "succeeded" {
+		t.Errorf("unexpected status: %s", job.Status)
+	}
+}
+
+func TestHealthAndListModelsAndGetModel(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v1/health":
+			json.NewEncoder(w).Encode(Health{Status: "ok", Version: "0.1.0", ModelsLoaded: 12, UptimeS: 100})
+		case "/v1/models":
+			json.NewEncoder(w).Encode(ModelList{Object: "list", Data: []ModelInfo{{ID: "yolo11n", Object: "model", Task: "detection"}}})
+		case "/v1/models/yolo11n":
+			json.NewEncoder(w).Encode(ModelInfo{ID: "yolo11n", Object: "model", Task: "detection", Classes: 80, Input: 640})
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	h, err := c.Health()
+	if err != nil || h.Status != "ok" || h.ModelsLoaded != 12 {
+		t.Fatalf("unexpected health: %+v, err=%v", h, err)
+	}
+	models, err := c.ListModels()
+	if err != nil || len(models.Data) != 1 || models.Data[0].ID != "yolo11n" {
+		t.Fatalf("unexpected models: %+v, err=%v", models, err)
+	}
+	model, err := c.GetModel("yolo11n")
+	if err != nil || model.Classes != 80 {
+		t.Fatalf("unexpected model: %+v, err=%v", model, err)
+	}
+}

+ 124 - 0
src/mod/aiservers/cnn/faces.go

@@ -0,0 +1,124 @@
+package cnn
+
+import "net/http"
+
+// FaceItem is one detected face.
+type FaceItem struct {
+	Score float64 `json:"score"`
+	Box   Box     `json:"box"`
+}
+
+// FaceDetectionResult is the response of POST /v1/faces/detections.
+type FaceDetectionResult = envelope[FaceItem]
+
+// FaceDetect runs face detection (ultraface-rfb-320, ultraface-slim-320).
+func (c *Client) FaceDetect(image []byte, mimeType string, opt RequestOptions) (*FaceDetectionResult, *Job, error) {
+	return doImageCall[FaceItem](c, "/v1/faces/detections", newImageRequest(image, mimeType, opt))
+}
+
+// LandmarkItem is one face and its landmark points.
+type LandmarkItem struct {
+	Box    Box     `json:"box"`
+	Points []Point `json:"points"`
+}
+
+// LandmarkResult is the response of POST /v1/faces/landmarks.
+type LandmarkResult = envelope[LandmarkItem]
+
+// FaceLandmarks detects faces and returns their landmark points (pfld).
+// Set opt.Cropped to treat the whole input image as a single face crop.
+func (c *Client) FaceLandmarks(image []byte, mimeType string, opt RequestOptions) (*LandmarkResult, *Job, error) {
+	return doImageCall[LandmarkItem](c, "/v1/faces/landmarks", newImageRequest(image, mimeType, opt))
+}
+
+// EmbeddingItem is one face and its embedding vector.
+type EmbeddingItem struct {
+	Box       Box       `json:"box"`
+	Embedding []float32 `json:"embedding"`
+	Dim       int       `json:"dim"`
+}
+
+// EmbeddingResult is the response of POST /v1/faces/embeddings.
+type EmbeddingResult = envelope[EmbeddingItem]
+
+// FaceEmbedding returns an L2-normalized embedding vector per face
+// (mbv2facenet). By default embeds the largest face; set opt.Cropped to
+// treat the input as a single face crop.
+func (c *Client) FaceEmbedding(image []byte, mimeType string, opt RequestOptions) (*EmbeddingResult, *Job, error) {
+	return doImageCall[EmbeddingItem](c, "/v1/faces/embeddings", newImageRequest(image, mimeType, opt))
+}
+
+// GenderScore is the gender classification for one face.
+type GenderScore struct {
+	Label      string             `json:"label"`
+	Confidence float64            `json:"confidence"`
+	Scores     map[string]float64 `json:"scores"`
+}
+
+// GenderItem is one face and its gender attributes.
+type GenderItem struct {
+	Box    Box         `json:"box"`
+	Gender GenderScore `json:"gender"`
+}
+
+// GenderResult is the response of POST /v1/faces/gender.
+type GenderResult = envelope[GenderItem]
+
+// FaceAttributes returns gender attributes per face (gender-mbv2-0.35). The
+// upstream API doc names this endpoint /v1/faces/attributes, but the
+// deployed server actually registers it under /v1/faces/gender (confirmed
+// against a live instance: /v1/faces/attributes -> 404, /v1/faces/gender ->
+// reachable) with response object "face.gender". This method targets the
+// real route under an ArozOS-friendly name.
+func (c *Client) FaceAttributes(image []byte, mimeType string, opt RequestOptions) (*GenderResult, *Job, error) {
+	return doImageCall[GenderItem](c, "/v1/faces/gender", newImageRequest(image, mimeType, opt))
+}
+
+// ComparisonOptions are the parameters for FaceCompare. The server does not
+// support async submission for this endpoint.
+type ComparisonOptions struct {
+	Model     string   `json:"model,omitempty"`
+	Threshold *float32 `json:"threshold,omitempty"`
+	ACropped  bool     `json:"a_cropped,omitempty"`
+	BCropped  bool     `json:"b_cropped,omitempty"`
+}
+
+type comparisonRequest struct {
+	Model     string   `json:"model,omitempty"`
+	ImageA    string   `json:"image_a"`
+	ImageB    string   `json:"image_b"`
+	ACropped  bool     `json:"a_cropped,omitempty"`
+	BCropped  bool     `json:"b_cropped,omitempty"`
+	Threshold *float32 `json:"threshold,omitempty"`
+}
+
+// ComparisonResult is the (unwrapped - not an envelope) response of
+// POST /v1/faces/comparisons.
+type ComparisonResult struct {
+	Object     string  `json:"object"`
+	Model      string  `json:"model"`
+	Created    int64   `json:"created"`
+	Similarity float64 `json:"similarity"`
+	Same       bool    `json:"same"`
+	Threshold  float64 `json:"threshold"`
+	BoxA       Box     `json:"box_a"`
+	BoxB       Box     `json:"box_b"`
+}
+
+// FaceCompare compares two face images (each a photo or a crop) and returns
+// their cosine similarity (mbv2facenet).
+func (c *Client) FaceCompare(imageA, imageB []byte, mimeA, mimeB string, opt ComparisonOptions) (*ComparisonResult, error) {
+	req := comparisonRequest{
+		Model:     opt.Model,
+		ImageA:    dataURI(imageA, mimeA),
+		ImageB:    dataURI(imageB, mimeB),
+		ACropped:  opt.ACropped,
+		BCropped:  opt.BCropped,
+		Threshold: opt.Threshold,
+	}
+	result := &ComparisonResult{}
+	if _, err := c.do(http.MethodPost, "/v1/faces/comparisons", req, result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}

+ 101 - 0
src/mod/aiservers/cnn/faces_test.go

@@ -0,0 +1,101 @@
+package cnn
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestFaceCompare(t *testing.T) {
+	var gotBody map[string]any
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/faces/comparisons" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		body, _ := io.ReadAll(r.Body)
+		json.Unmarshal(body, &gotBody)
+		json.NewEncoder(w).Encode(ComparisonResult{Object: "face.comparison", Similarity: 0.62, Same: true, Threshold: 0.5})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, err := c.FaceCompare([]byte("a"), []byte("b"), "image/jpeg", "image/png", ComparisonOptions{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if gotBody["image_a"] == nil || gotBody["image_b"] == nil {
+		t.Errorf("images not sent: %+v", gotBody)
+	}
+	if !result.Same || result.Similarity != 0.62 {
+		t.Errorf("unexpected comparison result: %+v", result)
+	}
+}
+
+func TestFaceAttributesUsesGenderRoute(t *testing.T) {
+	var gotPath string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotPath = r.URL.Path
+		json.NewEncoder(w).Encode(GenderResult{
+			Object: "face.gender",
+			Data: []GenderItem{{Gender: GenderScore{Label: "female", Confidence: 0.98,
+				Scores: map[string]float64{"female": 0.98, "male": 0.02}}}},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, job, err := c.FaceAttributes([]byte("x"), "image/jpeg", RequestOptions{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if job != nil {
+		t.Fatalf("did not expect an async job")
+	}
+	if gotPath != "/v1/faces/gender" {
+		t.Errorf("expected the real /v1/faces/gender route, got %s", gotPath)
+	}
+	if len(result.Data) != 1 || result.Data[0].Gender.Label != "female" {
+		t.Errorf("unexpected gender result: %+v", result.Data)
+	}
+}
+
+func TestFaceDetectAndLandmarksAndEmbedding(t *testing.T) {
+	cases := []struct {
+		name string
+		path string
+		call func(c *Client) error
+	}{
+		{"detect", "/v1/faces/detections", func(c *Client) error {
+			_, _, err := c.FaceDetect([]byte("x"), "image/jpeg", RequestOptions{})
+			return err
+		}},
+		{"landmarks", "/v1/faces/landmarks", func(c *Client) error {
+			_, _, err := c.FaceLandmarks([]byte("x"), "image/jpeg", RequestOptions{Cropped: true})
+			return err
+		}},
+		{"embedding", "/v1/faces/embeddings", func(c *Client) error {
+			_, _, err := c.FaceEmbedding([]byte("x"), "image/jpeg", RequestOptions{})
+			return err
+		}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var gotPath string
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				gotPath = r.URL.Path
+				w.Write([]byte(`{"object":"x","data":[]}`))
+			}))
+			defer srv.Close()
+
+			c := NewClient(srv.URL, "", 0)
+			if err := tc.call(c); err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if gotPath != tc.path {
+				t.Errorf("expected path %s, got %s", tc.path, gotPath)
+			}
+		})
+	}
+}

+ 98 - 0
src/mod/aiservers/cnn/images.go

@@ -0,0 +1,98 @@
+package cnn
+
+// ClassificationItem is one ranked label in a classification response.
+type ClassificationItem struct {
+	Label string  `json:"label"`
+	Index int     `json:"index"`
+	Score float64 `json:"score"`
+}
+
+// ClassificationResult is the response of POST /v1/images/classifications.
+type ClassificationResult = envelope[ClassificationItem]
+
+// Classify runs image classification (e.g. mobilenet-v2, yolo11n-cls).
+func (c *Client) Classify(image []byte, mimeType string, opt RequestOptions) (*ClassificationResult, *Job, error) {
+	return doImageCall[ClassificationItem](c, "/v1/images/classifications", newImageRequest(image, mimeType, opt))
+}
+
+// DetectionItem is one detected object.
+type DetectionItem struct {
+	Label   string  `json:"label"`
+	ClassID int     `json:"class_id"`
+	Score   float64 `json:"score"`
+	Box     Box     `json:"box"`
+}
+
+// DetectionResult is the response of POST /v1/images/detections.
+type DetectionResult = envelope[DetectionItem]
+
+// Detect runs object detection (e.g. yolo11n, nanodet-plus-m).
+func (c *Client) Detect(image []byte, mimeType string, opt RequestOptions) (*DetectionResult, *Job, error) {
+	return doImageCall[DetectionItem](c, "/v1/images/detections", newImageRequest(image, mimeType, opt))
+}
+
+// Mask is a per-instance, box-cropped segmentation mask.
+type Mask struct {
+	Encoding string `json:"encoding"` //"png"
+	Width    int    `json:"width"`
+	Height   int    `json:"height"`
+	Origin   Point  `json:"origin"`
+	Data     string `json:"data"` //base64-encoded 8-bit grayscale PNG
+}
+
+// SegmentationItem is one detected instance plus its mask.
+type SegmentationItem struct {
+	Label   string  `json:"label"`
+	ClassID int     `json:"class_id"`
+	Score   float64 `json:"score"`
+	Box     Box     `json:"box"`
+	Mask    Mask    `json:"mask"`
+}
+
+// SegmentationResult is the response of POST /v1/images/segmentations.
+type SegmentationResult = envelope[SegmentationItem]
+
+// Segment runs instance segmentation (yolo11n-seg).
+func (c *Client) Segment(image []byte, mimeType string, opt RequestOptions) (*SegmentationResult, *Job, error) {
+	return doImageCall[SegmentationItem](c, "/v1/images/segmentations", newImageRequest(image, mimeType, opt))
+}
+
+// Keypoint is one named pose keypoint (COCO-17 layout).
+type Keypoint struct {
+	Name string `json:"name"`
+	X    int    `json:"x"`
+	Y    int    `json:"y"`
+}
+
+// PoseItem is one detected person and their keypoints.
+type PoseItem struct {
+	Score     float64    `json:"score"`
+	Box       Box        `json:"box"`
+	Keypoints []Keypoint `json:"keypoints"`
+}
+
+// PoseResult is the response of POST /v1/images/poses.
+type PoseResult = envelope[PoseItem]
+
+// Pose runs pose estimation (yolo11n-pose).
+func (c *Client) Pose(image []byte, mimeType string, opt RequestOptions) (*PoseResult, *Job, error) {
+	return doImageCall[PoseItem](c, "/v1/images/poses", newImageRequest(image, mimeType, opt))
+}
+
+// OrientedItem is one detected object with a rotated bounding polygon.
+type OrientedItem struct {
+	Label    string  `json:"label"`
+	ClassID  int     `json:"class_id"`
+	Score    float64 `json:"score"`
+	AngleRad float64 `json:"angle_rad"`
+	Polygon  []Point `json:"polygon"`
+}
+
+// OrientedResult is the response of POST /v1/images/oriented.
+type OrientedResult = envelope[OrientedItem]
+
+// Oriented runs oriented (rotated box) detection (yolo11n-obb). Intended for
+// aerial/top-down imagery.
+func (c *Client) Oriented(image []byte, mimeType string, opt RequestOptions) (*OrientedResult, *Job, error) {
+	return doImageCall[OrientedItem](c, "/v1/images/oriented", newImageRequest(image, mimeType, opt))
+}

+ 153 - 0
src/mod/aiservers/cnn/images_test.go

@@ -0,0 +1,153 @@
+package cnn
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestClassifyRequestAndResponse(t *testing.T) {
+	var gotBody map[string]any
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/images/classifications" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		body, _ := io.ReadAll(r.Body)
+		json.Unmarshal(body, &gotBody)
+		json.NewEncoder(w).Encode(ClassificationResult{
+			Object: "image.classification", Model: "mobilenet-v2", Created: 1,
+			Image: &Dims{Width: 800, Height: 533},
+			Data:  []ClassificationItem{{Label: "cat", Index: 1, Score: 0.9}},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, job, err := c.Classify([]byte("fakejpeg"), "image/jpeg", RequestOptions{Model: "mobilenet-v2", TopK: 3})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if job != nil {
+		t.Fatalf("did not expect an async job")
+	}
+	if gotBody["model"] != "mobilenet-v2" || gotBody["top_k"].(float64) != 3 {
+		t.Errorf("unexpected request body: %+v", gotBody)
+	}
+	img, _ := gotBody["image"].(string)
+	if !strings.HasPrefix(img, "data:image/jpeg;base64,") {
+		t.Errorf("image not encoded as data URI: %s", img)
+	}
+	if len(result.Data) != 1 || result.Data[0].Label != "cat" {
+		t.Errorf("unexpected result data: %+v", result.Data)
+	}
+}
+
+func TestDetectRequestEncodesThresholds(t *testing.T) {
+	var gotBody map[string]any
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		json.Unmarshal(body, &gotBody)
+		json.NewEncoder(w).Encode(DetectionResult{
+			Object: "image.detection", Model: "yolo11n",
+			Data: []DetectionItem{{Label: "mouse", ClassID: 64, Score: 0.93, Box: Box{X1: 1, Y1: 2, X2: 3, Y2: 4}}},
+		})
+	}))
+	defer srv.Close()
+
+	score := float32(0.25)
+	nms := float32(0.45)
+	c := NewClient(srv.URL, "", 0)
+	result, _, err := c.Detect([]byte("fakejpeg"), "image/jpeg", RequestOptions{Model: "yolo11n", ScoreThreshold: &score, NMSThreshold: &nms})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if gotBody["score_threshold"].(float64) != 0.25 || gotBody["nms_threshold"].(float64) != 0.45 {
+		t.Errorf("thresholds not forwarded: %+v", gotBody)
+	}
+	if len(result.Data) != 1 || result.Data[0].Box.X2 != 3 {
+		t.Errorf("unexpected detection data: %+v", result.Data)
+	}
+}
+
+func TestSegmentPoseOrientedDecode(t *testing.T) {
+	cases := []struct {
+		name string
+		path string
+		call func(c *Client) error
+	}{
+		{"segment", "/v1/images/segmentations", func(c *Client) error {
+			_, _, err := c.Segment([]byte("x"), "image/png", RequestOptions{})
+			return err
+		}},
+		{"pose", "/v1/images/poses", func(c *Client) error {
+			_, _, err := c.Pose([]byte("x"), "image/png", RequestOptions{})
+			return err
+		}},
+		{"oriented", "/v1/images/oriented", func(c *Client) error {
+			_, _, err := c.Oriented([]byte("x"), "image/png", RequestOptions{})
+			return err
+		}},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var gotPath string
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				gotPath = r.URL.Path
+				w.Write([]byte(`{"object":"x","data":[]}`))
+			}))
+			defer srv.Close()
+
+			c := NewClient(srv.URL, "", 0)
+			if err := tc.call(c); err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if gotPath != tc.path {
+				t.Errorf("expected path %s, got %s", tc.path, gotPath)
+			}
+		})
+	}
+}
+
+func TestPoseDecodesKeypoints(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(PoseResult{
+			Object: "image.pose",
+			Data: []PoseItem{{Score: 0.91, Box: Box{X1: 1, Y1: 1, X2: 2, Y2: 2},
+				Keypoints: []Keypoint{{Name: "nose", X: 612, Y: 140}}}},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, _, err := c.Pose([]byte("x"), "image/png", RequestOptions{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(result.Data) != 1 || len(result.Data[0].Keypoints) != 1 || result.Data[0].Keypoints[0].Name != "nose" {
+		t.Errorf("unexpected pose data: %+v", result.Data)
+	}
+}
+
+func TestSegmentDecodesMask(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(SegmentationResult{
+			Object: "image.segmentation",
+			Data: []SegmentationItem{{Label: "person", Box: Box{X1: 16, Y1: 64, X2: 1440, Y2: 1751},
+				Mask: Mask{Encoding: "png", Width: 1424, Height: 1687, Origin: Point{X: 16, Y: 64}, Data: "iVBORw0KGgo="}}},
+		})
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", 0)
+	result, _, err := c.Segment([]byte("x"), "image/png", RequestOptions{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(result.Data) != 1 || result.Data[0].Mask.Encoding != "png" || result.Data[0].Mask.Data == "" {
+		t.Errorf("unexpected segmentation data: %+v", result.Data)
+	}
+}

+ 56 - 0
src/mod/aiservers/cnn/live_smoke_test.go

@@ -0,0 +1,56 @@
+package cnn
+
+import (
+	"encoding/base64"
+	"net"
+	"testing"
+	"time"
+)
+
+// TestLiveSmoke is a throwaway sanity check against a real, locally running
+// no_auth CXNNAIO instance. It's skipped unless one is actually reachable on
+// localhost:8080, so it never affects normal `go test ./...` runs / CI.
+func TestLiveSmoke(t *testing.T) {
+	conn, err := net.DialTimeout("tcp", "localhost:8080", 300*time.Millisecond)
+	if err != nil {
+		t.Skip("no live CXNNAIO server on localhost:8080, skipping")
+	}
+	conn.Close()
+
+	c := NewClient("http://localhost:8080", "", 10*time.Second)
+
+	h, err := c.Health()
+	if err != nil {
+		t.Fatalf("Health() failed: %v", err)
+	}
+	t.Logf("health: %+v", h)
+	if h.Status != "ok" {
+		t.Errorf("expected status ok, got %q", h.Status)
+	}
+
+	models, err := c.ListModels()
+	if err != nil {
+		t.Fatalf("ListModels() failed: %v", err)
+	}
+	t.Logf("models loaded: %d", len(models.Data))
+	if len(models.Data) == 0 {
+		t.Errorf("expected at least one model")
+	}
+
+	//1x1 PNG, verified against this same live server via curl during planning.
+	tinyPNG, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUAAarVyFEAAAAASUVORK5CYII=")
+	if err != nil {
+		t.Fatalf("failed to decode test PNG: %v", err)
+	}
+	result, job, err := c.Classify(tinyPNG, "image/png", RequestOptions{Model: "mobilenet-v2", TopK: 3})
+	if err != nil {
+		t.Fatalf("Classify() failed: %v", err)
+	}
+	if job != nil {
+		t.Fatalf("did not expect an async job")
+	}
+	t.Logf("classify result: object=%s model=%s data=%+v", result.Object, result.Model, result.Data)
+	if result.Object != "image.classification" || len(result.Data) == 0 {
+		t.Errorf("unexpected classify result: %+v", result)
+	}
+}

+ 45 - 0
src/mod/aiservers/cnn/meta.go

@@ -0,0 +1,45 @@
+package cnn
+
+import (
+	"net/http"
+	"net/url"
+)
+
+// Health calls GET /v1/health. This endpoint is always public on the server
+// side (no auth required), but the client still sends its token if one is set.
+func (c *Client) Health() (*Health, error) {
+	result := &Health{}
+	if _, err := c.do(http.MethodGet, "/v1/health", nil, result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// ListModels calls GET /v1/models.
+func (c *Client) ListModels() (*ModelList, error) {
+	result := &ModelList{}
+	if _, err := c.do(http.MethodGet, "/v1/models", nil, result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// GetModel calls GET /v1/models/{id}.
+func (c *Client) GetModel(id string) (*ModelInfo, error) {
+	result := &ModelInfo{}
+	if _, err := c.do(http.MethodGet, "/v1/models/"+url.PathEscape(id), nil, result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// GetJob polls an async job submitted by any of the recognition calls with
+// opt.Async = true. Status is one of "queued", "running", "succeeded" or
+// "failed"; Result/Error are populated once the job leaves "running".
+func (c *Client) GetJob(id string) (*Job, error) {
+	result := &Job{}
+	if _, err := c.do(http.MethodGet, "/v1/jobs/"+url.PathEscape(id), nil, result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}

+ 133 - 0
src/mod/aiservers/cnn/types.go

@@ -0,0 +1,133 @@
+package cnn
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Dims is the decoded source image's dimensions in pixels.
+type Dims struct {
+	Width  int `json:"width"`
+	Height int `json:"height"`
+}
+
+// Box is an absolute-pixel axis-aligned bounding box in the original image.
+type Box struct {
+	X1 int `json:"x1"`
+	Y1 int `json:"y1"`
+	X2 int `json:"x2"`
+	Y2 int `json:"y2"`
+}
+
+// Point is an absolute-pixel coordinate in the original image.
+type Point struct {
+	X int `json:"x"`
+	Y int `json:"y"`
+}
+
+// envelope is the common response shape shared by every single-image
+// recognition endpoint; only the item type carried in Data differs per task.
+type envelope[T any] struct {
+	Object        string `json:"object"`
+	Model         string `json:"model,omitempty"`
+	Created       int64  `json:"created"`
+	Image         *Dims  `json:"image,omitempty"`
+	TimingMs      int64  `json:"timing_ms"`
+	Data          []T    `json:"data,omitempty"`
+	RenderedImage string `json:"rendered_image,omitempty"`
+}
+
+// APIError is the server's OpenAI-style error envelope; it also satisfies error.
+type APIError struct {
+	Status  int    `json:"-"`
+	Message string `json:"message"`
+	Type    string `json:"type"`
+	Param   string `json:"param,omitempty"`
+	Code    string `json:"code,omitempty"`
+}
+
+func (e *APIError) Error() string {
+	if e.Code != "" {
+		return fmt.Sprintf("cnn: %s (%s)", e.Message, e.Code)
+	}
+	return "cnn: " + e.Message
+}
+
+// Job is an async inference job: returned immediately on submission (HTTP
+// 202, when the request set "async":true) and again by GetJob while polling.
+type Job struct {
+	ID      string          `json:"id"`
+	Object  string          `json:"object"`
+	Status  string          `json:"status"` //"queued" | "running" | "succeeded" | "failed"
+	Created int64           `json:"created"`
+	Result  json.RawMessage `json:"result,omitempty"`
+	Error   json.RawMessage `json:"error,omitempty"`
+}
+
+// ModelInfo describes one model registered on the server.
+type ModelInfo struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Task    string `json:"task"`
+	Classes int    `json:"classes,omitempty"`
+	Input   int    `json:"input,omitempty"`
+}
+
+// ModelList is the response of GET /v1/models.
+type ModelList struct {
+	Object string      `json:"object"`
+	Data   []ModelInfo `json:"data"`
+}
+
+// Health is the response of GET /v1/health.
+type Health struct {
+	Status       string `json:"status"`
+	Version      string `json:"version"`
+	ModelsLoaded int    `json:"models_loaded"`
+	Sessions     int    `json:"sessions"`
+	UptimeS      int64  `json:"uptime_s"`
+}
+
+// RequestOptions are the common per-call parameters shared by every
+// single-image recognition endpoint. Field names mirror the server's own
+// wire format (snake_case) so they can be set directly from AGI scripts
+// using the same names documented in the CXNNAIO API doc.
+type RequestOptions struct {
+	Model          string   `json:"model,omitempty"`
+	ScoreThreshold *float32 `json:"score_threshold,omitempty"`
+	NMSThreshold   *float32 `json:"nms_threshold,omitempty"`
+	TopK           int      `json:"top_k,omitempty"`
+	MaxResults     int      `json:"max_results,omitempty"`
+	Render         bool     `json:"render,omitempty"`
+	Cropped        bool     `json:"cropped,omitempty"`
+	Async          bool     `json:"async,omitempty"`
+}
+
+// imageRequest is the common single-image JSON request body. The same shape
+// is sent to every image/face recognition endpoint; fields that don't apply
+// to a given task (e.g. top_k for detection) are simply ignored server-side.
+type imageRequest struct {
+	Model          string   `json:"model,omitempty"`
+	Image          string   `json:"image"`
+	ScoreThreshold *float32 `json:"score_threshold,omitempty"`
+	NMSThreshold   *float32 `json:"nms_threshold,omitempty"`
+	TopK           int      `json:"top_k,omitempty"`
+	MaxResults     int      `json:"max_results,omitempty"`
+	Render         bool     `json:"render,omitempty"`
+	Cropped        bool     `json:"cropped,omitempty"`
+	Async          bool     `json:"async,omitempty"`
+}
+
+func newImageRequest(image []byte, mimeType string, opt RequestOptions) imageRequest {
+	return imageRequest{
+		Model:          opt.Model,
+		Image:          dataURI(image, mimeType),
+		ScoreThreshold: opt.ScoreThreshold,
+		NMSThreshold:   opt.NMSThreshold,
+		TopK:           opt.TopK,
+		MaxResults:     opt.MaxResults,
+		Render:         opt.Render,
+		Cropped:        opt.Cropped,
+		Async:          opt.Async,
+	}
+}

+ 1 - 0
src/startup.go

@@ -142,6 +142,7 @@ func RunStartup() {
 	AuthSettingsInit()        //Authentication Settings Handler, must be start after user Handler
 	AdvanceSettingInit()      //System Advance Settings
 	AIModelSettingInit()      //AI Model (OpenAI / Anthropic) config, pricing, quota & usage metrics
+	CNNInferenceSettingInit() //CXNNAIO vision-inference server config & connectivity test
 	AGIRuntimeManagerInit()   //AGI VM lifecycle monitor (Developer Options tab)
 	StartupFlagsInit()        //System BootFlag settibg
 	HardwarePowerInit()       //Start host power manager

+ 267 - 0
src/web/SystemAO/advance/cnninference.html

@@ -0,0 +1,267 @@
+<style>
+#cnn-root {
+    --cnn-bg: #ffffff;
+    --cnn-border: #e5e5e5;
+    --cnn-text: #1f1f1f;
+    --cnn-dim: #676767;
+    --cnn-soft: #f5f5f5;
+    --cnn-soft2: #efefef;
+    --cnn-accent: #4d6bff;
+    --cnn-accent-text: #ffffff;
+    --cnn-ok: #107c10;
+    --cnn-danger: #c42b1c;
+    --cnn-shadow: 0 4px 20px rgba(0,0,0,0.08);
+    background: var(--cnn-bg);
+    border: 1px solid var(--cnn-border);
+    border-radius: 12px;
+    box-shadow: var(--cnn-shadow);
+    color: var(--cnn-text);
+    padding: 16px;
+    margin-bottom: 4px;
+    font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+    font-size: 13px;
+}
+body.dark #cnn-root {
+    --cnn-bg: #2b2b2b;
+    --cnn-border: #3b3b3b;
+    --cnn-text: #ececec;
+    --cnn-dim: #aaaaaa;
+    --cnn-soft: #343434;
+    --cnn-soft2: #3c3c3c;
+    --cnn-accent: #5a78ff;
+    --cnn-ok: #2ecc71;
+    --cnn-danger: #ff6b5e;
+    --cnn-shadow: 0 8px 22px rgba(0,0,0,0.3);
+}
+
+#cnn-root * { box-sizing: border-box; }
+.cnn-head { display:flex; align-items:center; gap:10px; margin-bottom: 14px; }
+.cnn-head .cnn-title { font-size: 17px; font-weight: 600; }
+.cnn-head .cnn-sub { font-size: 12px; color: var(--cnn-dim); margin-top: 2px; }
+.cnn-head .cnn-badge { margin-left:auto; font-size:11px; color:var(--cnn-dim); display:inline-flex; align-items:center; gap:6px; min-width: 100px; }
+.cnn-dot { width:8px; height:8px; border-radius:50%; background: var(--cnn-dim); }
+.cnn-dot.ok { background: var(--cnn-ok); }
+.cnn-dot.off { background: var(--cnn-danger); }
+
+.cnn-card {
+    border: 1px solid var(--cnn-border);
+    border-radius: 10px;
+    background: var(--cnn-soft);
+    padding: 14px;
+    margin-bottom: 12px;
+}
+.cnn-card-title { font-size: 13.5px; font-weight: 600; margin-bottom: 4px; display:flex; align-items:center; gap:8px; }
+.cnn-card-desc { font-size: 11.5px; color: var(--cnn-dim); margin-bottom: 12px; line-height: 1.5; }
+
+.cnn-field { margin-bottom: 12px; }
+.cnn-field:last-child { margin-bottom: 0; }
+.cnn-field > label { display:block; font-size:12px; font-weight:600; color: var(--cnn-dim); margin-bottom: 5px; }
+.cnn-field .hint { font-size: 11px; color: var(--cnn-dim); margin-top: 5px; line-height: 1.5; }
+.cnn-row { display:flex; gap:10px; }
+.cnn-row > .cnn-field { flex:1; }
+
+#cnn-root input[type=text], #cnn-root input[type=password], #cnn-root input[type=number] {
+    width: 100%;
+    background: var(--cnn-bg);
+    border: 1px solid var(--cnn-border);
+    color: var(--cnn-text);
+    border-radius: 8px;
+    padding: 8px 10px;
+    font-size: 13px;
+    font-family: inherit;
+    outline: none;
+}
+#cnn-root input:focus { border-color: var(--cnn-accent); }
+#cnn-root code { background: var(--cnn-soft2); padding: 1px 5px; border-radius: 4px; font-size: 11.5px; }
+
+.cnn-btn {
+    border: 1px solid var(--cnn-border);
+    background: var(--cnn-bg);
+    color: var(--cnn-text);
+    border-radius: 8px;
+    font-size: 12.5px;
+    font-weight: 600;
+    padding: 8px 14px;
+    cursor: pointer;
+    transition: .12s;
+}
+.cnn-btn:hover { background: var(--cnn-soft2); }
+.cnn-btn.primary { background: var(--cnn-accent); color: var(--cnn-accent-text); border-color: var(--cnn-accent); }
+.cnn-btn.primary:hover { filter: brightness(1.06); }
+.cnn-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-top: 4px; }
+.cnn-result { font-size: 12px; color: var(--cnn-dim); }
+
+.cnn-checkbox { display:inline-flex; align-items:center; gap:8px; cursor:pointer; font-size:12.5px; }
+.cnn-checkbox input { width:auto; }
+
+/* Server info tiles */
+.cnn-grid { display:grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); gap:8px; margin-bottom: 12px; }
+.cnn-metric { border:1px solid var(--cnn-border); border-radius:10px; background:var(--cnn-bg); padding:10px; }
+.cnn-metric .lab { font-size:11px; color:var(--cnn-dim); margin-bottom:4px; }
+.cnn-metric .val { font-size:16px; font-weight:600; line-height:1.3; word-break:break-word; }
+.cnn-metric.status .val { color: var(--cnn-ok); }
+@media (max-width: 760px){ .cnn-grid { grid-template-columns: repeat(2, minmax(110px,1fr)); } }
+
+/* Models table */
+.cnn-table { width:100%; border-collapse: collapse; font-size: 12.5px; }
+.cnn-table th { text-align:left; font-size:11px; color:var(--cnn-dim); font-weight:600; padding:6px 8px; border-bottom:1px solid var(--cnn-border); }
+.cnn-table td { padding:6px 8px; border-bottom:1px solid var(--cnn-border); }
+
+#cnn-toast {
+    display:none; position:fixed; bottom:24px; right:24px; z-index:99999;
+    padding:11px 16px; border-radius:9px; font-size:12.5px; font-weight:600;
+    color:#fff; box-shadow:var(--cnn-shadow);
+}
+</style>
+
+<div id="cnn-root">
+    <div class="cnn-head">
+        <div>
+            <div class="cnn-title">CNN Inference</div>
+            <div class="cnn-sub">Connect AGI scripts to an external CXNNAIO vision-inference server for classification, detection, segmentation, pose, oriented detection and face analysis.</div>
+        </div>
+        <div class="cnn-badge"><span class="cnn-dot" id="cnn-conn-dot"></span><span id="cnn-conn-text">Checking…</span></div>
+    </div>
+
+    <!-- Connection -->
+    <div class="cnn-card">
+        <div class="cnn-card-title">Connection</div>
+        <div class="cnn-card-desc">The CXNNAIO server endpoint and (optional) API token used by every <code>requirelib("cnn")</code> call on this system.</div>
+        <div class="cnn-row">
+            <div class="cnn-field">
+                <label>Endpoint Base URL</label>
+                <input type="text" id="cnn-endpoint" placeholder="http://localhost:8080">
+                <div class="hint">No trailing slash needed; request paths (<code>/v1/...</code>) are appended automatically.</div>
+            </div>
+            <div class="cnn-field" style="max-width:160px;">
+                <label>Request Timeout (s)</label>
+                <input type="number" id="cnn-timeout" min="1" step="1" placeholder="60">
+            </div>
+        </div>
+        <div class="cnn-field">
+            <label>API Token</label>
+            <input type="password" id="cnn-token" placeholder="cxn-...">
+            <div class="hint"><span id="cnn-token-hint"></span> Leave blank to keep the saved token, or leave empty entirely for a server running with <code>no_auth</code>.</div>
+            <label class="cnn-checkbox" style="margin-top:7px;"><input type="checkbox" id="cnn-cleartoken"> Remove saved token</label>
+        </div>
+        <div class="cnn-actions">
+            <button class="cnn-btn primary" id="cnn-save-conn">Save Connection</button>
+            <button class="cnn-btn" id="cnn-test">Test Connection</button>
+            <span class="cnn-result" id="cnn-test-result"></span>
+        </div>
+    </div>
+
+    <!-- Server info -->
+    <div class="cnn-card">
+        <div class="cnn-card-title">Server Info</div>
+        <div class="cnn-card-desc">Populated by Test Connection. Shows the live status and model registry reported by the configured server.</div>
+        <div class="cnn-grid">
+            <div class="cnn-metric status"><div class="lab">Status</div><div class="val" id="cnn-i-status">—</div></div>
+            <div class="cnn-metric"><div class="lab">Version</div><div class="val" id="cnn-i-version">—</div></div>
+            <div class="cnn-metric"><div class="lab">Uptime</div><div class="val" id="cnn-i-uptime">—</div></div>
+            <div class="cnn-metric"><div class="lab">Sessions</div><div class="val" id="cnn-i-sessions">—</div></div>
+        </div>
+        <table class="cnn-table">
+            <thead><tr><th>Model</th><th>Task</th><th style="width:90px;">Classes</th><th style="width:80px;">Input</th></tr></thead>
+            <tbody id="cnn-models-body"><tr><td colspan="4" style="text-align:center; color:var(--cnn-dim);">Run Test Connection to list models.</td></tr></tbody>
+        </table>
+    </div>
+</div>
+<div id="cnn-toast"></div>
+
+<script>
+(function () {
+    var API = "../../system/cnn/";
+    var $root = $("#cnn-root");
+
+    /* ── theme (match overview.html) ── */
+    (function applyTheme() {
+        try {
+            if (typeof preferredTheme !== 'undefined') {
+                document.body.classList.toggle('dark', (preferredTheme === 'dark' || preferredTheme === 'darkTheme'));
+                return;
+            }
+        } catch (e) {}
+        if (typeof ao_module_getSystemThemeColor === 'function') {
+            ao_module_getSystemThemeColor(function (c) { document.body.classList.toggle('dark', c !== 'whiteTheme'); });
+        }
+    })();
+
+    function el(id){ return document.getElementById(id); }
+    function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
+    function toast(msg, ok){
+        var t = el("cnn-toast"); t.textContent = msg;
+        t.style.background = ok === false ? "#c42b1c" : (ok === true ? "#107c10" : "#4d6bff");
+        t.style.display = "block"; setTimeout(function(){ t.style.display = "none"; }, 3200);
+    }
+    function setConn(state, text){
+        var d = el("cnn-conn-dot"); d.className = "cnn-dot" + (state===true?" ok":state===false?" off":"");
+        el("cnn-conn-text").textContent = text;
+    }
+    function fmtUptime(s){
+        s = Number(s||0);
+        if (s <= 0) return "—";
+        var d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
+        if (d > 0) return d + "d " + h + "h";
+        if (h > 0) return h + "h " + m + "m";
+        return m + "m";
+    }
+
+    /* ── Connection ── */
+    function loadConfig(){
+        $.get(API + "config", function(d){
+            el("cnn-endpoint").value = d.endpoint || "";
+            el("cnn-timeout").value = d.timeoutSeconds || 60;
+            el("cnn-token-hint").textContent = d.hasToken ? ("Saved token: " + (d.tokenHint||"")) : "No token saved.";
+            if (d.endpoint) {
+                testConnection();
+            } else {
+                setConn(null, "Not configured");
+            }
+        }).fail(function(){ setConn(false, "Admin only"); toast("Failed to load configuration (admin only).", false); });
+    }
+    function saveConfig(){
+        var data = {
+            endpoint: el("cnn-endpoint").value.trim(),
+            timeoutSeconds: el("cnn-timeout").value || "60",
+            cleartoken: el("cnn-cleartoken").checked ? "true" : "false"
+        };
+        var token = el("cnn-token").value;
+        if (token) data.token = token;
+        $.post(API + "config", data, function(r){
+            if (r && r.error){ toast("Error: " + r.error, false); return; }
+            toast("Connection saved.", true);
+            el("cnn-token").value = ""; el("cnn-cleartoken").checked = false;
+            loadConfig();
+        }).fail(function(){ toast("Failed to save connection.", false); });
+    }
+    function testConnection(){
+        el("cnn-test-result").textContent = "Testing…";
+        $.post(API + "test", { endpoint: el("cnn-endpoint").value.trim(), token: el("cnn-token").value }, function(r){
+            if (r && r.error){ el("cnn-test-result").textContent = "✗ " + r.error; setConn(false, "Error"); return; }
+            el("cnn-test-result").textContent = "✓ Connected — " + r.modelCount + " model(s)";
+            setConn(true, r.modelCount + " model(s)");
+            renderServerInfo(r);
+        }).fail(function(){ el("cnn-test-result").textContent = "✗ Request failed"; setConn(false, "Unreachable"); });
+    }
+    function renderServerInfo(r){
+        el("cnn-i-status").textContent = r.status || "—";
+        el("cnn-i-version").textContent = r.version || "—";
+        el("cnn-i-uptime").textContent = fmtUptime(r.uptimeS);
+        el("cnn-i-sessions").textContent = (r.sessions != null) ? r.sessions : "—";
+
+        var models = r.models || [];
+        if (models.length === 0){ el("cnn-models-body").innerHTML = '<tr><td colspan="4" style="text-align:center; color:var(--cnn-dim);">No models reported.</td></tr>'; return; }
+        models.sort(function(a,b){ return (a.id||"").localeCompare(b.id||""); });
+        el("cnn-models-body").innerHTML = models.map(function(m){
+            return '<tr><td>'+esc(m.id)+'</td><td>'+esc(m.task)+'</td><td>'+esc(m.classes||"")+'</td><td>'+esc(m.input||"")+'</td></tr>';
+        }).join("");
+    }
+
+    /* ── Bind ── */
+    el("cnn-save-conn").addEventListener("click", saveConfig);
+    el("cnn-test").addEventListener("click", testConnection);
+
+    loadConfig();
+})();
+</script>

+ 9 - 0
src/web/SystemAO/system_setting/img/cnn.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#444" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
+  <path d="M8 16V8h8"/>
+  <path d="M32 8h8v8"/>
+  <path d="M8 32v8h8"/>
+  <path d="M40 32v8h-8"/>
+  <path d="M12 24Q24 12 36 24Q24 36 12 24Z"/>
+  <circle cx="24" cy="24" r="4.3"/>
+  <circle cx="24" cy="24" r="1.2" fill="#444" stroke="none"/>
+</svg>

+ 106 - 0
src/web/Terminal/docs/api.json

@@ -725,6 +725,112 @@
         }
       ]
     },
+    {
+      "id": "cnn",
+      "name": "cnn",
+      "desc": "Run image/face vision inference (classification, detection, segmentation, pose, oriented detection, face analysis) against an external CXNNAIO server. Endpoint, token and timeout are configured in System Settings > AI Integration > CNN Inference.",
+      "load": "requirelib(\"cnn\");",
+      "functions": [
+        {
+          "name": "cnn.classify",
+          "sig": "cnn.classify(file, options)",
+          "desc": "Classify an image (default model mobilenet-v2). file is a virtual path. Returns the server's image.classification envelope.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.classify(\"user:/cat.jpg\", { top_k: 3 });\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.detect",
+          "sig": "cnn.detect(file, options)",
+          "desc": "Run object detection (default model yolo11n). Returns the server's image.detection envelope; set options.render to also get an annotated PNG in rendered_image.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.detect(\"user:/street.jpg\", { score_threshold: 0.3 });\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.segment",
+          "sig": "cnn.segment(file, options)",
+          "desc": "Run instance segmentation (yolo11n-seg). Each item carries a per-instance, box-cropped base64 PNG mask.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.segment(\"user:/photo.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.pose",
+          "sig": "cnn.pose(file, options)",
+          "desc": "Run pose estimation (yolo11n-pose), 17 COCO keypoints per detected person.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.pose(\"user:/photo.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.oriented",
+          "sig": "cnn.oriented(file, options)",
+          "desc": "Run oriented/rotated-box detection (yolo11n-obb), intended for aerial/top-down imagery.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.oriented(\"user:/aerial.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.faceDetect",
+          "sig": "cnn.faceDetect(file, options)",
+          "desc": "Detect faces (default model ultraface-rfb-320). Returns the server's face.detection envelope.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.faceDetect(\"user:/group.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.faceLandmarks",
+          "sig": "cnn.faceLandmarks(file, options)",
+          "desc": "Detect 98-point facial landmarks (pfld). Set options.cropped to treat the whole input as one face crop instead of detecting faces first.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.faceLandmarks(\"user:/face.jpg\", { cropped: true });\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.faceEmbedding",
+          "sig": "cnn.faceEmbedding(file, options)",
+          "desc": "Return an L2-normalized 128-d embedding vector per face (mbv2facenet).",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.faceEmbedding(\"user:/face.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.faceAttributes",
+          "sig": "cnn.faceAttributes(file, options)",
+          "desc": "Return gender attributes per face (gender-mbv2-0.35). Calls the server's /v1/faces/gender route; response object is face.gender.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.faceAttributes(\"user:/face.jpg\", {});\nsendJSONResp(r.data);"
+        },
+        {
+          "name": "cnn.faceCompare",
+          "sig": "cnn.faceCompare(fileA, fileB, options)",
+          "desc": "Compare two face photos/crops and return their cosine similarity. options: { model, threshold, a_cropped, b_cropped }. No async variant.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.faceCompare(\"user:/a.jpg\", \"user:/b.jpg\", { threshold: 0.5 });\nsendResp(r.same ? \"same\" : \"different\");"
+        },
+        {
+          "name": "cnn.analyze",
+          "sig": "cnn.analyze(file, tasks, options)",
+          "desc": "Run several tasks over one image in a single round trip. tasks is an array (classify/detect/segment/pose/oriented/faces/landmarks/attributes); options carries top-level render/async plus per-task blocks keyed by task name.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar r = cnn.analyze(\"user:/group.jpg\", [\"detect\", \"faces\"], { render: true });\nsendJSONResp(r.results);"
+        },
+        {
+          "name": "cnn.job",
+          "sig": "cnn.job(id)",
+          "desc": "Poll an async job submitted with options.async = true. Returns { id, object, status, created, result, error }; status is queued, running, succeeded or failed.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nvar job = cnn.detect(\"user:/big.jpg\", { async: true });\nwhile (job.status === \"queued\" || job.status === \"running\") {\n    delay(500);\n    job = cnn.job(job.id);\n}\nsendJSONResp(job.result);"
+        },
+        {
+          "name": "cnn.models",
+          "sig": "cnn.models()",
+          "desc": "Return the live model registry from the configured server: { object, data: [{ id, object, task, classes, input }, ...] }.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nsendJSONResp(cnn.models());"
+        },
+        {
+          "name": "cnn.health",
+          "sig": "cnn.health()",
+          "desc": "Return live server health: { status, version, models_loaded, sessions, uptime_s }.",
+          "ret": "object",
+          "example": "requirelib(\"cnn\");\nsendJSONResp(cnn.health());"
+        }
+      ]
+    },
     {
       "id": "websocket",
       "name": "websocket",