Skill: jancee/sensecraft-ai-model-hub (version 1.1.2)

--- name: sensecraft-ai-model-hub description: Connect OpenClaw to the SenseCraft public model library to search, inspect, export, and download AI vision models for software and edge AI workflows. --- # SenseCraft AI Model Hub ## Overview Use this skill to treat the SenseCraft public model library as a practical source of pretrained vision models for OpenClaw. The core workflow is fixed: 1. `list_model` to discover model IDs 2. `view_model` to resolve a model into full metadata and `file_url` 3. download from `file_url` Assume: - base URL: `https://sensecraft.seeed.cc/aiserverapi` - default `appid=131` - public endpoints are anonymous unless evidence shows otherwise - list responses do **not** include direct download URLs - the actual download URL is `data.file_url` from `view_model` ## Use bundled script first Prefer the bundled script for repeatable work instead of rebuilding curl pipelines every time. Commands: - `scripts/sensecraft_models.py list` — list one page of public models - `scripts/sensecraft_models.py view <id>` — show one model detail with URL hints - `scripts/sensecraft_models.py index` — crawl all pages, fetch detail rows, summarize, and export an index - `scripts/sensecraft_models.py download` — download one or more model files and emit a manifest - `scripts/sensecraft_webcam_person_demo.py` — run a local webcam person-detection demo with a downloaded SenseCraft TFLite model ## Good default responses ### Find models Use when the request is like: - “search SenseCraft for person models” - “list classification models” - “show models for uniform_type 32” Prefer: ```bash python3 scripts/sensecraft_models.py list --search person --length 30 python3 scripts/sensecraft_models.py list --task 1 --uniform-type 32 --length 50 ``` ### Inspect one model Use when the request is like: - “what is model 60768?” - “give me the download link for this model ID” Prefer: ```bash python3 scripts/sensecraft_models.py view 60768 ``` This returns annotated detail including: - `file_url` - filename hint - extension hint - whether the URL looks like a `.tflite` artifact ### Export a searchable full index Use when the request is like: - “dump the whole public model catalog” - “make me a JSON/CSV index” - “summarize what model types are in the library” Prefer: ```bash python3 scripts/sensecraft_models.py index --format json --output ./sensecraft-models.json --summary --summary-json ./sensecraft-summary.json python3 scripts/sensecraft_models.py index --format csv --output ./sensecraft-models.csv ``` Use filters to reduce scope when possible: ```bash python3 scripts/sensecraft_models.py index --task 1 --uniform-type 32 --format json --output ./filtered.json python3 scripts/sensecraft_models.py index --model-format 2 --format table --summary ``` ### Download models Use when the request is like: - “download model 60768” - “download these several models” - “download everything from this exported index” Prefer: ```bash python3 scripts/sensecraft_models.py download --model-id 60768 --output-dir ./downloads --manifest ./downloaded.json --summary python3 scripts/sensecraft_models.py download --from-index ./sensecraft-models.json --output-dir ./downloads --manifest ./downloaded.json --summary-json ./downloaded-summary.json ``` ### Run a local webcam demo Use when the request is like: - “download a model and show me live inference from my webcam” - “make a local Python script that opens the camera and draws boxes” - “validate a SenseCraft model on my laptop before deployment” Prefer this path for a minimal local demo: 1. Run `scripts/setup_local_demo_env.sh` 2. Let `scripts/run_local_person_demo.sh` auto-download model `60080` into `./models` if missing 3. Run the launcher script Example: ```bash bash scripts/setup_local_demo_env.sh bash scripts/run_local_person_demo.sh ``` Helpful variants: ```bash bash scripts/run_local_person_demo.sh --debug bash scripts/run_local_person_demo.sh --camera 1 python3 scripts/sensecraft_webcam_person_demo.py --image ./sample.png --save ./annotated.png ``` Read `references/local-webcam-demo.md` before changing the postprocess logic or if the user reports bad boxes. Keep local demo assets under the skill root with this layout: - `models/` — downloaded TFLite artifacts and manifests - `captures/` — saved annotated frames from live demos - `scripts/` — setup, launcher, and Python entrypoints ## Output requirements When summarizing models for the user, include as many of these as available: - `id` - `name` - `description` - `task` - `uniform_types` - `model_format` - `file_url` - filename or extension hint if relevant - whether the artifact *looks like* `.tflite` When recommending a model for OpenClaw integration, also include: - expected purpose of the model - uncertainty about task mapping or format - whether the artifact should be inspected after download before claiming TFLite compatibility ## Field interpretation rule Do not invent semantic names for `task`, `uniform_type`, or `model_format` unless the mapping is documented. Safe behavior: - report raw numeric values exactly - compare/group/filter by those raw values - if the user wants human-readable meanings, state that the meaning is not confirmed and ask for or look for authoritative mapping Read `references/field-mapping-notes.md` if the user asks what those numeric values mean. ## API behavior ### List models Call: ```text GET /model/list_model ``` Typical parameters: - `appid=131` - `length` - `page` - `search` - `uniform_type` - repeated `task` Returned list items typically include: - `id` - `name` - `description` - `uniform_types` - `task` Do not expect `file_url` here. ### View model detail Call: ```text GET /model/view_model?appid=131&model_id=<ID> ``` Read from the detail response: - `data.id` - `data.name` - `data.description` - `data.task` - `data.uniform_types` - `data.model_format` - `data.file_url` `data.file_url` is the real download link. ### Download model Download with redirect following enabled. Equivalent shell shape: ```bash curl -L 'FILE_URL' -o output.bin ``` ## Safety and reliability rules - Treat the library as public, but do not assume the API contract is perfectly stable. - Use small delays during bulk detail fetches or downloads; avoid aggressive parallel floods. - Retry transient request failures a small number of times. - If `view_model` fails for a specific ID, skip it and record the failure during bulk crawls. - Preserve original metadata in exported JSON when possible. - Do not claim a model is definitely TFLite only from category or filename hints. - After download, inspect the artifact if exact runtime compatibility matters. - Do not guess YOLO-family fused-output decoding from generic tutorials when SSCMA-Micro or model-specific evidence is available. - For macOS local demos, warn that webcam access may fail until the terminal app has camera permission. ## References - `references/sensecraft-api.md` — API contract and workflow notes - `references/field-mapping-notes.md` — guidance for unknown numeric mappings - `references/integration-notes.md` — post-download validation and TFLite integration checks - `references/local-webcam-demo.md` — proven local webcam demo flow, package choices, and decoding caveats ## Decision rule If the user wants source discovery, indexing, filtering, URL resolution, or downloading from SenseCraft, stay inside this skill. If the user wants broader model selection across multiple ecosystems, use this skill to retrieve SenseCraft candidates first, then compare them against wider TFLite integration constraints.

