多模态 AI 实验

从 GitHub 前沿仓库提炼的多模态实验:CLIP 式图文对齐思想、向量检索、OCR 全流程,属于未完成但有研究价值的代码。

场景:前沿实验 技术栈:Python / CLIP / FAISS / OCR 来源:GitHub 参考

项目简介

多模态(Multimodal)指模型同时理解 文本、图像、音频 等信息。本页围绕“图文对齐”给出三个最小实验:特征提取、跨模态检索、OCR 流水线,帮助理解多模态模型的基本数据流。

代码示例:图文特征提取(CLIP 思路)

py
from PIL import Image
import torch

# 需 pip install open_clip_torch
import open_clip

model, _, preprocess = open_clip.create_model_and_transforms(
    "ViT-B-32", pretrained="laion2b_s34b_b79k"
)
tokenizer = open_clip.get_tokenizer("ViT-B-32")

def encode_text(texts: list[str]) -> torch.Tensor:
    tokens = tokenizer(texts)
    with torch.no_grad():
        return model.encode_text(tokens)

def encode_image(path: str) -> torch.Tensor:
    img = preprocess(Image.open(path)).unsqueeze(0)
    with torch.no_grad():
        return model.encode_image(img)

# 计算余弦相似度判断“图文是否匹配”
t = encode_text(["一只猫", "一片海"])
i = encode_image("cat.jpg")
print((i @ t.T).softmax(dim=-1))

代码示例:跨模态向量检索

py
import numpy as np

# 用 FAISS 建索引:把图片特征存库,用文本特征查询
# pip install faiss-cpu
import faiss

def build_index(vectors: list[np.ndarray]):
    d = vectors[0].shape[0]
    index = faiss.IndexFlatIP(d)          # 内积 = 余弦(归一化后)
    mat = np.vstack(vectors).astype("float32")
    faiss.normalize_L2(mat)
    index.add(mat)
    return index

def query(index, text_vec: np.ndarray, k: int = 5):
    q = text_vec.astype("float32").reshape(1, -1)
    faiss.normalize_L2(q)
    scores, idx = index.search(q, k)
    return idx[0], scores[0]

代码示例:OCR 全流程

py
# pip install paddleocr paddlepaddle
from paddleocr import PaddleOCR

ocr = PaddleOCR(use_angle_cls=True, lang="ch")

def ocr_image(path: str):
    result = ocr.ocr(path, cls=True)
    lines = []
    for line in result:
        for item in line:
            text = item[1][0]
            conf = item[1][1]
            lines.append((text, round(conf, 3)))
    return lines

# 输出后接正则清洗 + 结构化入库,即为完整文档数字化流水线

待完成方向

  • 接入真实图文数据集做 zero-shot 分类实验。
  • 把 OCR 结果接入命名实体识别(NER),构建知识库。
  • 尝试视频帧采样 + 图文对齐做视频检索。
  • 评估不同骨干(ViT / ConvNeXt)在中文场景的表现差异。

操作步骤

  1. pip install open_clip_torch faiss-cpu pillow 安装依赖。
  2. 准备一张猫的图片运行图文相似度示例,观察匹配分数。
  3. 把多张图片建索引,再用文字查询返回 Top-K,即完成检索闭环。

完整可运行工程

可直接运行零第三方依赖文本→图像检索

运行环境:Python 3.8+,仅标准库(示例图片用 P3 文本 PPM,记事本即可核对像素值)。先执行 python retrieval.py --make-samples ./samples 生成 4 张示例图;再执行 python retrieval.py ./samples "红色 明亮" 体验跨模态检索;--self-test 会校验语义对齐(同义图文相似度 > 0.98、异色 < 0.3)。

github-multimodal/ ├── retrieval.py P3 解析 + 图像/文本双编码器 + 余弦检索 + 自测 ├── requirements.txt 依赖清单(核心零依赖) └── README.md 运行步骤、与 CLIP 的对应关系与替换指引

