ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从.doc到可检索校徽库:文档图片提取、去重与FTS5检索

从.doc到可检索校徽库:文档图片提取、去重与FTS5检索 简介高校校徽标志图汇编面向设计从业者、校史与教育文化研究者以及需要制作课件或校际对比素材的师生解决零散查找各高校校徽素材耗时、来源不一的问题。整包为1个doc文档约21.96MB内容按院校逐条排布覆盖北京、天津、河北、东北、华东、华中、华南、西南、西北等地区数百所高校从北京大学、中国人民大学、北京航空航天大学等知名学府到地方本科、高职及民族、医药、农林、艺术体育类院校。各校标识承载着创办理念与学科特色如北大的红楼意象、人大的「实事求是」校训、北航的飞机元素、协和医学院的医学符号、农林院校的农作物与畜牧形象等便于对照理解其历史沿革、地域文化与专业优势。文档检索方便适合校徽比对、文化梳理与素材参考目前已有1517人学习下载。1. 从一份 .doc 到可检索校徽库这份资源到底解决了什么问题手上这份《中国大学最全最完整的标志图校徽最全.doc》体积不大打开却有点反直觉——五百多所高校的名称从「北京大学」一路排到「沈阳大学」中间没有逗号、没有换行、没有任何分隔符整段像被压扁了一样第一次看的人多半会以为文件损坏。它其实是把 Word 当图册用的老派做法段落里交替放置院校名和内嵌图片Word 的二进制容器把媒体流塞进复合文档资源管理器里能预览脚本却一行都读不出来。对做教育类产品、招生系统、志愿填报工具或者内部资料库的团队来说这类素材的痛点高度一致图就在那儿但抽不出来就算抽出来了也没法按省份、按办学层次、按名称拼音检索更没法做去重和版本管理。手工另存五百次不现实一张张截图更不可维护。这份资源的真正价值在于它同时提供了两样东西——完整的院校清单和对应的校徽图只要把媒体流抽出来、把那一长串校名切成结构化记录、把重复和低质图片剔掉它就能变成一张可编程调用的校徽资产表。2. 校徽图从 .doc 二进制流里抽OLE2 与 OOXML 两条路径2.1 先判断容器格式别一上来就改后缀.doc这个后缀本身说明不了任何问题。Word 97-2003 用的是 OLE2 复合文档Compound File Binary Format本质是一个小型文件系统图片作为独立 Stream 存在Data流里Word 2007 之后改成 OOXML本质就是一个 ZIP图片平铺在word/media/目录下。把 .docx 改名成 .doc、或者反过来是这类素材最常见的坑——python-docx 会抛PackageNotFoundErrorantiword 则是空输出。判断只要读文件头 8 个字节xxd -l 8 校徽最全.doc # 50 4b 03 04 ... - OOXML/ZIP直接按 docx 处理 # d0 cf 11 e0 a1 b1 1a e1 - OLE2 复合文档需要先转换-l 8只读前 8 字节不会把整个文件拉进内存。对照关系如下前 8 字节容器格式处理路径50 4B 03 04OOXML (ZIP)unzip -d直接解包D0 CF 11 E0 A1 B1 1A E1OLE2 复合文档LibreOffice 无头转换2.2 LibreOffice 无头转换比 antiword 更适合带图文档antiword 这类工具只负责吐文字图片会被整体丢弃对这份资源等于没用。真正能保住媒体流的是命令行转换soffice --headless \ --convert-to docx:MS Word 2007 XML \ --outdir ./converted \ 校徽最全.doc参数含义--headless不启动图形界面--convert-to后面是「目标扩展名:过滤器名」过滤器名写错会静默回退到默认过滤器转出来的东西未必是标准 docx--outdir目录必须提前存在否则直接报错退出。批量跑还有个隐藏坑——多个转换进程会抢同一个用户配置目录得显式隔离soffice -env:UserInstallationfile:///tmp/lo_$$ --headless \ --convert-to docx --outdir ./converted ./*.doc$$是当前 shell 的 PID保证每个进程用独立的UserInstallation并发时才不会互相锁死。注意转换后的 docx 体积通常会涨一圈因为原文档里的 WMF 矢量图被重新嵌入。这一步不要用--convert-to pdfPDF 会重新采样图片反而丢掉原始分辨率。2.3 解包 docx先摸清图有多少张mkdir -p unpack unzip -o converted/校徽最全.docx -d unpack ls unpack/word/media | wc -l du -sh unpack/word/mediaunzip -o覆盖已有文件重复执行不会卡在交互提示上。word/media/下的文件按image1.png、image2.jpeg、image3.wmf顺序编号编号大致对应插入顺序但删改过的文档会出现编号空洞不能直接拿编号跟校名一一对应。2.4 用 python-docx 按段落顺序把校名和图片配对真正要的是「哪张图属于哪所学校」得按文档流顺序遍历段落从 XML 里捞a:blip节点的r:embed关系 IDfrom docx import Document from docx.oxml.ns import qn import pathlib def iter_para_images(docx_path, out_dir): 按段落顺序产出 (段落文本, 图片二进制, 落盘文件名) doc Document(docx_path) out_dir pathlib.Path(out_dir) out_dir.mkdir(parentsTrue, exist_okTrue) for idx, para in enumerate(doc.paragraphs): text para.text.strip() # a:blip 是 DrawingML 中承载图片引用的节点 for blip in para._p.findall(.// qn(a:blip)): rid blip.get(qn(r:embed)) if not rid: continue # r:link 是外链图本地没有数据 part doc.part.related_parts.get(rid) if part is None: continue suffix pathlib.Path(str(part.partname)).suffix or .bin name f{idx:04d}_{rid}{suffix} (out_dir / name).write_bytes(part.blob) yield text, part.blob, name for text, blob, name in iter_para_images(converted/校徽最全.docx, ./media): if blob: print(f{name}\t{len(blob):8} B\t{text[:20]})逻辑说明doc.part.related_parts是 rId 到 Part 对象的映射part.blob就是原始字节part.partname保留了 Word 内部路径形如/word/media/image7.png后缀名可信直接沿用能避免格式误判。段落文本只截前 20 字做人工核对因为校名段落里往往混着空格和制表符。两处值得改的参数如果图片是浮动锚定的wp:anchor而不是内联的wp:inline段落归属会失真此时应按 body 的 XML 子节点顺序遍历而不是用doc.paragraphs另外part.blob对大图吃内存五百多张图要边遍历边落盘不要先攒成 list。2.5 WMF/EMF 矢量图单独处理OLE2 时代的 Word 内嵌矢量图默认是 WMFPillow 直接Image.open会抛UnidentifiedImageError媒体类型Pillow 支持处理方式PNG / JPEG支持直接读GIF / BMP支持直接读注意调色板转换WMF / EMF不支持libreoffice --convert-to png或 ImageMagick 带 libwmf delegateTIFF支持需确认压缩方式部分 LZW 变体会失败# 矢量图转位图务必显式给分辨率 soffice --headless --convert-to png:Impress PNG Export \ --outdir ./png ./media/0003_rId9.wmf矢量转位图不给分辨率默认出来的可能只有 96 DPI放进 Sprite 里糊成一片。稳妥做法是转成上限 1024×1024 的 PNG再在归一化阶段统一缩到目标尺寸。3. 把连成一串的校名切成结构化记录后缀锚点切分与省份归位3.1 这一长串校名为什么不能按分隔符切正文从「北京大学」一路排到「沈阳大学」没有逗号、没有换行、没有制表符——它原本是 Word 里的连续文本段落分隔在转换链路里丢掉了。想按[\s,、]切结果整段只返回一条。这不是数据坏了是切分锚点选错了中文校名里唯一稳定的边界信号是后缀词。3.2 用「大学/学院/学校」做锚点回切import re SUFFIX re.compile(r(大学|学院|学校)([^]{1,12})?) def split_schools(raw: str): names, pos [], 0 for m in SUFFIX.finditer(raw): end m.end() name raw[pos:end].strip() if name: names.append(name) pos end tail raw[pos:].strip() if tail: # 收尾最后一段没匹配到后缀 names.append(tail) return names逻辑说明finditer从左到右找第一个后缀词把它之前到上一个切点之间的全部字符当作一个校名。之所以能这么干是因为「大学」「学院」「学校」在中文高校名称里几乎只出现在末尾不存在嵌在中间的情况。[^]{1,12}专门吃「中国石油大学华东」「中国地质大学武汉」这类括号后缀限制 12 个字符是防止误吞正文里的长括号内容。跑完必须抽查的边界情况原始片段切分结果说明中国石油大学华东青岛科技大学中国石油大学华东 / 青岛科技大学括号后缀正确附着河南职业技术学院湖南工业职业技术学院两条独立记录「学院」后缀天然覆盖「职业技术学院」山西中医学院呼伦贝尔学院两条独立记录相邻校名无分隔符也能切开沈阳大学沈阳大学收尾分支处理3.3 省份与办学层次补全切出来只是字符串检索场景还得知道它在哪个省、是本科还是专科。省份没法从校名稳定推断「中国海洋大学」在山东「中国计量学院」在浙江最省事的办法是关键字映射加人工校验PROVINCE_HINT { 北京: 北京, 天津: 天津, 上海: 上海, 重庆: 重庆, 河北: 河北, 山西: 山西, 内蒙古: 内蒙古, 辽宁: 辽宁, 沈阳: 辽宁, 大连: 辽宁, 吉林: 吉林, 黑龙江: 黑龙江, 哈尔滨: 黑龙江, 江苏: 江苏, 南京: 江苏, 浙江: 浙江, 杭州: 浙江, 安徽: 安徽, 合肥: 安徽, 福建: 福建, 厦门: 福建, 江西: 江西, 南昌: 江西, 山东: 山东, 青岛: 山东, 河南: 河南, 郑州: 河南, 湖北: 湖北, 武汉: 湖北, 湖南: 湖南, 长沙: 湖南, 广东: 广东, 广州: 广东, 广西: 广西, 海南: 海南, 四川: 四川, 成都: 四川, 贵州: 贵州, 云南: 云南, 西藏: 西藏, 陕西: 陕西, 西安: 陕西, 甘肃: 甘肃, 兰州: 甘肃, 青海: 青海, 宁夏: 宁夏, 新疆: 新疆, } def guess_province(name: str) - str: for key in sorted(PROVINCE_HINT, keylen, reverseTrue): if name.startswith(key) or key in name[:6]: return PROVINCE_HINT[key] return 未知逻辑说明sorted(..., keylen, reverseTrue)让「内蒙古」「黑龙江」这类三字省名优先于两字前缀否则「内蒙古工业大学」可能被更短的键抢先命中。key in name[:6]允许匹配出现在校名中间「上海交通大学」靠开头命中「华中科技大学」就必须靠「武汉」。关键词表不可能全覆盖实际项目里我会把未知的条目导出来手工填一张修正表通常一轮就收敛。办学层次判断更简单后缀带「职业技术学院」「职业学院」的按专科处理其余按本科这份清单里专科院校集中在末尾一小撮。3.4 落库一张撑得起检索的表结构CREATE TABLE university ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, short_name TEXT, pinyin_init TEXT, -- 拼音首字母如 bjdx province TEXT, level TEXT, -- 本科 / 专科 media_file TEXT, -- 归一化后的 PNG 文件名 phash TEXT, -- 感知哈希用于近似去重 sha256 TEXT, -- 文件指纹用于增量更新 updated_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX idx_uni_province ON university(province);写入用executemany一次事务提交别逐条 commitimport sqlite3 conn sqlite3.connect(campus.db) conn.executemany( INSERT OR IGNORE INTO university (name, province, level) VALUES (?, ?, ?), [(n, guess_province(n), 专科 if 职业 in n else 本科) for n in names] ) conn.commit()INSERT OR IGNORE配合name上的 UNIQUE 约束重复跑同一份清单不会产生脏数据——切分脚本调参时会被反复执行这个幂等性很关键。4. 校徽图片去重与归一化pHash、Alpha 裁剪与 Sprite 打包4.1 先裁透明边距再算哈希同一所学校的校徽在文档里出现两次很正常。文件级 MD5 只能发现字节完全相同的文件转个格式、缩个尺寸、加圈白边就失效了所以要上感知哈希pHash。但直接对原始 PNG 算 pHash 会翻车Word 里的图片大多带大小不一的透明边距圆形校徽缩在画布中央两张视觉上一模一样的图因为留白不同汉明距离能到二十几。正确顺序是先按 Alpha 通道裁掉透明区再算哈希。from PIL import Image import imagehash def trimmed_hash(path, hash_size16, highfreq_factor8): 先去透明边距再算 pHash im Image.open(path).convert(RGBA) bbox im.getchannel(A).getbbox() # 非透明像素的包围盒 if bbox: im im.crop(bbox) # 压到白底避免透明像素的 RGB0 在灰度化时形成黑晕 bg Image.new(RGBA, im.size, (255, 255, 255, 255)) flat Image.alpha_composite(bg, im).convert(L) return imagehash.phash(flat, hash_sizehash_size, highfreq_factorhighfreq_factor)逻辑说明getbbox()返回非透明像素的最小外接矩形对纯色底校徽来说就是图形本身范围。alpha_composite那一步不做的话透明像素默认 RGB 是 0边缘会出现一圈黑晕pHash 的 DCT 系数会被黑边带偏。hash_size16输出 256 位哈希比默认 8 位细得多适合区分线条密集的校徽highfreq_factor控制 DCT 取样倍率调到 8 以上对噪声更宽容。4.2 两两比对五百张图的 O(n²) 完全够用from itertools import combinations hashes {name: trimmed_hash(p) for name, p in media_index.items()} threshold 10 dups [] for a, b in combinations(hashes, 2): d hashes[a] - hashes[b] # imagehash 重载了减法返回汉明距离 if d threshold: dups.append((a, b, d)) for a, b, d in sorted(dups, keylambda x: x[2])[:20]: print(f{a} - {b} 距离{d})逻辑说明五百张图两两组合是 12.5 万次比较每次本质是 256 位整数异或加 popcount纯 Python 几秒钟跑完没必要上 BK-tree 或向量索引。阈值 10 是经验值同一张图经过轻微重采样后距离通常在 4 以内不同学校的校徽哪怕都是圆形带麦穗距离普遍在 28 以上中间这段空当很好切。注意阈值调太低会漏掉「同一校徽的横版和竖版」调太高会把工科院校常见的齿轮麦穗构图误判成重复。分类目分阈值更稳——医学类院校的蛇杖图案天然就更像。同一组里保留字节数最大的那张其余不是删除而是记录进dup_of字段万一后续发现误判还能回溯。4.3 统一尺寸与背景合成规范校徽要进前端图标位或者打成 Sprite尺寸和背景必须统一否则不同学校之间大小参差、透明底和实底混排视觉上很乱。我一般按下面这套参数走参数取值理由输出尺寸256×256 PNG单张 3060 KBSprite 与单图两用内边距图形占画布 88%圆形校徽留约 6% 呼吸位避免贴边背景白底与透明双版本白底给文档用透明给前端用重采样Image.LANCZOS缩小时锐度最好色彩模式保留 RGBA部分校徽用了金色渐变灰度会塌from PIL import Image TARGET 256 PAD_RATIO 0.88 def normalize(src, dst): im Image.open(src).convert(RGBA) bbox im.getchannel(A).getbbox() if bbox: im im.crop(bbox) inner int(TARGET * PAD_RATIO) w, h im.size scale inner / max(w, h) # 按长边等比缩放 im im.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.LANCZOS) canvas Image.new(RGBA, (TARGET, TARGET), (255, 255, 255, 0)) canvas.paste(im, ((TARGET - im.width) // 2, (TARGET - im.height) // 2), im) canvas.save(dst, PNG, optimizeTrue) return dst逻辑说明scale按长边算而不是分别拉伸宽高否则方形校徽和长条形校徽会被压变形。canvas.paste的第三个参数是遮罩传im本身才能让 alpha 正确叠加漏掉这个参数会把透明区糊成黑色。optimizeTrue能让 PNG 体积降两成左右五百张图省下来的量相当可观。4.4 Sprite 打包与索引 JSON前端一次请求五百张图不现实打成一张 Sprite 加一份坐标索引最省流量。校徽都近似正方形直接按网格排布不需要装箱算法import json from PIL import Image COLS, CELL 25, 256 files sorted(normalized_files) rows (len(files) COLS - 1) // COLS sheet Image.new(RGBA, (COLS * CELL, rows * CELL), (255, 255, 255, 0)) index {} for i, path in enumerate(files): x, y (i % COLS) * CELL, (i // COLS) * CELL sheet.paste(Image.open(path), (x, y)) index[path.stem] {x: x, y: y, w: CELL, h: CELL} sheet.save(sprite.png, PNG, optimizeTrue) json.dump(index, open(sprite.json, w), ensure_asciiFalse)25 列 × 256 像素 6400 像素宽二十来行就是 6400×5200单张 PNG 大约 24 MB比五百个独立请求划算得多。CSS 里用background-position: -{x}px -{y}px直接取用索引 JSON 保留stem作为键跟数据库的media_file字段对齐。5. 进阶用 FTS5 trigram 和拼音首字母把校徽库接进检索5.1 unicode61 分词器为什么搜不到中文子串SQLite 的 FTS5 默认分词器unicode61按「非字母数字」切词而 CJK 字符在它眼里全是字母于是「华中科技大学」整条被切成一个 token。搜「科技」返回空搜「华中科技大」也返回空只有搜完整校名才行——对校徽检索完全不可用。SQLite 3.34 之后内置了trigram分词器按三字符滑窗建索引天然支持中文子串匹配CREATE VIRTUAL TABLE uni_fts USING fts5( name, pinyin_init, contentuniversity, content_rowidid, tokenizetrigram ); INSERT INTO uni_fts(uni_fts) VALUES(rebuild);contentuniversity把 FTS 表做成外部内容表索引里不重复存正文省一半空间rebuild在建好外部内容表之后必须执行一次否则索引是空的。查询词短于 3 个字时比如只搜「北大」trigram 匹配不到得靠拼音列兜底。5.2 拼音首字母列让 hzkjdx 也能命中华中科技大学from pypinyin import lazy_pinyin, Style def pinyin_init(name: str) - str: return .join(lazy_pinyin(name, styleStyle.FIRST_LETTER, errorsignore))[:8] conn.execute(UPDATE university SET pinyin_init ? WHERE id ?, (pinyin_init(华中科技大学), row_id)) # - hzkjdxerrorsignore让括号、数字这类非中文字符直接跳过避免拼音串里混进垃圾。截断到 8 位是刻意的——检索侧同样截断这样输入「hzkjdx」和「hzkjdxt」都能命中同一批结果。5.3 增量更新SHA-256 与 pHash 双键比对新拿到一份校徽文档时最怕全量重跑把人工修正过的字段覆盖掉。稳妥做法是双键比对先算新图的 SHA-256 精确查重命中就跳过不命中再算 pHash跟库里同名校徽比汉明距离小于阈值就当作「同一张图的不同版本」import hashlib, imagehash from PIL import Image def ingest(conn, name, path): blob open(path, rb).read() sha hashlib.sha256(blob).hexdigest() row conn.execute(SELECT id, phash FROM university WHERE name ?, (name,)).fetchone() if row is None: return new # 库里没有走新增流程 if conn.execute(SELECT 1 FROM university WHERE sha256 ?, (sha,)).fetchone(): return skip # 字节级完全一致 cur imagehash.phash(Image.open(path).convert(L)) if row[1] and (cur - imagehash.hex_to_hash(row[1])) 10: return skip # 视觉近似视为同图 conn.execute( UPDATE university SET sha256 ?, phash ?, media_file ?, updated_at datetime(now,localtime) WHERE id ?, (sha, str(cur), path, row[0]) ) return update逻辑说明sha256负责精确去重成本只有一次哈希phash只在精确比对失败后才算避免每张图都跑一遍 Pillow 解码。返回skip而不是update是为了保住人工填过的province修正值——不少团队在这里翻过车一次全量重跑把手工校对表全抹了。5.4 一个容易忽略的细节Sprite 的缓存键Sprite 打包完之后前端通常对sprite.png做长缓存。校徽库一旦增量更新文件内容变了但文件名没变浏览器还在拿旧图。最简单的办法是把 Sprite 内容的 SHA-256 前 8 位拼进文件名输出成sprite.a3f91c02.png每次打包自动生成新名跟index.json一起刷新索引里的media_file字段同步指向新的归一化文件整条链路就不需要手动清 CDN 缓存了。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表