path: references/field-mapping-notes.md
content: # Field Mapping Notes

Use this file when the user asks what numeric fields such as `task`, `uniform_type`, or `model_format` mean.

## Rule

Do not hallucinate mappings.

If SenseCraft documentation or the API response does not explicitly define a value, present the raw numeric value and say the semantic label is not yet confirmed.

## Safe language

Good:
- `task=1`
- `uniform_types=[32, 36]`
- `model_format=2`
- “These values can be filtered and grouped, but their human-readable meanings are not confirmed in the available docs.”

Bad:
- “task=1 definitely means object detection” unless documented
- “uniform_type 32 is device X” unless documented
- “model_format 2 means TFLite” unless documented

## Practical fallback

If the user needs a working filter but not a semantic label:
- filter on the raw value
- show example models under that value
- let the user validate the bucket by names/descriptions/downloaded artifacts

## How to become more certain

You may increase confidence only if at least one of these is true:
- official SenseCraft documentation defines the mapping
- model names/descriptions plus repeated examples make the mapping very likely and you clearly label it as inferred, not confirmed
- downloaded artifacts can be inspected and strongly support a format inference

## Recommended phrasing for inferred meanings

Use wording like:
- “likely corresponds to … based on the surrounding model names, but not confirmed by the API docs”
- “appears to group models of this kind, but I would not treat that label as authoritative yet”


path: references/integration-notes.md
content: # Integration Notes

## Preprocessing checklist

Always confirm:
- input tensor shape, usually `[1, height, width, channels]`
- channel order, usually RGB
- dtype: `uint8`, `int8`, or `float32`
- normalization: none, `[0,1]`, `[-1,1]`, or model-specific mean/std
- resize policy: direct resize, letterbox, or center crop

Do not guess these from model family alone if the artifact page documents them explicitly.

## Classification outputs

Common patterns:
- logits vector
- probabilities vector
- top-k labels through task library metadata

Verify whether softmax is already applied.

## Detection outputs

Common patterns:
- boxes tensor
- classes tensor
- scores tensor
- detection count tensor

Or a single fused tensor in some YOLO exports.

Always define:
- score threshold
- IoU / NMS threshold
- class label mapping
- coordinate convention: normalized vs pixel

## Quantized models

For `uint8` or `int8` models, inspect quantization parameters if raw interpreter output is used. Task libraries may handle this automatically.

Quantized models are usually the safest default for CPU-bound, always-on camera features.

## Metadata

If model metadata exists, use it. It often provides:
- label file association
- normalization hints
- task-library compatibility

If metadata is missing, capture the equivalent assumptions explicitly in code or config.

## Runtime risk factors

Watch for:
- unsupported custom ops
- delegate-specific behavior differences
- exporter-specific YOLO tensor formats
- mismatch between label files and class count
- wrong image orientation from camera pipeline

## Validation ritual

Before declaring a model “integrated,” run at least:
1. one obvious positive image
2. one near-miss image
3. one negative image

Check that confidence scores and false positives are reasonable. A model that technically runs but needs brittle thresholds is a weak default recommendation.


path: references/local-webcam-demo.md
content: # Local Webcam Demo Notes

Use this reference when the user wants to do a fast local validation loop on a laptop or workstation with a webcam.

## Recommended starter model

Use this SenseCraft model first for a minimal person-detection demo:

- `60080` — `Person Detection--Swift YOLO`
- current public file URL resolves to a `.tflite` model

Reason to prefer it:
- easy to demonstrate visually
- single-class output keeps the overlay simple
- small enough for CPU demo use

## Recommended environment on macOS arm64

Prefer Python `3.12` over `3.14` for this flow.

Use:
- `opencv-python`
- `numpy`
- `pillow`
- `ai-edge-litert`

Avoid assuming `tflite-runtime` is available. On macOS arm64 / newer Python versions, `tflite-runtime` wheels may be unavailable. `ai-edge-litert` works as a practical replacement for local TFLite inference.

Example setup:

```bash
bash scripts/setup_local_demo_env.sh
source .venv/bin/activate
```

Equivalent manual setup if needed:

```bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip setuptools wheel
python -m pip install numpy opencv-python pillow ai-edge-litert
```

