Python从Word批量提取校徽并构建FTS5检索与感知哈希去重流水线
2026/9/18 1:20:54 网站建设 项目流程

简介:面向高校师生、招生宣传与校园文化爱好者整理的《中国大学最全最完整的标志图+校徽》文档,系统收录全国各类高校的标志与校徽图像,帮助读者快速识别院校、了解标识背后的历史沿革与学科特色。内容按院校类型与地域分布编排,涵盖综合、理工、农林、医药、师范、财经、艺术体育及民族院校等,并涉及北大“红楼”、人大“实事求是”、北航飞机元素、协和医学院医学符号、农林院校自然元素等设计要点,可作为校史梳理、视觉设计与校际对比的素材。资源为单一doc文件,文件总数1个,压缩包约21.96MB,以图片与文字说明为主,体量适中、便于离线查阅与二次整理。目前已有1517人浏览学习。对于需要集中比对各校校徽、提炼设计语言或准备校园文化展示的读者,这份合集省去逐一检索官网的时间,具备较高的参考与收藏价值。

1. 一份 .doc 里的上千枚校徽,为什么要先拆成资产库

需求方甩过来一个文件:中国大学最全最完整的标志图+校徽(最全).doc。打开一看,几百上千枚校徽挤在 Word 里,有的图下面跟着校名,有的校名在图上边,有的什么都不写,还有一小撮是 WMF 矢量图或 OLE 嵌入对象。对方的要求通常是:做个能搜的小程序、能按规格下载、能出一份带编号的目录清单。

这时候最好先把预期压下来:真正花时间的不是写检索接口,而是把这份文档还原成「图片 + 校名 + 多规格文件」的三元组。做过一次就知道,直接从 Word 里肉眼复制粘贴,一千枚图能磨掉两天,而且还漏、还重、还叫错名。把提取、规范化、索引做成一条可重跑的流水线,后面新增学校只需要把新图丢进 inbox 目录。

接下来的路径是:从 .doc 解包出原始图片并绑定校名,把图统一成透明底多规格资产,用 SQLite 建可检索索引并暴露接口,最后用感知哈希去重并把结果回导成一份新的 .docx 目录。适合后端、数据工程和做校园类产品的同学;只要能跑 Python 就能跟着落地。

注意:校徽属于学校标识,涉及商标与著作权,素材用于商用前需要取得相应授权,流水线只解决技术问题。

2. 用 python-docx 从 .doc 中批量提取校徽原图与校名对应关系

2.1 老版 .doc 先转 docx:soffice 无头转换的必调参数

二进制 .doc 无法直接按 zip 解析,先用 LibreOffice 无头模式转成 docx。命令本身很短,坑在参数和并发上:

# 单文件转换;filter 名写全,避免某些版本默认转成 odt soffice --headless \ --convert-to 'docx:MS Word 2007 XML' \ --outdir ./converted \ './raw/中国大学最全最完整的标志图+校徽(最全).doc' # 批量并发时每个进程必须给独立 profile,否则第二个进程直接退出 for f in ./raw/*.doc; do soffice --headless \ -env:UserInstallation=file:///tmp/lo_$RANDOM \ --convert-to 'docx:MS Word 2007 XML' \ --outdir ./converted "$f" done

参数含义:--headless不弹界面,适合服务器;--convert-to后面用扩展名:FilterName的形式显式指定过滤器,只写docx在部分版本上会走到不确定的默认过滤器;--outdir必须存在,否则静默失败;-env:UserInstallation=file:///tmp/lo_xxx是并发关键,LibreOffice 用用户 profile 加锁,同 profile 并发会互相踢掉。

转换完成后先做一次体检,对比转换前后的媒体文件数量和体积。老 .doc 里的 WMF 有可能在转换过程中被光栅化成低分辨率 PNG,如果发现word/media/里的图片明显变小,就别用转换结果,改走 2.3 的原始解包路径。

2.2 按段落顺序绑定图片与校名:rId 到 image part 的映射

