第 3 课

听和看:用 Whistle 转写语音、用 Mirage 读图识字,再导出断网运行包

用 FFmpeg 准备 16 kHz 音频,用 Whistle 转写英文演讲并拿到词级时间戳,把语音直接交给 Needle 3 做工具调用;用 Mirage 识别招牌上的文字、读懂归一化坐标;用 extract() 按表单读一张猫的照片,从认错的结果里学会看概率和交叉检查;最后用 cactus download、cactus fetch 与环境变量导出离线包,用空缓存验证断网可用。

图文25 分钟Cactus Needle 官方文档 ↗

课程目录第 3 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方页面:Needle Python Docs 、Whistle 介绍 、Mirage 介绍 、Mirage 模型卡 。测试环境同课时 1(cactus-compute 4.1.0,Python 3.13.5,系统里装有 FFmpeg)。下面所有「对照结果」都是测试机的真实输出,耗时类数值每次运行会不同。

同一个 cactus-compute 包里还有两个模型:Whistle 把语音转成文字,权重约 17 MB;Mirage 读图片,能描述场景、找物体、识别文字、按表单填值,权重约 21 MB。它们和 Needle 3 共用同一个引擎。本课先转写一段英文演讲,再让 Mirage 读一块招牌、数一张照片里的动物,最后把模型和引擎导出到一个文件夹,验证断网也能跑。

第 1 步:准备一段 16 kHz 的音频

Whistle 直接接受 16 kHz 单声道 WAV。其他采样率要装 cactus-compute[mic] 扩展,或者先用 FFmpeg 转好。下面用 OpenAI Whisper 仓库测试目录里那段 11 秒的肯尼迪演讲做素材,换成自己录的英文音频也可以:

curl -L -o jfk.flac \
  https://github.com/openai/whisper/raw/main/tests/jfk.flac
ffmpeg -loglevel error -y -i jfk.flac -ar 16000 -ac 1 clip.wav
ffmpeg -hide_banner -i clip.wav 2>&1 | grep -E 'Duration|Stream'

对照结果:

  Duration: 00:00:11.00, bitrate: 256 kb/s
  Stream #0:0: Audio: pcm_s16le ..., 16000 Hz, mono, s16, 256 kb/s

第 2 步:用 Whistle 转写,拿到词级时间戳

新建 listen.py:

import textwrap
import cactus

r = cactus.transcribe("clip.wav", word_timestamps=True)
print(textwrap.fill("text: " + r["text"], 64))
print("language:", r["language"])
print("ttft_ms:", r["ttft_ms"], "decode_tps:", r["decode_tps"])
for w in r["words"][:5]:
    print(w)

运行 python listen.py。第一次会下载 whistle.cact,对照结果:

text: And so, my fellow Americans, ask not what your country can
do for you, ask what you can do for your country.
language: en
ttft_ms: 802.0 decode_tps: 452.9
{'word': 'And', 'start': 0.4, 'end': 0.72, 'probability': 0.532}
{'word': 'so,', 'start': 0.72, 'end': 1.04, 'probability': 0.99}
{'word': 'my', 'start': 1.12, 'end': 1.28, 'probability': 0.996}
{'word': 'fellow', 'start': 1.28, 'end': 1.68, 'probability': 0.941}
{'word': 'Americans,', 'start': 1.68, 'end': 2.24, 'probability': 0.965}

整句转写正确,语言自动识别为 en。ttft_ms 是出第一个字的耗时,测试机的纯 CPU 上不到 1 秒。打开 word_timestamps 后,每个词都带开始、结束时间和概率,可以直接拿去做字幕。官方说明里还有几点:单次最多处理 30 秒,更长的音频会自动分窗;keywords 参数可以传一组人名或专有名词,让识别更偏向它们;纯静音和稳定噪声会返回空文本,但音乐仍可能被转成文字,常开麦克风的场景要在前面加语音活动检测或唤醒词。支持的语言和 Needle 3 一样,不含中文。

语音也可以直接交给 Needle 3 做工具调用:agent.complete(audio="clip.wav", language="en")。返回结果里多一个 audio_text 字段。新建 voice_note.py 试一下:

import textwrap
import cactus

@cactus.tool
def save_note(text: str):
    """Save a note.

    Args:
        text: the note text, copied verbatim
    """
    return {"saved": text}

agent = cactus.Cactus(tools=[save_note])
r = agent.complete(audio="clip.wav", language="en")
print(textwrap.fill("heard: " + r["audio_text"], 64))
print("calls:", r["function_calls"])

对照结果:

heard: And so, my fellow Americans, ask not what your country
can do for you, ask what you can do for your country.
calls: []

听写是对的,但调用为空:这段演讲里没有「记一下」之类的请求,模型不会因为有一个 save_note 工具就把听到的话存起来。这和课时 1 里讲的约定一致。

第 3 步:用 Mirage 读出图片里的文字

先用 Pillow 画一块招牌当测试图。新建 make_sign.py:

from PIL import Image, ImageDraw, ImageFont

def font(size):
    try:
        return ImageFont.truetype("DejaVuSans-Bold.ttf", size)
    except OSError:
        return ImageFont.load_default(size=size)

img = Image.new("RGB", (800, 450), "white")
d = ImageDraw.Draw(img)
d.rectangle([40, 40, 760, 410], outline="black", width=8)
d.text((90, 90), "CAFE OPEN", fill="black", font=font(96))
d.text((90, 240), "Mon-Fri 8:00-18:00", fill="darkred", font=font(52))
img.save("sign.png")
print("saved sign.png", img.size)

再新建 look.py,sections 参数只要物体和文字两部分:

import cactus

vision = cactus.Mirage()
r = vision.describe("sign.png", sections=["objects", "text"])
for o in r["objects"]:
    print("object:", o["name"], o["bbox"], o["score"])
for t in r["text"]:
    print("text:", repr(t["string"]), t["score"])

依次运行:

python make_sign.py
python look.py

对照结果:

saved sign.png (800, 450)
object: text [119, 240, 866, 402] 0.537
object: sign [44, 79, 952, 897] 0.868
object: text [135, 562, 788, 637] 0.615
text: 'CAFE' 0.443
text: 'CAFE OPEN' 0.307
text: 'OPEN' 0.499
text: 'Mon-Fri' 0.456
text: 'Mon-Fri 8:00-18:00' 0.401
text: '8:00' 0.451
text: '8:O0-18:O0' 0.399
text: '18:00' 0.332

几点读法:

  • bbox 是左上、右下两个点的坐标,按 0 到 1000 归一化,不是像素。比如招牌框 [44, 79, 952, 897],换算回 800×450 的原图,要把横坐标乘 0.8、纵坐标乘 0.45。
  • 文字会同时给出整行和单词两级结果,同一行可能有多个候选。这里有一个候选把 0 认成了字母 O(8:O0-18:O0),分数也偏低。实际使用时按 score 取最高的一条,或者对数字做格式校验。
  • 不传 sections 时,返回还会带 scene、regions、relations、events、reasoning 等字段,物体还带分割轮廓 mask,输出会很长。

第 4 步:用 extract() 按表单读照片,并学会看概率

Mirage 的 extract() 和课时 2 的文字抽取类似:给一张表单,它按字段填值,并给出每个候选的概率。下载一张 Hugging Face 官方文档常用的 COCO 示例图(两只猫躺在沙发上):

curl -L -o cats.png \
  https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png

新建 pets.py:

import cactus

vision = cactus.Mirage()
r = vision.describe("cats.png", sections=["objects"])
for o in r["objects"]:
    print("object:", o["name"], o["bbox"], o["score"])

form = {
    "animal": {"enum": ["dog", "cat"]},
    "count": {"type": "integer", "minimum": 0, "maximum": 5},
}
res = vision.extract("cats.png", form)["form"]
for name, field in res.items():
    probs = {k: round(p, 2) for k, p in field["probabilities"].items()}
    print(name, "=", field["value"], probs)

运行 python pets.py,对照结果:

object: tag [59, 149, 267, 242] 0.578
object: tag [528, 163, 578, 390] 0.314
object: dog [535, 77, 981, 776] 0.719
object: dog [23, 145, 493, 958] 0.692
object: blanket [2, 187, 994, 1000] 0.636
animal = dog {'dog': 0.64, 'cat': 0.3, '-': 0.06}
count = 1 {'0': 0.08, '1': 0.8, '2': 0.1, '3': 0.02, '4': 0.0, '5': 0.0}

这张图里其实是两只猫,Mirage 在测试机上把它们认成了狗,count 也只给了 1。这一步专门留下这个错误,是想说明两件事:

  • 官方标称约 5130 万参数(2-bit 量化后约 22 MB)的视觉模型适合做「读招牌、看仪表、判断有没有」这类窄任务,开放场景的物种识别不可靠。上线前要拿自己场景的图片测一遍。
  • 别只看 value。animal 的最高概率只有 0.64,describe() 里的物体框也是两个,和 count = 1 互相矛盾。可以设一个概率门槛,低于门槛就交给人或更大的模型复核。- 表示「哪个都不是」的概率。

第 5 步:把引擎和模型导出到文件夹,断网运行

官方文档的离线方案是:在联网的电脑上把引擎和权重下载到一个文件夹,拷到目标设备,再用环境变量指过去。

cactus download whistle --out ./offline
cactus fetch --out ./offline
ls -la offline

对照结果(节选):

  weights   ./offline/whistle.cact  16.92 MB
  engine    ./offline/libcactus.so
-rw------- 1 box box  1938312 Nov 12 00:22 libcactus.so
-rw------- 1 box box 16919407 Nov 12 00:22 whistle.cact

模拟一台全新的、不能联网的机器:把缓存目录指到一个空文件夹,打开 Hugging Face 离线模式,只用 ./offline 里的两个文件:

XDG_CACHE_HOME=/tmp/emptycache HF_HUB_OFFLINE=1 \
CACTUS_LIB_PATH=$PWD/offline/libcactus.so \
CACTUS_WHISTLE_WEIGHTS=$PWD/offline/whistle.cact \
python listen.py

对照结果(前 3 行):

text: And so, my fellow Americans, ask not what your country can
do for you, ask what you can do for your country.
language: en

转写结果和联网时一样,而且运行后 /tmp/emptycache 根本没有被创建,说明没有去下载任何东西。Needle 3 和 Mirage 同理,分别用 cactus download needle3、cactus download mirage 下载,对应的环境变量是 CACTUS_NEEDLE_WEIGHTS 和 CACTUS_MIRAGE_WEIGHTS。要给另一种设备准备引擎,用 cactus fetch --platform-tag manylinux2014_aarch64 这类参数。Python 包本身的离线安装,用 pip download cactus-compute 下载后再 pip install --no-index --find-links <目录> cactus-compute。

常见坑

  • 音频不是 16 kHz:直接传 44.1 kHz 的文件需要 [mic] 扩展,最省事的办法是先用第 1 步的 FFmpeg 命令转换。
  • 想转写中文:Whistle 不支持中文,传进去不会得到可用结果,中文语音请换别的模型。
  • 把 bbox 当像素用:Mirage 的坐标是 0 到 1000 的归一化值,画框前要按原图宽高换算。
  • 只看 value 不看概率:第 4 步的猫被认成狗,概率只有 0.64,设门槛才拦得住。
  • 离线时还在联网:忘了设 HF_HUB_OFFLINE=1 时,缺文件会去下载而不是立刻报错。部署到设备前用空缓存目录跑一遍,就能确认文件齐了。

小结

这一课用 Whistle 转写了一段英文演讲并拿到词级时间戳,把语音直接交给 Needle 3 做了工具调用;用 Mirage 读出了招牌上的文字,也在一张猫的照片上看到了它认错的情况,学会了用概率和交叉检查兜底;最后把引擎和权重导出到文件夹,用空缓存和离线模式验证了断网可用。三课合起来,就是用一个不到 80 MB 的本地包,完成「听懂、看懂、调用函数」这三件事的完整流程。