## Download flow

From the skill root:

```bash
python3 scripts/sensecraft_models.py download --model-id 60080 --output-dir ./models --manifest ./models/downloaded.json --summary
```

This should produce a path like:

```text
./models/60080-Person-Detection--Swift-YOLO.tflite
```

## Demo script

Use the bundled launcher first:

```bash
bash scripts/run_local_person_demo.sh
```

Double-clickable macOS helper:

```bash
scripts/run_local_person_demo.command
```

Direct Python entrypoint if needed:

```bash
python3 scripts/sensecraft_webcam_person_demo.py --model ./models/60080-Person-Detection--Swift-YOLO.tflite
```

Useful options:

```bash
bash scripts/run_local_person_demo.sh --debug
bash scripts/run_local_person_demo.sh --camera 1
python3 scripts/sensecraft_webcam_person_demo.py --image ./sample.png --save ./annotated.png
```

Window controls:
- `s` — save the current annotated frame into `./captures/`
- `q` or `Esc` — quit

## Important decoding note

Do **not** assume every SenseCraft / SSCMA YOLO-family export can be decoded as plain `x1,y1,x2,y2` from the fused output tensor.

For the `60080` Swift-YOLO model used here, a plain `xyxy` interpretation produced visibly wrong boxes.
A more accurate practical decode for the single fused output `[1, 2268, 6]` was:

- first 4 values → treat as `cx, cy, w, h` in model-space
- 5th value → use as the candidate score for thresholding and NMS

This was validated empirically against sample imagery and local webcam output.

## SSCMA-Micro cross-check

When the user reports wrong boxes, check the SSCMA-Micro reference implementation before guessing.

Relevant repo:
- `https://github.com/Seeed-Studio/SSCMA-Micro`

Relevant source for YOLO-family postprocess patterns:
- `sscma/core/model/ma_model_yolo26.cpp`

Why it matters:
- SSCMA models may use exporter-specific fused outputs
- box meaning can differ from generic TFLite YOLO tutorials
- direct `xyxy` assumptions can be wrong even when inference runs successfully

## Camera permission reminder

On macOS, if OpenCV cannot open the webcam, remind the user to grant camera access to the terminal app they are using:

- System Settings → Privacy & Security → Camera

Typical symptom:
- `not authorized to capture video`

## Validation standard

Before calling the integration "good":
1. run on one obvious positive image
2. run on live webcam feed
3. save at least one annotated frame
4. confirm the box geometry looks plausible, not merely that the script runs

If geometry is still off:
- enable `--debug`
- inspect top candidates
- adjust decoding before chasing thresholds


path: references/model-catalog.md
content: # Model Catalog

Use this file to shortlist likely TFLite candidates quickly.

## 1. MediaPipe vision tasks

Best first stop for common interactive camera tasks.

### Good for
- object detection
- image classification
- image segmentation
- pose landmarks
- hand landmarks
- face detection / landmarks

### Why prefer it
- official TFLite-oriented stack
- consistent task APIs
- good mobile/edge ergonomics
- documented preprocessing and outputs

### Caveats
- task abstraction may hide tensor-level details
- some tasks are easiest through MediaPipe runtimes rather than raw TFLite interpreter use

## 2. TensorFlow Lite model zoo / task examples

Best for standard TensorFlow-native vision tasks.

### Look here for
- EfficientDet-Lite object detectors
- EfficientNet-Lite classifiers
- MobileNet classifiers
- segmentation examples

### Good default choices
- **Balanced object detection:** EfficientDet-Lite0 or Lite1
- **Fast object detection:** SSD MobileNet variants
- **Balanced classification:** EfficientNet-Lite0
- **Very lightweight classification:** MobileNetV3 small

## 3. TensorFlow Hub

Use when you need a reputable upstream source but can tolerate some validation work.

### Watch for
- whether the asset is already `.tflite` or only a SavedModel
- whether labels are bundled
- input resolution and normalization details

## 4. Coral / Edge TPU ecosystem

Use when target hardware may use Edge TPU now or later.

### Good for
- low-latency detection/classification on supported hardware

### Caveats
- Edge TPU compiled models are a subset of generic TFLite models
- some models require separate CPU fallback if accelerator is absent

## 5. YOLO exported to TFLite

Use only when the user specifically needs YOLO-family accuracy/behavior or already has a YOLO pipeline.

### Good for
- custom detection tasks with strong upstream ecosystem

### Caveats
- output tensor layouts vary by exporter/version
- NMS may be external or fused
- integration risk is higher than EfficientDet-Lite/SSD
- documentation quality varies a lot

## Practical recommendations by task

### General camera object detection for OpenClaw feature work
1. EfficientDet-Lite0
2. SSD MobileNet V2
3. EfficientDet-Lite1 if accuracy matters more than speed

### Lightweight on-device classification
1. MobileNetV3 small
2. EfficientNet-Lite0

### Person/background separation
1. MediaPipe Selfie Segmentation

### Body/hand/face understanding
1. MediaPipe task models first

## What to capture while evaluating a model page

- exact artifact URL
- model version
- license
- classes / label map
- input size
- dtype and quantization
- sample code availability
- any custom op requirement
- output decoding notes


path: references/sensecraft-api.md
content: # SenseCraft Public Model API

## One-line summary

SenseCraft public model download is a three-step flow:

1. `list_model` -> get model IDs
2. `view_model` -> get `data.file_url`
3. download from `file_url`

## Base URL

```text
https://sensecraft.seeed.cc/aiserverapi
```