docx 里正文图片通过r:embed关系 ID 指向word/media/下的实体文件,段落顺序就是校名和图片的天然对应关系。python-docx 没有现成的「取图并取邻近文字」接口,自己遍历 XML 更稳:

# extract_logos.py import os, json from docx import Document from docx.oxml.ns import qn NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'} def iter_images_with_context(docx_path, ctx_window=3): doc = Document(docx_path) ctx = [] # 最近若干段非空文字,作为校名候选 for idx, para in enumerate(doc.paragraphs): text = para.text.strip() if text: ctx.append(text) ctx = ctx[-ctx_window:] # 只留最近 3 段,防止串到上一所学校 for blip in para._p.findall('.//a:blip', NS): rid = blip.get(qn('r:embed')) if not rid: continue part = doc.part.related_parts.get(rid) if part is None: continue yield { 'para_index': idx, 'rid': rid, 'name_hint': ctx[-1] if ctx else '', 'ext': os.path.splitext(str(part.partname))[1].lower(), 'blob': part.blob, } if __name__ == '__main__': rows = list(iter_images_with_context('./converted/校徽.docm.docx')) with open('raw_manifest.jsonl', 'w', encoding='utf-8') as fp: for i, r in enumerate(rows): out = f'./raw_media/{i:05d}{r["ext"]}' os.makedirs('./raw_media', exist_ok=True) with open(out, 'wb') as im: im.write(r['blob']) fp.write(json.dumps({k: v for k, v in r.items() if k != 'blob'}, ensure_ascii=False) + '\n') print('extracted', len(rows))

逻辑说明:ctx是一个滑动窗口,只保留最近 3 段文字,因为 Word 里常见的排版是「校名 — 图片 — 空白段 — 下一校名」,窗口开太大会把上一所学校的名字带进来。related_parts是 rId 到 part 的映射,同一个图片被引用多次时 part 相同,用rid去重可以避免重复落盘。para._p.findall('.//a:blip', NS)用命名空间前缀写 XPath,比触摸inline_shapes更可靠,因为浮动图片(wp:anchor)不会出现在inline_shapes里。

一个容易忽略的位置:表格里的图片。很多「最全版」文档用三列表格排版,图片全在单元格里。补一段for table in doc.tables: for cell in ...: cell.paragraphs的递归遍历,复用上面同一套 blip 查找逻辑即可。

2.3 WMF/EMF 与 OLE 嵌入:提取失败时的三条兜底路径

老文档里的校徽有三种形态,处理路径完全不同:

素材形态在 docx 中的表现推荐处理路径主要风险
位图 JPG/PNGword/media/image*.png直接落盘,走规范化白底、分辨率参差
矢量 WMF/EMFword/media/image*.wmfmagick 或 inkscape 转 300dpi PNG线宽丢失、字体缺失
OLE 嵌入对象word/embeddings/oleObject*.binLibreOffice 转 PDF 后按页裁切定位不准、需人工兜底

先把包结构列出来确认命中了哪一种:

# docx 本质是 zip,先看清单再决定策略 unzip -l './converted/校徽.docx' | grep -E 'media|embeddings' # WMF/EMF 转高分辨率位图,密度给到 300 足够印刷级 magick -density 300 -background none './raw_media/00012.wmf' './png/00012.png' # 没有 ImageMagick 时用 Inkscape,导出尺寸用 DPI 控制 inkscape './raw_media/00012.wmf' --export-type=png --export-dpi=300 --export-filename='./png/00012.png' # OLE 兜底:整体转 PDF,再用 pdftocairo 按页导出矢量 soffice --headless --convert-to pdf --outdir ./pdf './converted/校徽.docx' pdftocairo -svg -f 12 -l 12 './pdf/校徽.pdf' './svg/00012.svg'

参数上,-density 300是矢量转位图的渲染密度,直接决定输出像素;-background none保留透明通道,少了它圆形校徽会被填成白底。pdftocairo -f/-l是起止页码,页码要和 manifest 里的para_index对齐,这个对齐关系需要人工抽查十几条确认,不要假设完全一致。