retrieval.py

检索主脚本:把图像与文本编码到同一 4 维向量空间,用余弦相似度取 top-k。

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""retrieval.py —— 跨模态检索最小实现(文本 ↔ 图像,纯标准库)。

思路与 CLIP 一致:把两种模态编码到同一个向量空间,再用余弦相似度排序取 top-k。
本工程的编码器极简,但算法链路完整且真能跑对:

    图像 -> [R, G, B, 亮度](通道均值归一化到 0~1)
    文本 -> 颜色词 / 明暗词映射到同一 4 维语义空间

换成真实模型时,只需替换 encode_image() / encode_text() 两个函数(见 README)。

用法:
    python retrieval.py --make-samples ./samples       # 生成 4 张示例图片(P3 PPM 文本格式)
    python retrieval.py ./samples "红色 明亮"           # 检索最匹配的图片
    python retrieval.py ./samples "蓝色" --topk 2
    python retrieval.py --self-test                    # 零配置自测

图片格式:零依赖读取 P3 文本 PPM;若安装了 Pillow,可自行扩展读取 png / jpg。
"""
import argparse
import math
import os
import sys
import tempfile

try:
    sys.stdout.reconfigure(encoding='utf-8')
except Exception:
    pass

COLORS = {
    '红': (1.0, 0.0, 0.0), 'red': (1.0, 0.0, 0.0),
    '绿': (0.0, 1.0, 0.0), 'green': (0.0, 1.0, 0.0),
    '蓝': (0.0, 0.0, 1.0), 'blue': (0.0, 0.0, 1.0),
    '黄': (1.0, 1.0, 0.0), 'yellow': (1.0, 1.0, 0.0),
    '紫': (1.0, 0.0, 1.0), 'purple': (1.0, 0.0, 1.0),
    '青': (0.0, 1.0, 1.0), 'cyan': (0.0, 1.0, 1.0),
    '白': (1.0, 1.0, 1.0), 'white': (1.0, 1.0, 1.0),
    '黑': (0.0, 0.0, 0.0), 'black': (0.0, 0.0, 0.0),
    '灰': (0.5, 0.5, 0.5), 'gray': (0.5, 0.5, 0.5),
    '橙': (1.0, 0.5, 0.0), 'orange': (1.0, 0.5, 0.0),
}
BRIGHT_WORDS = ('明亮', '亮', 'bright', 'light')
DARK_WORDS = ('暗', 'dark', 'dim')


def read_ppm(path):
    """读取 P3 文本格式 PPM,返回 (像素列表 [(r,g,b), ...], 宽, 高, 最大值)。"""
    with open(path, encoding='utf-8') as fh:
        raw = fh.read()
    tokens = []
    for line in raw.splitlines():
        tokens.extend(line.split('#')[0].split())
    assert tokens and tokens[0].upper() == 'P3', '仅支持 P3 文本格式 PPM:%s' % path
    width, height, maximum = int(tokens[1]), int(tokens[2]), int(tokens[3])
    nums = [int(x) for x in tokens[4:4 + width * height * 3]]
    pixels = [(nums[i], nums[i + 1], nums[i + 2]) for i in range(0, len(nums) - 2, 3)]
    return pixels, width, height, maximum


def normalize(vec):
    norm = math.sqrt(sum(x * x for x in vec))
    return [x / norm for x in vec] if norm else list(vec)


def encode_image(path):
    """图像编码器:三通道均值 + 平均亮度,输出 4 维单位向量。"""
    pixels, _w, _h, maximum = read_ppm(path)
    n = len(pixels) or 1
    r = sum(p[0] for p in pixels) / n / maximum
    g = sum(p[1] for p in pixels) / n / maximum
    b = sum(p[2] for p in pixels) / n / maximum
    luma = (r + g + b) / 3.0
    return normalize([r, g, b, luma])


def encode_text(text):
    """文本编码器:颜色词映射到 RGB 三通道,明暗词映射到亮度维。"""
    vec = [0.0, 0.0, 0.0, 0.5]
    low = text.lower()
    hits = 0
    for word, rgb in COLORS.items():
        if word in low:
            for i in range(3):
                vec[i] += rgb[i]
            hits += 1
    if any(w in low for w in BRIGHT_WORDS):
        vec[3] = 1.0
    elif any(w in low for w in DARK_WORDS):
        vec[3] = 0.15
    if hits == 0:
        vec[:3] = [0.34, 0.34, 0.34]     # 认不出颜色词时退化为中性灰,避免零向量
    return normalize(vec)


def cosine(a, b):
    return sum(x * y for x, y in zip(a, b))


def index_folder(folder):
    """把目录里所有可解析图片编码进内存索引(真实系统里应替换为向量库)。"""
    index = []
    for name in sorted(os.listdir(folder)):
        path = os.path.join(folder, name)
        if not os.path.isfile(path):
            continue
        try:
            index.append((name, path, encode_image(path)))
        except Exception:
            continue                      # 非 PPM / 损坏文件直接跳过
    return index


def search(folder, query, topk=3):
    qv = encode_text(query)
    scored = [(cosine(qv, vec), name, path) for name, path, vec in index_folder(folder)]
    scored.sort(key=lambda x: -x[0])
    return scored[:topk]


def write_sample(path, rgb, size=8, maximum=255):
    lines = ['P3', '%d %d' % (size, size), str(maximum)]
    lines += ['%d %d %d' % rgb] * (size * size)
    with open(path, 'w', encoding='utf-8', newline='\n') as fh:
        fh.write('\n'.join(lines) + '\n')


SAMPLES = (('red.ppm', (255, 0, 0)), ('green.ppm', (0, 255, 0)),
           ('blue.ppm', (0, 0, 255)), ('white.ppm', (255, 255, 255)))


def make_samples(folder):
    os.makedirs(folder, exist_ok=True)
    paths = []
    for name, rgb in SAMPLES:
        p = os.path.join(folder, name)
        write_sample(p, rgb)
        paths.append(p)
    return paths


def selftest():
    tmp = tempfile.mkdtemp(prefix='multimodal-selftest-')
    make_samples(tmp)
    for query, expect in (('红色', 'red.ppm'), ('绿色', 'green.ppm'),
                          ('蓝色', 'blue.ppm'), ('白色 明亮', 'white.ppm')):
        hits = search(tmp, query, topk=1)
        assert hits and hits[0][1] == expect, (query, hits)
    top2 = search(tmp, '红', topk=2)
    assert len(top2) == 2 and top2[0][0] > top2[1][0], top2
    score = cosine(encode_text('红'), encode_image(os.path.join(tmp, 'red.ppm')))
    assert score > 0.98, '同义图文相似度应接近 1:%s' % score
    assert cosine(encode_text('红'), encode_image(os.path.join(tmp, 'green.ppm'))) < 0.3
    print('selftest OK:P3 解析 / 双模态编码 / 余弦排序 / top-k / 语义对齐 全部通过')


def main():
    ap = argparse.ArgumentParser(description='跨模态(文本 -> 图像)检索最小实现')
    ap.add_argument('folder', nargs='?', help='图片目录(P3 文本 PPM)')
    ap.add_argument('query', nargs='?', help='查询文本,如 "红色 明亮"')
    ap.add_argument('--topk', type=int, default=3, help='返回条数')
    ap.add_argument('--make-samples', metavar='DIR', help='生成 4 张示例图片到指定目录')
    ap.add_argument('--self-test', action='store_true', help='零配置自测')
    a = ap.parse_args()
    if a.self_test:
        selftest()
        return
    if a.make_samples:
        for p in make_samples(a.make_samples):
            print('已生成:', p)
        return
    if not (a.folder and a.query):
        ap.error('请提供 目录 与 查询文本,或使用 --self-test')
    for score, name, path in search(a.folder, a.query, a.topk):
        print('%.4f  %s' % (score, name))


if __name__ == '__main__':
    main()

requirements.txt

依赖清单:核心仅标准库;Pillow / numpy / open_clip / chromadb 为可选增强。

text
# github-multimodal 工程依赖清单
# 核心只用 Python 标准库(math / os / argparse),零安装即可运行:
#     python retrieval.py --self-test
# 要求 Python 3.8+。
# 示例图片使用 P3 文本格式 PPM,纯文本、无需 Pillow 即可读写。
#
# 可选增强(把示例编码器换成真实模型时再装):
Pillow>=10.0.0          # 直接读 png / jpg / webp,替换 read_ppm
numpy>=1.26.0           # 相似度计算换成矩阵运算,支持批量检索
open-clip-torch>=2.24.0 # 真实的 CLIP 图文双塔模型
chromadb>=0.4.22        # 换成本地向量库,支撑十万级图片检索
# 安装:pip install -r requirements.txt

README.md

运行步骤、与 CLIP 的逐项对应关系、需先改的配置项表。

markdown
# github-multimodal · 跨模态图文检索(可运行工程)

用「文本找图片」把多模态检索的完整链路跑通:**双模态编码 → 统一向量空间 → 余弦相似度 → top-k**。
和 CLIP 的差别只在编码器强弱:本工程用颜色/明暗语义做编码,零依赖、可离线复现;换成 CLIP 即为生产方案。

## 目录结构
```
github-multimodal/
├── retrieval.py       # P3 PPM 解析 + 图像/文本编码 + 余弦检索 + 自测
├── requirements.txt   # 依赖清单(核心零依赖)
└── README.md
```

## 运行环境
- Python 3.8+;核心零第三方依赖。
- 示例图片为 P3 文本 PPM(用记事本就能打开核对像素值),不需要任何图像库。

## 快速开始
```bash
# 1) 生成 4 张示例图片(红/绿/蓝/白)
python retrieval.py --make-samples ./samples

# 2) 用文本检索图片
python retrieval.py ./samples "红色"
python retrieval.py ./samples "蓝" --topk 2
python retrieval.py ./samples "白色 明亮"

# 3) 零配置自测(含语义对齐断言)
python retrieval.py --self-test
```

## 需先改的配置项
| 配置项 | 位置 | 说明 |
|---|---|---|
| 图像编码器 | `retrieval.py` → `encode_image()` | 现为 RGB 均值;换 CLIP 时改成模型推理,输出 512 维向量 |
| 文本编码器 | `retrieval.py` → `encode_text()` | 现为颜色/明暗词典;换 CLIP 时改成同一模型的文本塔 |
| 颜色词表 | `retrieval.py` → `COLORS` | 按你的业务补充中英文词条 |
| 图片格式 | `retrieval.py` → `read_ppm()` | 只有 P3 PPM 零依赖;要读 jpg/png 请装 Pillow 并替换该函数 |
| 向量库 | `retrieval.py` → `index_folder()` | 图片量大时替换为 Chroma / FAISS |

## 与 CLIP 的对应关系
| 本工程 | CLIP 生产方案 |
|---|---|
| `encode_image()` 取通道均值 | 图像塔(ViT / ResNet)输出 512 维 |
| `encode_text()` 词典映射 | 文本塔(Transformer)输出 512 维 |
| `cosine()` + 排序 | 完全相同,可换 FAISS 加速 |
| `index_folder()` 内存索引 | 向量数据库持久化 |

需先改的配置项(4 处):

  • encode_image() / encode_text():换成 CLIP 双塔推理即为生产方案(余弦检索部分无需改动)
  • 图片格式:默认只读 P3 PPM;要读 jpg/png 请装 Pillow 并替换 read_ppm()
  • COLORS 词典:按业务补充中英文颜色/属性词
  • 图片量大时把 index_folder() 换成 Chroma / FAISS 持久化向量库
复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考