## Authentication

Public model library access is expected to work anonymously.

Operational assumptions:
- send `appid=131` by default
- do not send `Authorization` unless required by future evidence
- handle per-model failures gracefully during bulk runs

## Endpoint: list_model

```http
GET /model/list_model
```

### Common parameters
- `appid=131`
- `length`
- `page`
- `search`
- `uniform_type`
- repeated `task`

### Important response fields
- `data.count`
- `data.list[]`
- `data.list[].id`
- `data.list[].name`
- `data.list[].description`
- `data.list[].uniform_types`
- `data.list[].task`

### Important caveat

This endpoint does **not** directly return the final downloadable model URL.

## Endpoint: view_model

```http
GET /model/view_model
```

### Parameters
- `appid=131`
- `model_id=<ID>`

### Important response fields
- `data.id`
- `data.name`
- `data.description`
- `data.file_url`
- `data.model_format`
- `data.uniform_types`
- `data.task`

### Critical field

`data.file_url` is the actual model download URL.

## Download behavior

Follow redirects while downloading.

Shell shape:

```bash
curl -L 'FILE_URL' -o output.bin
```

The bundled script improves on this by:
- retrying transient failures
- inferring a file extension from the final response when possible
- emitting a manifest after download

## Bulk crawling strategy

1. request page 1 from `list_model`
2. read `data.count`
3. compute total pages from `count` and `length`
4. collect every model ID from every page
5. call `view_model` for each ID
6. export detail records to JSON or CSV
7. optionally export an aggregate summary
8. optionally download selected artifacts

## Suggested fields for exported indexes

- `id`
- `name`
- `description`
- `task`
- `uniform_types`
- `model_format`
- `file_url`
- filename / extension hints
- whether the URL looks TFLite-like
- raw detail payload when useful

## Failure handling

During bulk jobs:
- skip individual detail failures
- record warnings
- keep the crawl going
- use mild throttling rather than unrestricted concurrency
- retry transient failures a small number of times


path: scripts/run_local_person_demo.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VENV_DIR="${ROOT}/.venv"
MODEL_PATH="${ROOT}/models/60080-Person-Detection--Swift-YOLO.tflite"

if [ ! -d "$VENV_DIR" ]; then
  echo "error: missing virtualenv at $VENV_DIR" >&2
  echo "run: bash scripts/setup_local_demo_env.sh" >&2
  exit 1
fi

# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"

if [ ! -f "$MODEL_PATH" ]; then
  echo "Model not found: $MODEL_PATH" >&2
  echo "Downloading model 60080 into $ROOT/models ..." >&2
  python "$ROOT/scripts/sensecraft_models.py" download --model-id 60080 --output-dir "$ROOT/models" --manifest "$ROOT/models/downloaded.json" --summary
fi

exec python "$ROOT/scripts/sensecraft_webcam_person_demo.py" --model "$MODEL_PATH" "$@"


path: scripts/sensecraft_models.py
content: #!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import re
import sys
import time
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterable, List, Optional, Tuple

BASE = "https://sensecraft.seeed.cc/aiserverapi"
DEFAULT_APPID = "131"
DEFAULT_PAGE_SIZE = 100
DEFAULT_TIMEOUT = 30
DEFAULT_SLEEP = 0.1
DEFAULT_RETRIES = 2
USER_AGENT = "openclaw-sensecraft-skill/1.1"


class RequestError(RuntimeError):
    pass


def build_query(params: Dict[str, Any]) -> str:
    items = []
    for k, v in params.items():
        if v is None:
            continue
        if isinstance(v, (list, tuple)):
            for item in v:
                items.append((k, str(item)))
        else:
            items.append((k, str(v)))
    return urllib.parse.urlencode(items)


def http_open(url: str, timeout: int = DEFAULT_TIMEOUT):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    return urllib.request.urlopen(req, timeout=timeout)


def retry_call(fn, retries: int, sleep_s: float):
    last_exc = None
    for attempt in range(retries + 1):
        try:
            return fn()
        except Exception as e:
            last_exc = e
            if attempt >= retries:
                break
            time.sleep(max(sleep_s, 0.2) * (attempt + 1))
    raise last_exc  # type: ignore[misc]


def http_get_json(path: str, params: Dict[str, Any], timeout: int = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES, retry_sleep: float = DEFAULT_SLEEP) -> Dict[str, Any]:
    query = build_query(params)
    url = f"{BASE}{path}?{query}"

    def run():
        with http_open(url, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))

    return retry_call(run, retries=retries, sleep_s=retry_sleep)


def list_models_page(appid: str, page: int, length: int, search: Optional[str], uniform_type: Optional[str], tasks: List[str], timeout: int, retries: int, retry_sleep: float) -> Dict[str, Any]:
    params: Dict[str, Any] = {"appid": appid, "page": page, "length": length}
    if search:
        params["search"] = search
    if uniform_type:
        params["uniform_type"] = uniform_type
    if tasks:
        params["task"] = tasks
    return http_get_json("/model/list_model", params, timeout=timeout, retries=retries, retry_sleep=retry_sleep)


def view_model(appid: str, model_id: str, timeout: int, retries: int, retry_sleep: float) -> Dict[str, Any]:
    return http_get_json("/model/view_model", {"appid": appid, "model_id": model_id}, timeout=timeout, retries=retries, retry_sleep=retry_sleep)