提示:三种路径处理完的图片统一进raw_media/,并在 manifest 里标记source_kind,后面规范化时只对这一层做操作,原始文件永远不动,方便回滚。

3. 校徽图片规范化:透明底、统一尺寸与多规格输出

3.1 去白底与自动裁边:alpha 阈值怎么定

校徽多是圆形或盾形图案配白底,前端展示时白底会露出方形边框,所以必须去底加透明。朴素做法是「接近白色的像素 alpha 置 0」,直接这么干会在深色线条边缘留下一圈硬白边,因为抗锯齿像素是灰白渐变的。正确的做法是按白度做线性衰减:

# normalize.py import numpy as np from PIL import Image def normalize(src, dst, size=1024, white=250, soft=12, pad_ratio=0.06): im = Image.open(src).convert('RGBA') arr = np.array(im).astype(np.float32) rgb, alpha = arr[..., :3], arr[..., 3] # 用三通道最小值衡量“白度”:只有 R/G/B 同时接近 255 才算背景 whiteness = rgb.min(axis=2) # soft 区间内线性过渡,避免边缘出现硬白圈 factor = np.clip((white - whiteness) / soft, 0.0, 1.0) alpha = alpha * factor out = Image.fromarray(np.dstack([rgb, alpha]).astype('uint8'), 'RGBA') bbox = out.getbbox() # 基于 alpha>0 求有效内容边界 if bbox: out = out.crop(bbox) # 等比缩放到最长边不超过 size,再补一圈留白 w, h = out.size scale = size / max(w, h) if scale < 1: out = out.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS) pad = int(size * pad_ratio) canvas = Image.new('RGBA', (size + 2 * pad, size + 2 * pad), (0, 0, 0, 0)) canvas.paste(out, ((canvas.width - out.width) // 2, (canvas.height - out.height) // 2)) canvas.save(dst, optimize=True) return dst normalize('./raw_media/00012.png', './logos/00012/logo@1024.png')

参数怎么调:white=250是判定为纯背景的阈值,调低会开始吃掉浅灰线条,调高则白色残留;soft=12是过渡带宽,校徽线条细就调大到 16~20,色块粗就调到 6~8;pad_ratio=0.06给四周留 6% 空白,前端圆形头像裁剪时才不会切到图案边缘。getbbox()依赖 alpha 通道,所以裁边必须放在去底之后。

3.2 多规格输出与目录结构约定

一次规范化同时产出所有需要的规格,避免前端临时压缩:

文件名尺寸格式用途
logo@1024.png1024+padPNG详情页、印刷备用
logo@512.png512+padPNG列表大图
logo@256.webp256+padWebP移动端列表,体积约为 PNG 的 1/4
logo@128.webp128+padWebP排行榜、标签
logo.svg原始矢量SVG存在矢量源时保留,无限缩放
logo.ico多尺寸ICO桌面端与收藏夹图标

目录按 slug 分文件夹,一个学校一个目录,内部文件名固定,这样接口只要返回 slug,前端自己拼路径就行。给 slug 定规则:小写、用连字符、不带中文,例如peking-university;重名的学校加城市后缀区分。所有规格都从 1024 那一份缩放生成,保证视觉一致,不要各自从原图缩,否则圆角粗细会有肉眼可见的差异。

3.3 命名与元数据:slug、拼音、英文名三者对齐

校徽库能不能用,八成取决于元数据表准不准。一份catalog.json建议至少包含这些字段:slugname_zh(全称)、name_short(常用简称)、name_enpinyinpinyin_initialsaliases(数组,放「北大」「PKU」这类别称)、source_kindfile_1024file_256file_svgphashupdated_at

拼音字段不能省,用户搜「beida」或「bd」时靠它命中,拼音用 pypinyin 生成:

from pypinyin import lazy_pinyin, Style def pinyin_fields(name): return { 'pinyin': ''.join(lazy_pinyin(name)), 'pinyin_initials': ''.join(lazy_pinyin(name, style=Style.FIRST_LETTER)), } print(pinyin_fields('北京大学')) # {'pinyin': 'beijingdaxue', 'pinyin_initials': 'bjdx'}

name_shortaliases只能半自动生成,做法是把原始文档里的文字全部抽出来,跑一遍频次统计,出现次数高且长度在 2~6 字之间的词组作为候选,然后人工过一遍。这一步别省,很多校徽库搜不到,不是因为接口烂,是因为别名表里没有用户真实会输入的那个词。

4. 校徽检索服务:拼音、别名与模糊匹配的索引设计

4.1 SQLite FTS5 建索引:表结构与建表语句

数据量在万级以内,SQLite 完全够用,别一上来就上搜索引擎。校名是短文本,中文分词器反而会把「北京大学」切成不确定的词,用 FTS5 的 trigram 分词更合适,它按三字符滑动窗口建索引,天然支持任意子串匹配:

-- logos.sql CREATE TABLE IF NOT EXISTS school_logo ( slug TEXT PRIMARY KEY, name_zh TEXT NOT NULL, name_short TEXT, name_en TEXT, aliases TEXT, -- JSON 数组,检索时拼成一行存全文 pinyin TEXT NOT NULL, pinyin_initials TEXT, file_1024 TEXT, file_256 TEXT, file_svg TEXT, phash TEXT, updated_at TEXT ); CREATE INDEX IF NOT EXISTS idx_phash ON school_logo(phash); -- trigram 需要 SQLite >= 3.34,用 content= 做外部内容表,省一份存储 CREATE VIRTUAL TABLE IF NOT EXISTS school_logo_fts USING fts5( name_zh, name_short, name_en, aliases, pinyin, pinyin_initials, content='school_logo', content_rowid='rowid', tokenize='trigram' ); -- 首次及每次重建后同步一次 INSERT INTO school_logo_fts(school_logo_fts) VALUES('rebuild');

content='school_logo'表示 FTS 表不复制数据,只存倒排索引,查询时回原表取行;content_rowid='rowid'是关联键,必须写。trigram有一个副作用:查询串短于 3 个字符时匹配不到,所以「北大」这种两字简称要么靠name_short精确等值查,要么走 LIKE 兜底,接口里两条路都留着。

4.2 FastAPI 暴露按校名查校徽的接口

接口只做一件事:接收用户输入的中文、拼音或简称,返回可用的图片路径。关键点是把查询串也做拼音转换,再分别命中中文、全拼、首字母三条路径:

# api.py import sqlite3 from fastapi import FastAPI, Query from pypinyin import lazy_pinyin, Style DB = "logos.db" app = FastAPI(title="CampusLogo API") def to_pinyin(q: str): return ("".join(lazy_pinyin(q)), "".join(lazy_pinyin(q, style=Style.FIRST_LETTER))) @app.get("/api/logo/search") def search(q: str = Query(..., min_length=1, max_length=32), limit: int = Query(20, ge=1, le=50)): q = q.strip() full, initials = to_pinyin(q) conn = sqlite3.connect(DB) conn.row_factory = sqlite3.Row sql = """ SELECT slug, name_zh, name_short, aliases, file_256, file_1024 FROM school_logo WHERE name_zh LIKE :kw OR name_short = :raw OR pinyin LIKE :py OR pinyin_initials = :ini ORDER BY length(name_zh) ASC, name_zh ASC LIMIT :limit """ rows = conn.execute(sql, { "kw": f"%{q}%", "raw": q, "py": f"%{full}%", "ini": initials, "limit": limit, }).fetchall() return {"query": q, "count": len(rows), "items": [dict(r) for r in rows]}

min_lengthmax_length不是装饰:q会拼进 LIKE,长度失控会拖慢全表扫描;ORDER BY length(name_zh) ASC让「北京大学」排在「北京大学医学部」前面,符合用户的直觉预期;pinyin_initials用等值而不是 LIKE,因为首字母是顶点查询,用 LIKE 会命中一堆无关项。