def ensure_ok(payload: Dict[str, Any], context: str) -> Dict[str, Any]:
    code = str(payload.get("code", ""))
    if code != "0":
        raise RequestError(f"{context} failed: code={code} msg={payload.get('msg') or payload.get('message')}")
    return payload.get("data") or {}


def iter_model_ids(appid: str, page_size: int, search: Optional[str], uniform_type: Optional[str], tasks: List[str], timeout: int, retries: int, retry_sleep: float) -> Iterable[str]:
    first = ensure_ok(list_models_page(appid, 1, page_size, search, uniform_type, tasks, timeout, retries, retry_sleep), "list_model")
    total = int(first.get("count") or 0)
    pages = max(1, math.ceil(total / page_size)) if total else 1
    for item in first.get("list") or []:
        yield str(item.get("id"))
    for page in range(2, pages + 1):
        data = ensure_ok(list_models_page(appid, page, page_size, search, uniform_type, tasks, timeout, retries, retry_sleep), f"list_model page {page}")
        for item in data.get("list") or []:
            yield str(item.get("id"))


def normalize_detail(detail: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "id": detail.get("id"),
        "name": detail.get("name"),
        "description": detail.get("description"),
        "task": detail.get("task"),
        "uniform_types": detail.get("uniform_types"),
        "model_format": detail.get("model_format"),
        "file_url": detail.get("file_url"),
        "pic_url": detail.get("pic_url"),
        "create_time": detail.get("create_time"),
        "update_time": detail.get("update_time"),
        "raw": detail,
    }


def annotate_model(model: Dict[str, Any]) -> Dict[str, Any]:
    row = dict(model)
    file_url = str(row.get("file_url") or "")
    path = urllib.parse.urlparse(file_url).path
    ext = os.path.splitext(path)[1].lower()
    row["filename_hint"] = os.path.basename(path) if path else None
    row["extension_hint"] = ext or None
    row["looks_like_tflite"] = ext == ".tflite" or ".tflite" in file_url.lower()
    return row


def collect_models(appid: str, page_size: int, search: Optional[str], uniform_type: Optional[str], tasks: List[str], model_format: Optional[str], timeout: int, sleep_s: float, retries: int, retry_sleep: float, stderr=sys.stderr) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    for model_id in iter_model_ids(appid, page_size, search, uniform_type, tasks, timeout, retries, retry_sleep):
        try:
            data = ensure_ok(view_model(appid, model_id, timeout, retries, retry_sleep), f"view_model {model_id}")
        except Exception as e:
            print(f"warn: skip model {model_id}: {e}", file=stderr)
            continue
        model = annotate_model(normalize_detail(data))
        if model_format is not None and str(model.get("model_format")) != str(model_format):
            if sleep_s:
                time.sleep(sleep_s)
            continue
        out.append(model)
        if sleep_s:
            time.sleep(sleep_s)
    return out


def summarize_models(models: List[Dict[str, Any]]) -> Dict[str, Any]:
    by_task: Dict[str, int] = {}
    by_format: Dict[str, int] = {}
    by_uniform_type: Dict[str, int] = {}
    tflite_like = 0
    for m in models:
        task = str(m.get("task") or "")
        fmt = str(m.get("model_format") or "")
        by_task[task] = by_task.get(task, 0) + 1
        by_format[fmt] = by_format.get(fmt, 0) + 1
        for ut in m.get("uniform_types") or []:
            ut_s = str(ut)
            by_uniform_type[ut_s] = by_uniform_type.get(ut_s, 0) + 1
        if m.get("looks_like_tflite"):
            tflite_like += 1
    return {
        "total": len(models),
        "looks_like_tflite": tflite_like,
        "by_task": dict(sorted(by_task.items(), key=lambda kv: kv[0])),
        "by_model_format": dict(sorted(by_format.items(), key=lambda kv: kv[0])),
        "by_uniform_type": dict(sorted(by_uniform_type.items(), key=lambda kv: kv[0])),
    }


def print_table(models: List[Dict[str, Any]], include_url: bool = True) -> None:
    for m in models:
        uts = ",".join(str(x) for x in (m.get("uniform_types") or []))
        parts = [
            str(m.get("id") or ""),
            str(m.get("name") or ""),
            f"task={m.get('task')}",
            f"format={m.get('model_format')}",
            f"uniform_types={uts}",
            f"tflite_like={str(bool(m.get('looks_like_tflite'))).lower()}",
        ]
        if include_url:
            parts.append(str(m.get("file_url") or ""))
        print("\t".join(parts))


def export_csv(models: List[Dict[str, Any]], path: str) -> None:
    fieldnames = [
        "id", "name", "description", "task", "uniform_types", "model_format", "file_url",
        "filename_hint", "extension_hint", "looks_like_tflite", "pic_url", "create_time", "update_time"
    ]
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fieldnames)
        w.writeheader()
        for m in models:
            row = dict(m)
            row["uniform_types"] = json.dumps(row.get("uniform_types") or [], ensure_ascii=False)
            row.pop("raw", None)
            w.writerow({k: row.get(k) for k in fieldnames})


def export_json(data: Any, path: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def safe_name(text: str) -> str:
    keep = []
    for ch in text:
        if ch.isalnum() or ch in ("-", "_", "."):
            keep.append(ch)
        elif ch.isspace():
            keep.append("-")
    s = "".join(keep).strip("-_")
    return s or "model"


def infer_extension(url: str, headers) -> str:
    parsed = urllib.parse.urlparse(url)
    path_ext = os.path.splitext(parsed.path)[1]
    if path_ext:
        return path_ext

    cd = headers.get("Content-Disposition", "")
    match = re.search(r'filename="?([^";]+)"?', cd)
    if match:
        return os.path.splitext(match.group(1))[1] or ".bin"

    content_type = (headers.get("Content-Type") or "").split(";")[0].strip().lower()
    if content_type == "application/octet-stream":
        return ".bin"
    if "zip" in content_type:
        return ".zip"
    if "json" in content_type:
        return ".json"
    return ".bin"


def download_file(url: str, out_path_base: str, timeout: int, retries: int, retry_sleep: float) -> Tuple[str, Dict[str, Any]]:
    os.makedirs(os.path.dirname(out_path_base) or ".", exist_ok=True)

    def run():
        with http_open(url, timeout=timeout) as resp:
            ext = infer_extension(url, resp.headers)
            out_path = out_path_base if os.path.splitext(out_path_base)[1] else out_path_base + ext
            final_url = resp.geturl()
            content_type = resp.headers.get("Content-Type")
            with open(out_path, "wb") as f:
                while True:
                    chunk = resp.read(1024 * 1024)
                    if not chunk:
                        break
                    f.write(chunk)
            return out_path, {"final_url": final_url, "content_type": content_type}

    return retry_call(run, retries=retries, sleep_s=retry_sleep)


def cmd_list(args: argparse.Namespace) -> int:
    data = ensure_ok(list_models_page(args.appid, args.page, args.length, args.search, args.uniform_type, args.task or [], args.timeout, args.retries, args.retry_sleep), "list_model")
    items = data.get("list") or []
    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
    else:
        for item in items:
            print(f"{item.get('id')}\t{item.get('name')}\ttask={item.get('task')}\tuniform_types={','.join(item.get('uniform_types') or [])}")
    return 0


def cmd_view(args: argparse.Namespace) -> int:
    data = ensure_ok(view_model(args.appid, args.model_id, args.timeout, args.retries, args.retry_sleep), f"view_model {args.model_id}")
    print(json.dumps(annotate_model(normalize_detail(data)), ensure_ascii=False, indent=2))
    return 0


def cmd_index(args: argparse.Namespace) -> int:
    models = collect_models(args.appid, args.length, args.search, args.uniform_type, args.task or [], args.model_format, args.timeout, args.sleep, args.retries, args.retry_sleep)
    summary = summarize_models(models)

    if args.summary_json:
        export_json(summary, args.summary_json)

    if args.summary:
        print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)

    if args.format == "json":
        if args.output:
            export_json(models, args.output)
        else:
            print(json.dumps(models, ensure_ascii=False, indent=2))
    elif args.format == "csv":
        if not args.output:
            raise SystemExit("--output is required for --format csv")
        export_csv(models, args.output)
    else:
        print_table(models, include_url=not args.no_url)
    return 0


def cmd_download(args: argparse.Namespace) -> int:
    ids: List[str] = list(args.model_id or [])
    if args.from_index:
        with open(args.from_index, "r", encoding="utf-8") as f:
            data = json.load(f)
        for item in data:
            mid = str(item.get("id"))
            if mid and mid not in ids:
                ids.append(mid)
    if not ids:
        raise SystemExit("provide --model-id or --from-index")

    os.makedirs(args.output_dir, exist_ok=True)
    manifest = []
    for model_id in ids:
        try:
            data = ensure_ok(view_model(args.appid, str(model_id), args.timeout, args.retries, args.retry_sleep), f"view_model {model_id}")
        except Exception as e:
            print(f"warn: skip model {model_id}: {e}", file=sys.stderr)
            continue
        model = annotate_model(normalize_detail(data))
        url = model.get("file_url")
        if not url:
            print(f"warn: model {model_id} has no file_url", file=sys.stderr)
            continue
        base_name = f"{model['id']}-{safe_name(str(model.get('name') or 'model'))}"
        out_base = os.path.join(args.output_dir, base_name)
        print(f"download: {model['id']} -> {out_base}", file=sys.stderr)
        download_path, meta = download_file(str(url), out_base, args.timeout, args.retries, args.retry_sleep)
        model["download_path"] = download_path
        model["download_content_type"] = meta.get("content_type")
        model["download_final_url"] = meta.get("final_url")
        manifest.append(model)
        if args.sleep:
            time.sleep(args.sleep)

    summary = summarize_models(manifest)
    if args.manifest:
        export_json(manifest, args.manifest)
    else:
        print(json.dumps(manifest, ensure_ascii=False, indent=2))
    if args.summary_json:
        export_json(summary, args.summary_json)
    if args.summary:
        print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="SenseCraft public model helper")
    p.add_argument("--appid", default=DEFAULT_APPID)
    p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
    p.add_argument("--retries", type=int, default=DEFAULT_RETRIES)
    p.add_argument("--retry-sleep", type=float, default=DEFAULT_SLEEP)

    sub = p.add_subparsers(dest="command", required=True)

    p_list = sub.add_parser("list", help="list one page of public models")
    p_list.add_argument("--page", type=int, default=1)
    p_list.add_argument("--length", type=int, default=30)
    p_list.add_argument("--search")
    p_list.add_argument("--uniform-type")
    p_list.add_argument("--task", action="append")
    p_list.add_argument("--json", action="store_true")
    p_list.set_defaults(func=cmd_list)

    p_view = sub.add_parser("view", help="view one model detail")
    p_view.add_argument("model_id")
    p_view.set_defaults(func=cmd_view)

    p_index = sub.add_parser("index", help="crawl full public index via list -> view")
    p_index.add_argument("--length", type=int, default=DEFAULT_PAGE_SIZE)
    p_index.add_argument("--search")
    p_index.add_argument("--uniform-type")
    p_index.add_argument("--task", action="append")
    p_index.add_argument("--model-format")
    p_index.add_argument("--sleep", type=float, default=DEFAULT_SLEEP, help="seconds between detail requests")
    p_index.add_argument("--format", choices=["table", "json", "csv"], default="json")
    p_index.add_argument("--output")
    p_index.add_argument("--summary", action="store_true", help="print aggregate summary to stderr")
    p_index.add_argument("--summary-json", help="write aggregate summary JSON")
    p_index.add_argument("--no-url", action="store_true", help="omit file_url in table output")
    p_index.set_defaults(func=cmd_index)

    p_dl = sub.add_parser("download", help="download one or more model files")
    p_dl.add_argument("--model-id", action="append")
    p_dl.add_argument("--from-index", help="load ids from a JSON index file produced by index")
    p_dl.add_argument("--output-dir", default="./downloads")
    p_dl.add_argument("--manifest", help="write downloaded model manifest as JSON")
    p_dl.add_argument("--summary", action="store_true", help="print aggregate summary to stderr")
    p_dl.add_argument("--summary-json", help="write aggregate summary JSON")
    p_dl.add_argument("--sleep", type=float, default=DEFAULT_SLEEP)
    p_dl.set_defaults(func=cmd_download)

    return p


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/sensecraft_webcam_person_demo.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import sys
import time
from pathlib import Path