调用验证:

curl -s 'http://127.0.0.1:8000/api/logo/search?q=bjdx' | python -m json.tool curl -s 'http://127.0.0.1:8000/api/logo/search?q=%E5%8C%97%E5%A4%A7&limit=5'

4.3 模糊匹配阈值与常见误召回

精确路径覆盖不到拼错的输入,需要一层模糊匹配,用 rapidfuzz 做候选打分,但阈值必须卡住,否则「中大」会同时召回好几所学校:

用户输入期望结果命中策略误召回风险
北京大學北京大学繁简归一 + 精确
bj daxue北京大学去空格后拼音匹配中,短串易撞
中大中山大学 / 中南大学别名表 + 属地加权高,必须给多个结果
huazhong华中科技大学等拼音前缀 + 人工排序高,同名高校多

打分链路写成三步:先用fuzz.rationame_zh + name_short + aliases逐个算分,取score_cutoff=70;然后对分数在 70~85 之间的结果,用partial_ratio复算一遍,避免「华科」这种子串被整体相似度压低;最后按「别名精确命中 > 简称命中 > 全称包含 > 拼音包含」重排序。繁简归一用 opencc 或一张小型字符映射表即可,校名里的繁体字量很小。

提示:模糊匹配只用于展示层的候选推荐,写库和导出时永远用 slug 做主键,别把用户输入直接当文件名,否则路径穿越和非法字符问题会在后面集中爆发。

5. 去重、质量校验与回导 Word 目录的进阶做法

5.1 感知哈希去重与人工复核清单

「最全版」文档最大的隐性问题是同一所学校出现两三次,图案还略有差别——一次是横版、一次是圆形、一次带中文、一次只带英文。用感知哈希粗筛最快:

import imagehash from PIL import Image def phash_of(path, size=16): # hash_size 越大越敏感;16 对应 256 位,比默认 8 更适合区分校徽 return imagehash.phash(Image.open(path).convert('L'), hash_size=size) def hamming(a, b): return bin(int(str(a), 16) ^ int(str(b), 16)).count('1') h1, h2 = phash_of('./logos/a/logo@256.webp'), phash_of('./logos/b/logo@256.webp') print(hamming(h1, h2)) # <= 6 视为疑似同图,进入复核清单

阈值给 6 是个经验值,调小会漏、调大会把「圆形 + 麦穗」这类构图相似的完全不同的校徽也拉进来。所以 phash 只做粗筛,复核按三档走:哈希距离 ≤ 6 且长宽比差 < 5% 标为「高度疑似」;哈希距离 ≤ 10 标为「待复核」;其余直接放过。把结果导出成一张 CSV,字段给slug_aslug_bdistanceratio_diffverdict,人工只需填最后一列。校验还有两项必做:一是用 Pillow 打开每个输出文件确认没有半截损坏图(Image.open(p).load()),二是统计透明像素占比,占比超过 95% 的图基本是去底参数调过头把图案吃掉了。

回导一份可交付的目录文档,用 python-docx 生成固定列宽的表格,图片塞进单元格的 run 里:

from docx import Document from docx.shared import Cm doc = Document() table = doc.add_table(rows=0, cols=4) table.style = 'Table Grid' for row in rows: # rows 来自 catalog.json cells = table.add_row().cells cells[0].text = row['name_zh'] cells[1].text = row['name_short'] or '' cells[2].text = row['pinyin_initials'] cells[3].paragraphs[0].add_run().add_picture(row['file_256'], width=Cm(1.6)) doc.save('./dist/中国高校校徽目录.docx')

add_picture只能挂在 run 上,宽高必须显式给,否则 Word 会按原始像素渲染,1024 的图能把表格撑爆;固定 1.6 厘米宽配合Table Grid样式,导出后行列对齐基本不用再调。整条流水线建议做成make extract && make normalize && make index && make export四步,中间产物分目录落盘,任何一步失败都能从上一层重跑,不用把一千枚图重新抠一遍。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询