import cv2
import numpy as np
from ai_edge_litert.interpreter import Interpreter


DEFAULT_MODEL_ID = "60080"
DEFAULT_MODEL_NAME = "Person-Detection--Swift-YOLO"
DEFAULT_MODEL_PATH = Path(__file__).resolve().parent.parent / "models" / f"{DEFAULT_MODEL_ID}-{DEFAULT_MODEL_NAME}.tflite"


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Run a local webcam person-detection demo with a SenseCraft TFLite model.")
    p.add_argument("--model", type=Path, default=DEFAULT_MODEL_PATH)
    p.add_argument("--camera", type=int, default=0, help="OpenCV camera index")
    p.add_argument("--score-threshold", type=float, default=45.0, help="Model-space score threshold for candidate filtering")
    p.add_argument("--nms-threshold", type=float, default=0.45, help="IoU threshold for NMS")
    p.add_argument("--image", type=Path, help="Run once on a still image instead of webcam")
    p.add_argument("--save", type=Path, help="Optional output path for an annotated still image")
    p.add_argument("--width", type=int, default=1280)
    p.add_argument("--height", type=int, default=720)
    p.add_argument("--debug", action="store_true", help="Print top raw candidates before NMS")
    return p.parse_args()


class PersonDetector:
    def __init__(self, model_path: Path, score_threshold: float = 45.0, nms_threshold: float = 0.45, debug: bool = False):
        if not model_path.exists():
            raise FileNotFoundError(f"Model not found: {model_path}")

        self.score_threshold = score_threshold
        self.nms_threshold = nms_threshold
        self.debug = debug
        self.interpreter = Interpreter(model_path=str(model_path))
        self.interpreter.allocate_tensors()

        self.input_detail = self.interpreter.get_input_details()[0]
        self.output_detail = self.interpreter.get_output_details()[0]

        _, self.input_h, self.input_w, _ = self.input_detail["shape"]
        self.input_scale, self.input_zero = self.input_detail["quantization"]
        self.output_scale, self.output_zero = self.output_detail["quantization"]

    def preprocess(self, frame_bgr: np.ndarray) -> np.ndarray:
        rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
        resized = cv2.resize(rgb, (self.input_w, self.input_h), interpolation=cv2.INTER_LINEAR)
        q = np.round(resized / self.input_scale + self.input_zero).astype(np.int8)
        return np.expand_dims(q, axis=0)

    def infer(self, frame_bgr: np.ndarray) -> list[dict]:
        tensor = self.preprocess(frame_bgr)
        self.interpreter.set_tensor(self.input_detail["index"], tensor)
        self.interpreter.invoke()
        raw = self.interpreter.get_tensor(self.output_detail["index"])[0].astype(np.float32)
        raw = (raw - self.output_zero) * self.output_scale
        return self.decode(raw, frame_bgr.shape[1], frame_bgr.shape[0])

    def decode(self, raw: np.ndarray, frame_w: int, frame_h: int) -> list[dict]:
        # This specific SenseCraft Swift-YOLO export exposes a single fused output
        # tensor with shape [1, 2268, 6]. In practice, the first four values decode
        # more correctly as center-x, center-y, width, height in model-space rather
        # than plain x1,y1,x2,y2. Treating them as xyxy causes visibly wrong boxes.
        centers_and_sizes = raw[:, :4].copy()
        scores = raw[:, 4].copy()

        keep = scores >= self.score_threshold
        centers_and_sizes = centers_and_sizes[keep]
        scores = scores[keep]
        if len(centers_and_sizes) == 0:
            return []

        cx = centers_and_sizes[:, 0]
        cy = centers_and_sizes[:, 1]
        bw = centers_and_sizes[:, 2]
        bh = centers_and_sizes[:, 3]

        x1 = np.clip(cx - bw / 2.0, 0, self.input_w - 1)
        y1 = np.clip(cy - bh / 2.0, 0, self.input_h - 1)
        x2 = np.clip(cx + bw / 2.0, 0, self.input_w - 1)
        y2 = np.clip(cy + bh / 2.0, 0, self.input_h - 1)

        boxes = np.stack([x1, y1, x2, y2], axis=1)

        if self.debug:
            top = np.argsort(scores)[-8:][::-1]
            print("[debug] top candidates before NMS:")
            for rank, idx in enumerate(top, start=1):
                bx = boxes[idx]
                print(
                    f"  #{rank} score={scores[idx]:.3f} box_model=({bx[0]:.1f},{bx[1]:.1f},{bx[2]:.1f},{bx[3]:.1f}) "
                    f"raw=({centers_and_sizes[idx][0]:.3f},{centers_and_sizes[idx][1]:.3f},{centers_and_sizes[idx][2]:.3f},{centers_and_sizes[idx][3]:.3f})"
                )

        scale_x = frame_w / float(self.input_w)
        scale_y = frame_h / float(self.input_h)
        boxes[:, [0, 2]] *= scale_x
        boxes[:, [1, 3]] *= scale_y

        indices = cv2.dnn.NMSBoxes(
            bboxes=[[float(x1), float(y1), float(x2 - x1), float(y2 - y1)] for x1, y1, x2, y2 in boxes],
            scores=[float(s) for s in scores],
            score_threshold=float(self.score_threshold),
            nms_threshold=float(self.nms_threshold),
        )
        if len(indices) == 0:
            return []

        flat = np.array(indices).reshape(-1)
        detections = []
        for idx in flat:
            x1, y1, x2, y2 = boxes[idx]
            detections.append({
                "label": "person",
                "score": float(scores[idx]),
                "box": [int(x1), int(y1), int(x2), int(y2)],
            })
        return detections


def draw(frame: np.ndarray, detections: list[dict], fps: float | None = None) -> np.ndarray:
    out = frame.copy()
    for det in detections:
        x1, y1, x2, y2 = det["box"]
        cv2.rectangle(out, (x1, y1), (x2, y2), (40, 220, 90), 2)
        text = f"{det['label']} {det['score']:.1f}"
        cv2.putText(out, text, (x1, max(24, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (40, 220, 90), 2, cv2.LINE_AA)

    status = f"persons: {len(detections)}"
    if fps is not None:
        status += f" | fps: {fps:.1f}"
    cv2.rectangle(out, (10, 10), (330, 44), (0, 0, 0), -1)
    cv2.putText(out, status, (16, 33), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
    return out


def run_image(args: argparse.Namespace, detector: PersonDetector) -> int:
    frame = cv2.imread(str(args.image))
    if frame is None:
        print(f"Failed to read image: {args.image}", file=sys.stderr)
        return 2
    detections = detector.infer(frame)
    annotated = draw(frame, detections)
    if args.save:
        cv2.imwrite(str(args.save), annotated)
        print(f"Saved: {args.save}")
    print(f"detections={len(detections)}")
    for det in detections[:20]:
        print(det)
    return 0


def run_camera(args: argparse.Namespace, detector: PersonDetector) -> int:
    cap = cv2.VideoCapture(args.camera)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)
    if not cap.isOpened():
        print("Could not open camera. On macOS this often means camera permission is missing.", file=sys.stderr)
        return 2

    prev = time.perf_counter()
    fps = None
    win = "SenseCraft Person Detection"
    cv2.namedWindow(win, cv2.WINDOW_NORMAL)

    while True:
        ok, frame = cap.read()
        if not ok:
            print("Camera frame read failed.", file=sys.stderr)
            break

        start = time.perf_counter()
        detections = detector.infer(frame)
        infer_ms = (time.perf_counter() - start) * 1000.0

        now = time.perf_counter()
        instant_fps = 1.0 / max(now - prev, 1e-6)
        prev = now
        fps = instant_fps if fps is None else (fps * 0.9 + instant_fps * 0.1)

        annotated = draw(frame, detections, fps=fps)
        cv2.putText(annotated, f"infer: {infer_ms:.1f} ms | s: save | q: quit", (16, annotated.shape[0] - 18), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2, cv2.LINE_AA)

        cv2.imshow(win, annotated)
        key = cv2.waitKey(1) & 0xFF
        if key == ord('s'):
            captures_dir = Path(__file__).resolve().parent.parent / "captures"
            captures_dir.mkdir(parents=True, exist_ok=True)
            out_path = captures_dir / f"capture-{int(time.time())}.png"
            cv2.imwrite(str(out_path), annotated)
            print(f"Saved frame: {out_path}")
        if key in (ord('q'), 27):
            break

    cap.release()
    cv2.destroyAllWindows()
    return 0


def main() -> int:
    args = parse_args()
    detector = PersonDetector(args.model, args.score_threshold, args.nms_threshold, debug=args.debug)
    if args.image:
        return run_image(args, detector)
    return run_camera(args, detector)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/setup_local_demo_env.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VENV_DIR="${ROOT}/.venv"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"

if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
  echo "error: $PYTHON_BIN not found. Set PYTHON_BIN=/path/to/python3.12 and retry." >&2
  exit 1
fi

mkdir -p "$ROOT/models" "$ROOT/captures"

if [ ! -d "$VENV_DIR" ]; then
  "$PYTHON_BIN" -m venv "$VENV_DIR"
fi

# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
python -m pip install numpy opencv-python pillow ai-edge-litert

echo "OK: local demo environment ready"
echo "activate with: source $VENV_DIR/bin/activate"


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)